Compare commits
57 Commits
1c27a66691
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de1ae70aa | ||
|
|
3901caf668 | ||
|
|
5f58c2c686 | ||
|
|
83525c2f59 | ||
|
|
627d7e4f59 | ||
|
|
9059b2b51e | ||
|
|
7d2eb1ebe2 | ||
|
|
3e2942b479 | ||
|
|
70fe3824b3 | ||
|
|
10f2b6f77f | ||
|
|
6dfcd0be06 | ||
|
|
1371eef648 | ||
|
|
ac0bc7b6a9 | ||
|
|
1c474e9692 | ||
|
|
0cd90e61db | ||
|
|
8de27de335 | ||
|
|
71b8258281 | ||
|
|
4516a74772 | ||
|
|
a42ccb5cda | ||
|
|
4a1db5ef27 | ||
|
|
c90c6210e1 | ||
|
|
c675b7b7ae | ||
|
|
ae0249be69 | ||
|
|
1d5bcc9dd6 | ||
|
|
98814299cf | ||
|
|
21fb2323eb | ||
|
|
5a9b6a053b | ||
|
|
429a98decb | ||
|
|
4299c6eb4b | ||
|
|
8dd680e28a | ||
|
|
3eb2852b78 | ||
|
|
5c2d17fe0a | ||
|
|
182ef5d83d | ||
|
|
0131982c34 | ||
|
|
491f05eee8 | ||
|
|
a38472e4a0 | ||
|
|
11b36b28a5 | ||
|
|
95c90dd699 | ||
|
|
9bcc5e5e21 | ||
|
|
0c57dd7886 | ||
|
|
f236fe2f90 | ||
|
|
b7b9818855 | ||
|
|
c773c7d3b8 | ||
|
|
edd2f2a274 | ||
|
|
00fd4a8cba | ||
|
|
13c21ed7de | ||
|
|
daae1a42e5 | ||
|
|
4e06318985 | ||
|
|
9f96d1f820 | ||
|
|
140d5e5a4d | ||
|
|
865d53ed9a | ||
|
|
c9ae99ebc8 | ||
|
|
9dbbb48ee0 | ||
|
|
1f26d5001b | ||
|
|
722ac4efd0 | ||
|
|
bba04f24c2 | ||
|
|
287a1ebb59 |
@@ -65,11 +65,24 @@
|
||||
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Bash(git diff:*)",
|
||||
"mcp__serena__find_symbol"
|
||||
"mcp__serena__find_symbol",
|
||||
"mcp__serena__get_symbols_overview",
|
||||
"Bash(git status:*)",
|
||||
"Bash(npx next:*)",
|
||||
"mcp__butler__butler__list_skills",
|
||||
"mcp__butler__butler__get_skill_usage",
|
||||
"mcp__butler__inspect_runtime_config",
|
||||
"mcp__butler__execute_task",
|
||||
"Bash(git -C /home/akira/develop/keinasystem remote -v)",
|
||||
"Bash(cat butler/skills/read_from_gitea*)",
|
||||
"Bash(bash ~/.claude/scripts/gitea.sh GET /repos/akira/keinasystem/issues/11)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"C:\\Users\\akira\\AppData\\Local\\Temp",
|
||||
"C:\\Users\\akira\\Develop\\keinasystem_t02"
|
||||
"C:\\Users\\akira\\Develop\\keinasystem_t02",
|
||||
"/home/akira/develop",
|
||||
"/home/akira/.docker",
|
||||
"/tmp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
26
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
26
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: バグ報告
|
||||
about: 不具合・予期しない動作の報告
|
||||
labels: バグ
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 問題の概要
|
||||
|
||||
## 再現手順
|
||||
|
||||
## 期待する動作
|
||||
|
||||
## 実際の動作
|
||||
|
||||
## 関連
|
||||
24
.gitea/ISSUE_TEMPLATE/design.md
Normal file
24
.gitea/ISSUE_TEMPLATE/design.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 設計・方針決定
|
||||
about: 実装前の設計議論・方針決定が必要なもの
|
||||
labels: "種別: 設計待ち"
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 問題・背景
|
||||
|
||||
## 検討事項
|
||||
|
||||
## 完了条件
|
||||
|
||||
## 関連
|
||||
24
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
24
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 機能追加
|
||||
about: 新機能・改善提案
|
||||
labels: 機能
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
## 背景・目的
|
||||
|
||||
## 完了条件
|
||||
|
||||
## 関連
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,3 +15,5 @@ postgres_data/
|
||||
nul
|
||||
|
||||
*.tsbuildinfo
|
||||
.mcp.json
|
||||
.codex
|
||||
|
||||
1
.serena/memories/project_overview.md
Normal file
1
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1 @@
|
||||
keinasystem_t02 は農業生産者向けの作付け計画・圃場管理システム。主要スタックは Django/DRF/PostgreSQL(PostGIS) のバックエンドと Next.js 14 App Router + TypeScript + Tailwind CSS のフロントエンド。backend/apps に fields, plans, weather, reports, fertilizer, materials, mail があり、frontend/src/app に各画面がある。ドキュメント駆動で、CLAUDE.md と document/*.md が重要な仕様ソース。Windows 環境で Docker Compose による開発を前提としている。
|
||||
1
.serena/memories/style_and_completion.md
Normal file
1
.serena/memories/style_and_completion.md
Normal file
@@ -0,0 +1 @@
|
||||
コードと仕様の変更はドキュメントドリブンで進める。仕様変更時は document 配下や CLAUDE.md の更新が重要。バックエンドは Django/DRF の標準的なモデル・serializer・viewset 構成、フロントは Next.js App Router と TypeScript。完了時は影響範囲に応じて少なくとも関連ドキュメント確認、必要な migration 確認、frontend lint (`npm run lint`) や対象 API/画面の動作確認を行う。既存の dirty worktree は勝手に戻さない。
|
||||
1
.serena/memories/suggested_commands.md
Normal file
1
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1 @@
|
||||
Windows 環境の主要コマンド: `git status`, `rg <pattern>`, `Get-ChildItem`, `Get-Content <file>`, `docker compose -f docker-compose.develop.yml up -d`, `docker compose exec backend python manage.py migrate`, `docker compose exec backend python manage.py makemigrations`, `docker compose exec backend python manage.py runserver 0.0.0.0:8000`, `cd frontend; npm install; npm run dev`, `cd frontend; npm run lint`。開発用 compose では backend は `python manage.py runserver 0.0.0.0:8000`、frontend は `npm run dev` を利用する。
|
||||
@@ -133,3 +133,17 @@ symbol_info_budget:
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
570
CLAUDE.md
570
CLAUDE.md
@@ -1,539 +1,127 @@
|
||||
# Keina System - Claude 向けガイド
|
||||
|
||||
> **最終更新**: 2026-03-05
|
||||
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
|
||||
## プロジェクト概要
|
||||
|
||||
## 📌 このファイルの目的
|
||||
|
||||
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
|
||||
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
|
||||
|
||||
## ⚠️ Claude への重要な指示
|
||||
|
||||
**このファイルは、セッションごとに必ず最初に読んでください。**
|
||||
|
||||
さらに、以下のルールを厳守してください:
|
||||
|
||||
### 📝 更新義務
|
||||
|
||||
**ドキュメントドリブンの徹底**
|
||||
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
|
||||
|
||||
**機能追加・変更時は、必ずこのファイルを更新すること。**
|
||||
|
||||
- ✅ 新機能実装時 → 「実装状況」セクションを更新
|
||||
- ✅ データモデル変更時 → 「データモデル概要」を更新
|
||||
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
|
||||
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
|
||||
- ✅ 問題解決時 → 「トラブルシューティング」に追加
|
||||
- ✅ 更新時は必ず「更新履歴」セクションに記録
|
||||
|
||||
|
||||
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 プロジェクト概要(30秒で理解)
|
||||
|
||||
**何を作っているか:**
|
||||
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
||||
ユーザーは65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理。
|
||||
|
||||
**ユーザー:**
|
||||
65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理
|
||||
**技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
|
||||
|
||||
**技術スタック:**
|
||||
- Backend: Django 5.2 + DRF + PostGIS
|
||||
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
|
||||
- Database: PostgreSQL 16 + PostGIS 3.4
|
||||
|
||||
**開発方針:**
|
||||
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||
**開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||
|
||||
---
|
||||
|
||||
## 📂 プロジェクト構造
|
||||
## 絶対に守るべき制約
|
||||
|
||||
1. **Field ↔ OfficialKyosaiField / OfficialChusankanField は M:N** — 決してFK (1:N) に戻さない
|
||||
2. **年度+圃場の組み合わせは1つの Plan のみ** (`unique_together`)
|
||||
3. **面積**: 表示=反(tan)、計算・保存=m2、変換: 1反=1000m2
|
||||
4. **FertilizationEntry.fertilizer は PROTECT** — 使用中の肥料は削除不可
|
||||
5. **3回同じコードを書くまでは抽象化しない**
|
||||
6. **ドキュメントドリブン**: 仕様変更時はまず関連ドキュメントから更新する
|
||||
|
||||
## コーディング規約
|
||||
|
||||
- **Backend**: Django ベストプラクティス、日本語フィールドは `verbose_name` で対応
|
||||
- **Frontend**: TypeScript strict mode、ESLint に従う
|
||||
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
||||
|
||||
---
|
||||
|
||||
## プロジェクト構造
|
||||
|
||||
```
|
||||
keinasystem_t02/
|
||||
├── CLAUDE.md # このファイル(Claude向けガイド)
|
||||
├── .cursor/
|
||||
│ └── rules/
|
||||
│ └── 30_Cursorガイド.md # Cursor専用ガイド
|
||||
├── document/ # 詳細設計書(人間向け)
|
||||
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
||||
│ ├── 01_プロダクトビジョン.md
|
||||
│ ├── 02_ユーザーストーリー.md
|
||||
│ ├── 03_データ仕様書.md
|
||||
│ ├── 04_画面設計書.md
|
||||
│ └── 05_実装優先順位.md
|
||||
├── CLAUDE.md # このファイル
|
||||
├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
|
||||
├── document/ # 設計書・マスタードキュメント
|
||||
├── backend/
|
||||
│ ├── keinasystem/ # Django設定
|
||||
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
|
||||
│ │ └── urls.py # ルートURL設定
|
||||
│ ├── keinasystem/ # Django設定 (settings.py, urls.py)
|
||||
│ └── apps/
|
||||
│ ├── fields/ # 圃場管理アプリ
|
||||
│ │ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
|
||||
│ │ ├── views.py # インポート機能、CRUD API
|
||||
│ │ └── urls.py
|
||||
│ ├── plans/ # 作付け計画アプリ
|
||||
│ │ ├── models.py # Plan, Crop(+base_temp), Variety
|
||||
│ │ └── views.py # 作付け計画API、集計API
|
||||
│ ├── weather/ # 気象データアプリ
|
||||
│ │ ├── models.py # WeatherRecord (1日1行)
|
||||
│ │ ├── views.py # sync(APIキー), records, summary, gdd, similarity
|
||||
│ │ ├── urls.py
|
||||
│ │ └── management/commands/fetch_weather.py # 初回一括取得・差分取得
|
||||
│ └── reports/ # 申請書生成アプリ
|
||||
│ ├── views.py # PDF生成API
|
||||
│ └── templates/ # PDF用HTMLテンプレート
|
||||
└── frontend/
|
||||
└── src/app/
|
||||
├── allocation/ # 作付け計画編集画面(メイン)
|
||||
├── fields/ # 圃場一覧・詳細
|
||||
├── reports/ # 申請書ダウンロード
|
||||
├── import/ # データ取込画面
|
||||
├── mail/
|
||||
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
|
||||
│ ├── history/ # メール処理履歴
|
||||
│ └── rules/ # 送信者ルール管理
|
||||
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
|
||||
└── settings/
|
||||
└── password/ # パスワード変更
|
||||
│ ├── fields/ # 圃場管理(Field, OfficialKyosaiField, OfficialChusankanField)
|
||||
│ ├── plans/ # 作付け計画(Plan, Crop, Variety)
|
||||
│ ├── weather/ # 気象データ(WeatherRecord)
|
||||
│ ├── reports/ # 申請書PDF生成
|
||||
│ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
|
||||
│ ├── workrecords/ # 作業記録索引
|
||||
│ └── mail/ # メールフィルタリング(Windmill連携)
|
||||
└── frontend/src/app/
|
||||
├── allocation/ # 作付け計画編集(メイン画面)
|
||||
├── fields/ # 圃場一覧・詳細
|
||||
├── fertilizer/ # 施肥計画・散布実績
|
||||
├── distribution/ # 運搬計画
|
||||
├── weather/ # 気象データ
|
||||
├── reports/ # 申請書DL
|
||||
├── import/ # データ取込
|
||||
├── mail/ # メール管理
|
||||
└── settings/ # パスワード変更
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ データモデル概要
|
||||
|
||||
### コアエンティティ
|
||||
|
||||
```
|
||||
Field (実圃場)
|
||||
├── 39筆の実際の農地
|
||||
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
|
||||
├── group_name, display_order (グループ分け・表示順)
|
||||
└── ManyToMany関係
|
||||
├── kyosai_fields (共済マスタ、M:N)
|
||||
└── chusankan_fields (中山間マスタ、M:N)
|
||||
|
||||
OfficialKyosaiField (共済マスタ)
|
||||
└── 31区画(水稲共済細目書用)
|
||||
|
||||
OfficialChusankanField (中山間マスタ)
|
||||
├── 71区画(中山間地域等直接支払交付金用)
|
||||
└── 17フィールド: c_id, chusankan_flag, oaza, aza, chiban,
|
||||
branch_num, land_type, area, planting_area,
|
||||
original_crop, manager, owner, slope,
|
||||
base_amount, steep_slope_addition, smart_agri_addition,
|
||||
payment_amount
|
||||
|
||||
Plan (作付け計画)
|
||||
├── field (FK to Field)
|
||||
├── year (年度)
|
||||
├── crop (FK to Crop)
|
||||
├── variety (FK to Variety, nullable)
|
||||
└── unique_together = ['field', 'year']
|
||||
|
||||
Crop (作物マスタ)
|
||||
├── name(米、トウモロコシ、エンドウ、野菜、その他)
|
||||
└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加
|
||||
|
||||
Variety (品種マスタ)
|
||||
├── crop (FK to Crop)
|
||||
├── name (品種名)
|
||||
└── unique_together = ['crop', 'name']
|
||||
|
||||
MailSender (送信者ルール)
|
||||
├── email (EmailField, nullable)
|
||||
├── domain (CharField, nullable)
|
||||
├── rule ('never_notify' | 'always_notify')
|
||||
└── ConstraintCheck: email/domain どちらか一方のみ
|
||||
|
||||
MailEmail (受信メール記録)
|
||||
├── account (gmail / gmail_service / hotmail / xserver1〜xserver6、旧データxserver)
|
||||
├── message_id (unique)
|
||||
├── sender_email, sender_domain
|
||||
├── subject, body_preview
|
||||
├── received_at, llm_verdict (important/not_important)
|
||||
├── notified_at (LINE通知日時、nullable)
|
||||
└── feedback (important/not_important/never_notify/always_notify, nullable)
|
||||
|
||||
MailNotificationToken (フィードバックURL用トークン)
|
||||
├── email (OneToOne FK to MailEmail)
|
||||
└── token (UUID, unique)
|
||||
|
||||
WeatherRecord (日次気象記録)
|
||||
├── date (DateField, unique)
|
||||
├── temp_mean, temp_max, temp_min (気温℃)
|
||||
├── sunshine_h (日照時間h)
|
||||
├── precip_mm (降水量mm)
|
||||
├── wind_max (最大風速m/s)
|
||||
└── pressure_min (最低気圧hPa)
|
||||
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
||||
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
||||
|
||||
Fertilizer (肥料マスタ)
|
||||
├── name(肥料名、必須・unique)
|
||||
├── maker(メーカー、任意)
|
||||
├── capacity_kg(1袋重量kg、任意)
|
||||
├── nitrogen_pct / phosphorus_pct / potassium_pct(成分%、任意)
|
||||
└── notes(備考、任意)
|
||||
|
||||
FertilizationPlan (施肥計画)
|
||||
├── name(計画名)
|
||||
├── year(年度)
|
||||
└── variety (FK to plans.Variety)
|
||||
|
||||
FertilizationEntry (施肥エントリ・中間テーブル)
|
||||
├── plan (FK to FertilizationPlan, CASCADE)
|
||||
├── field (FK to fields.Field, CASCADE)
|
||||
├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可
|
||||
├── bags(袋数、Decimal)
|
||||
└── unique_together = ['plan', 'field', 'fertilizer']
|
||||
|
||||
DeliveryPlan (運搬計画) ← 旧 DistributionPlan を置き換え(2026-03-16 再設計)
|
||||
├── year(年度)← 施肥計画へのFK廃止、年度ベースで全施肥計画を横断
|
||||
├── name(計画名)
|
||||
├── groups → DeliveryGroup
|
||||
└── trips → DeliveryTrip
|
||||
|
||||
DeliveryGroup (配送先グループ)
|
||||
├── delivery_plan (FK to DeliveryPlan, CASCADE)
|
||||
├── name(グループ名)
|
||||
├── order(表示順)
|
||||
└── unique_together = ['delivery_plan', 'name']
|
||||
|
||||
DeliveryGroupField (グループ圃場割り当て)
|
||||
├── delivery_plan (FK to DeliveryPlan, CASCADE) ← 一意制約用
|
||||
├── group (FK to DeliveryGroup, CASCADE)
|
||||
├── field (FK to fields.Field, PROTECT)
|
||||
└── unique_together = ['delivery_plan', 'field'] ← 1圃場=1グループ/1計画
|
||||
|
||||
DeliveryTrip (運搬回)
|
||||
├── delivery_plan (FK to DeliveryPlan, CASCADE)
|
||||
├── order(何回目)
|
||||
├── name(任意の名前)
|
||||
├── date(運搬日、nullable、デフォルト=1回目の日付)
|
||||
└── items → DeliveryTripItem
|
||||
|
||||
DeliveryTripItem (運搬明細)
|
||||
├── trip (FK to DeliveryTrip, CASCADE)
|
||||
├── field (FK to fields.Field, PROTECT)
|
||||
├── fertilizer (FK to Fertilizer, PROTECT)
|
||||
├── bags(袋数、Decimal)
|
||||
└── unique_together = ['trip', 'field', 'fertilizer']
|
||||
```
|
||||
|
||||
### 重要な設計判断
|
||||
|
||||
1. **M:N関係に変更**: 当初はM:1だったが、実運用で「1つの実圃場が複数の申請区画に紐づく」ケースが判明し、ManyToManyに変更(マイグレーション0003で実施)
|
||||
|
||||
2. **面積単位の二重管理**:
|
||||
- DB内部は `area_m2` (整数) で保存
|
||||
- 表示用に `area_tan` (反, Decimal) も保持
|
||||
- 理由: 申請書ではm2、農家の感覚では反
|
||||
|
||||
3. **品種は全作物で統一**:
|
||||
- 「作付けしない」も「その他」作物の品種として扱う
|
||||
- UI操作を統一するため
|
||||
|
||||
4. **グループ機能**:
|
||||
- `group_name` (エリアや用途によるグループ分け)
|
||||
- `display_order` (リスト表示時の順序)
|
||||
- マイグレーション0004で追加
|
||||
|
||||
5. **年度管理の設計方針**(⚠️ Phase 2 で必ず参照):
|
||||
- **作付け計画**: 年度セレクタで独立して来年度も選べる。選んだ年度はlocalStorageに保存して維持
|
||||
- **過去年度**: 「参照モード」として視覚的に区別(背景色・バナー)
|
||||
- **Phase 2 の栽培管理・販売管理**: グローバル作業年度を導入し、基本は今年度に従う
|
||||
- **栽培記録・作業日誌**: 日付中心設計、年度は日付から自動算出
|
||||
- 参考: ソリマチ農業簿記の年度管理方式(明示的に年度を選択、変更するまで固定)
|
||||
|
||||
---
|
||||
|
||||
## 🔑 重要な制約・ルール
|
||||
|
||||
### 絶対に守るべきこと
|
||||
|
||||
1. **データの整合性**
|
||||
- 年度 + 圃場の組み合わせは1つの Plan のみ (`unique_together`)
|
||||
- 作物 + 品種名の組み合わせは一意 (`unique_together`)
|
||||
|
||||
2. **面積の扱い**
|
||||
- 表示: 反 (tan)
|
||||
- 計算・保存: m2
|
||||
- 変換: 1反 = 1000m2 (正確には991.736m2だが、実運用では1000で統一)
|
||||
|
||||
3. **M:N関係の重要性**
|
||||
- Field と OfficialKyosaiField は M:N
|
||||
- Field と OfficialChusankanField は M:N
|
||||
- 決して FK (1:N) に戻さない
|
||||
|
||||
4. **シンプルさ優先**
|
||||
- 過度な抽象化を避ける
|
||||
- 3回同じコードを書くまでは抽象化しない
|
||||
- ユーザーは1人、パフォーマンス最適化は後回し
|
||||
|
||||
### コーディング規約
|
||||
|
||||
- **Backend**: Django のベストプラクティスに従う
|
||||
- **Frontend**: TypeScript strict mode、ESLint に従う
|
||||
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
||||
- **命名**: 日本語のフィールドは `verbose_name` で対応
|
||||
|
||||
---
|
||||
|
||||
## 📍 現在の実装状況
|
||||
|
||||
### ✅ 実装済み(Phase 1 - MVP)
|
||||
|
||||
1. **認証**: JWT認証(アクセストークン24h、リフレッシュトークン7日)
|
||||
2. **圃場管理**:
|
||||
- CRUD API (`/api/fields/`)
|
||||
- ODS/Excelインポート (`/api/fields/import/`)
|
||||
- グループ機能(マイグレーション0004)
|
||||
3. **作付け計画**:
|
||||
- 年度別の作付け計画 CRUD (`/api/plans/?year=2025`)
|
||||
- 前年度コピー機能 (`/api/plans/copy_from_previous_year/`)
|
||||
- 一括更新 (`/api/plans/bulk_update/`)
|
||||
- 集計API (`/api/plans/summary/?year=2025`)
|
||||
4. **申請書生成**:
|
||||
- 水稲共済細目書 PDF (`/api/reports/kyosai/?year=2025`)
|
||||
- 中山間交付金 PDF (`/api/reports/chusankan/?year=2025`)
|
||||
5. **フロントエンド**:
|
||||
- 作付け計画編集画面(集計サイドバー付き)
|
||||
- 圃場一覧・詳細・新規作成
|
||||
- データ取込画面
|
||||
- 申請書ダウンロード画面
|
||||
- ダッシュボード画面(概要サマリー、作物別集計、クイックアクセス)
|
||||
6. **対応付け可視化・紐づけ管理** (E-2):
|
||||
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
|
||||
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
|
||||
- 共通 LinkModal コンポーネント
|
||||
7. **メールフィルタリング機能**(Windmill連携):
|
||||
- Django `apps/mail` アプリ(MailSender, MailEmail, MailNotificationToken)
|
||||
- Windmill向けAPI(APIキー認証): `GET /api/mail/sender-rule/`, `GET /api/mail/sender-context/`, `POST /api/mail/emails/`, `GET /api/mail/stats/`
|
||||
- フィードバックAPI(認証不要・UUIDトークン): `GET/POST /api/mail/feedback/<token>/`
|
||||
- ルール管理API(JWT認証): `GET/POST/DELETE /api/mail/senders/`, `PATCH /api/mail/emails/<pk>/feedback/`
|
||||
- フィードバックページ: `/mail/feedback/[token]`(LINEからタップ一発、認証不要)
|
||||
- ルール管理ページ: `/mail/rules/`
|
||||
- 処理履歴ページ: `/mail/history/`
|
||||
- 対応アカウント: Gmail × 2、Xserver × 6(本番稼働中、`account` は `xserver1`〜`xserver6` で識別)
|
||||
- Windmill フロー: `f/mail/mail_filter`(本番: windmill.keinafarm.net にデプロイ済み、10分間隔スケジュール)
|
||||
- To: ヘッダー宛先補正を実装(Gmail先行取り込み時も @keinafarm.com 宛は xserver1〜xserver6 として記録/通知)
|
||||
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||
8. **パスワード変更機能**:
|
||||
- Backend: `POST /api/auth/change-password/`(JWT認証、`ChangePasswordView` in `keinasystem/urls.py`)
|
||||
- Frontend: `/settings/password` ページ
|
||||
- Navbar: KeyRound アイコンボタン(ログアウトボタンの左隣)
|
||||
9. **気象データ基盤**(Windmill連携):
|
||||
- Django `apps/weather` アプリ(WeatherRecord: 1日1行、2016-01-01〜)
|
||||
- データソース: Open-Meteo archive API(窪川 lat=33.213, lon=133.133)
|
||||
- Windmill向けAPI(APIキー認証): `POST /api/weather/sync/`(upsert、単一/リスト両対応)
|
||||
- フロントエンド向けAPI(JWT認証):
|
||||
- `GET /api/weather/records/?year=&start=&end=` 日次レコード一覧
|
||||
- `GET /api/weather/summary/?year=` 月別・年間サマリー(猛暑日・冬日数含む)
|
||||
- `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度(GDD)
|
||||
- `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較)
|
||||
- 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]`
|
||||
- Windmill フロー: `f/weather/weather_sync`(本番稼働中、毎朝6時 Asia/Tokyo)
|
||||
- `Crop.base_temp`(GDD計算の基準温度、default=0.0℃)をCropモデルに追加
|
||||
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
|
||||
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts)
|
||||
- **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測)
|
||||
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
||||
10. **施肥計画機能**(本番稼働中):
|
||||
- Django `apps/fertilizer` アプリ(Fertilizer, FertilizationPlan, FertilizationEntry)
|
||||
- API: `/api/fertilizer/fertilizers/`, `/api/fertilizer/plans/`, `/api/fertilizer/calculate/`, `/api/fertilizer/candidate_fields/`
|
||||
- PDF出力: `/api/fertilizer/plans/{id}/pdf/`(WeasyPrint、A4横向き)
|
||||
- FertilizationEntry.fertilizer は PROTECT(使用中の肥料は削除不可・migration 0002)
|
||||
- 自動計算3方式: per_tan(反当袋数)/ even(均等配分)/ nitrogen(反当チッソ)
|
||||
- 四捨五入トグル: `≈`(丸め)/ `↩`(元の計算値に戻す)
|
||||
- フロントエンド: `/fertilizer`(一覧)、`/fertilizer/masters`(肥料マスタ)、`/fertilizer/new`・`/fertilizer/[id]/edit`(編集)
|
||||
- 施肥機能全体で alert/confirm を廃止し、React インラインバナーでエラー表示
|
||||
- マスタードキュメント: `document/13_マスタードキュメント_施肥計画編.md`
|
||||
10. **施肥計画機能**:
|
||||
- Django `apps/fertilizer` アプリ(Fertilizer, FertilizationPlan, FertilizationEntry)
|
||||
- API(JWT認証): `GET/POST /api/fertilizer/fertilizers/`, `GET/POST /api/fertilizer/plans/?year=`, `GET /api/fertilizer/plans/{id}/pdf/`, `GET /api/fertilizer/candidate_fields/?year=&variety_id=`, `POST /api/fertilizer/calculate/`
|
||||
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
|
||||
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new`・`/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
|
||||
- スコープ外(将来): 購入管理
|
||||
11. **運搬計画機能**(旧・分配計画、2026-03-16 再設計中):
|
||||
- 旧 DistributionPlan/Group/GroupField → 新 DeliveryPlan/Group/GroupField/Trip/TripItem に移行
|
||||
- 施肥計画への直接FK廃止 → 年度ベースで全施肥計画を横断
|
||||
- 「軽トラ1回分」を基本単位とする運搬回(DeliveryTrip)を追加
|
||||
- 運搬明細(DeliveryTripItem)で圃場×肥料単位の袋数を管理
|
||||
- 運搬回ごとの日付記録(作業記録としても機能)
|
||||
- API(JWT認証): `/api/fertilizer/delivery/` 配下
|
||||
- PDF出力(A4横向き・回ごとに1ページ)
|
||||
- フロントエンド: `/distribution/`(一覧・編集)
|
||||
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
|
||||
|
||||
### 🚧 既知の課題・技術的負債
|
||||
|
||||
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
|
||||
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
||||
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
||||
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
||||
### 🔜 次の実装タスク(優先順)
|
||||
|
||||
差異レポートの全タスク(A-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2)は全件完了。
|
||||
Phase 2 のタスクに進む段階。
|
||||
|
||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
||||
|
||||
### 📅 次のマイルストーン(Phase 2)
|
||||
|
||||
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
|
||||
- 作業予定のカレンダー表示
|
||||
- モバイル対応の改善(スマホでの記録入力)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ よくある作業パターン
|
||||
## よくある作業パターン
|
||||
|
||||
### 新しいモデルを追加する場合
|
||||
|
||||
1. `apps/<app_name>/models.py` にモデルクラスを追加
|
||||
2. `python manage.py makemigrations`
|
||||
3. `python manage.py migrate`
|
||||
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
|
||||
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
|
||||
6. ViewSet 作成 (`apps/<app_name>/views.py`)
|
||||
7. URL登録 (`apps/<app_name>/urls.py`)
|
||||
1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
|
||||
5. Serializer → 6. ViewSet → 7. URL登録
|
||||
|
||||
### 新しいAPI エンドポイントを追加する場合
|
||||
### 新しいAPI / 画面を追加する場合
|
||||
|
||||
1. `apps/<app_name>/views.py` にビューを追加
|
||||
2. `apps/<app_name>/urls.py` にパスを追加
|
||||
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
|
||||
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
|
||||
|
||||
### 新しい画面を追加する場合
|
||||
|
||||
1. `frontend/src/app/<page_name>/page.tsx` を作成
|
||||
2. 必要に応じてレイアウト調整 (`layout.tsx`)
|
||||
3. API呼び出しは `useEffect` + `fetch` で実装
|
||||
4. ローディング状態、エラー状態を適切に処理
|
||||
- API: `views.py` → `urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
|
||||
- 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
|
||||
|
||||
---
|
||||
|
||||
## 🔍 トラブルシューティング
|
||||
|
||||
### 本番デプロイコマンド(必須)
|
||||
## デプロイ・トラブルシューティング
|
||||
|
||||
```bash
|
||||
# deploy.sh で git pull → down → build → up -d を一括実行
|
||||
# 本番デプロイ(git pull → build → up -d を一括実行)
|
||||
ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
|
||||
```
|
||||
|
||||
**Docker Compose 構成:**
|
||||
- `docker-compose.yml` = 本番用(Traefik連携、gunicorn、prod Dockerfile)
|
||||
- `docker-compose.develop.yml` = 開発用(ホットリロード、DEBUG=True)
|
||||
- 本番サーバー: `.env` → `.env.production` シンボリックリンク
|
||||
- `deploy.sh` = 本番デプロイ、`develop.bat` = ローカル開発起動
|
||||
|
||||
### 本番確認手順(デプロイ後の必須チェック)
|
||||
|
||||
**⚠️ Playwright(ビジュアルテスト)を使う前に、必ずcurlで先に確認すること。**
|
||||
curlはキャッシュの影響を受けず、偽装不可能な確認手段。
|
||||
|
||||
```bash
|
||||
# ステップ1: curlヘルスチェック(全9項目、所要約10秒)
|
||||
# 本番ヘルスチェック(9項目、curlベース)
|
||||
bash scripts/check_prod.sh claude keina1234
|
||||
# → 全 9 項目 PASS が出れば本番が正常稼働中
|
||||
|
||||
# ステップ2(任意): Playwrightでビジュアル確認する場合のプロンプト原則
|
||||
# - 「認証できなければ即中止して報告せよ」を必ず明記
|
||||
# - 「スクリーンショットには今日の日付が画面内に見えること」を要求
|
||||
# - 「成功の証跡(HTTP レスポンスの実テキスト)を必ず添付すること」を要求
|
||||
```
|
||||
|
||||
**本番バックエンドのマイグレーション適用(バックエンド変更時のみ):**
|
||||
```bash
|
||||
# 本番マイグレーション(バックエンド変更時のみ)
|
||||
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
||||
sudo -u keinasystem docker compose build backend && \
|
||||
sudo -u keinasystem docker compose up -d && \
|
||||
sleep 5 && \
|
||||
sudo -u keinasystem docker compose up -d && sleep 5 && \
|
||||
sudo -u keinasystem docker compose exec backend python manage.py migrate'
|
||||
```
|
||||
|
||||
### マイグレーションエラー
|
||||
|
||||
```bash
|
||||
# マイグレーションをリセット(開発環境のみ!)
|
||||
docker-compose exec backend python manage.py migrate <app_name> zero
|
||||
docker-compose exec backend python manage.py makemigrations
|
||||
docker-compose exec backend python manage.py migrate
|
||||
```
|
||||
|
||||
### CORS エラー
|
||||
|
||||
- `backend/keinasystem/settings.py` の `CORS_ALLOWED_ORIGINS` を確認
|
||||
- 現在は `http://localhost:3000` と `http://127.0.0.1:3000` を許可
|
||||
|
||||
### JWT トークンエラー
|
||||
|
||||
- トークンの有効期限を確認(アクセストークン: 24時間)
|
||||
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`)
|
||||
|
||||
### PDF 生成エラー
|
||||
|
||||
- WeasyPrint のインストールを確認
|
||||
- 日本語フォントの設定を確認(HTMLテンプレートのCSS)
|
||||
- **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
|
||||
- **CORS**: `settings.py` の `CORS_ALLOWED_ORIGINS`(localhost:3000 許可済み)
|
||||
- **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
|
||||
|
||||
---
|
||||
|
||||
## 📚 詳細情報へのリンク
|
||||
## マスタードキュメント(機能別リファレンス)
|
||||
|
||||
### マスタードキュメント(機能別の網羅的リファレンス)
|
||||
特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
|
||||
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
|
||||
|
||||
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。**
|
||||
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が
|
||||
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
|
||||
|
||||
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
||||
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
|
||||
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md`
|
||||
- **分配計画機能**: `document/14_マスタードキュメント_分配計画編.md`
|
||||
|
||||
### 設計ドキュメント(プロジェクト横断)
|
||||
|
||||
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
|
||||
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
|
||||
- **データモデル詳細**: `document/03_データ仕様書.md`
|
||||
- **画面設計**: `document/04_画面設計書.md`
|
||||
- **実装手順**: `document/00_Gemini向け統合指示書.md`
|
||||
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
|
||||
| 機能 | ドキュメント |
|
||||
|------|------------|
|
||||
| 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
|
||||
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
|
||||
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
|
||||
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
|
||||
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
|
||||
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
|
||||
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
|
||||
| データモデル全体 | `document/03_データ仕様書.md` |
|
||||
|
||||
---
|
||||
|
||||
## 💡 新しいセッションでの推奨フロー
|
||||
## セッション開始・終了フロー
|
||||
|
||||
### 開始時
|
||||
1. この `CLAUDE.md` を読む
|
||||
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md`)
|
||||
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照
|
||||
4. 実装・修正を行う
|
||||
5. 重要な設計判断があれば、この `CLAUDE.md` と該当マスタードキュメントを更新
|
||||
|
||||
---
|
||||
|
||||
## 📝 更新履歴
|
||||
|
||||
- 2026-03-16: 分配計画を「運搬計画」に再設計。実運用のワークフロー(軽トラ複数回・複数施肥計画混在・肥料指定)に合わせ、DeliveryPlan/Trip/TripItem モデルへ移行。施肥計画へのFK廃止→年度ベース。マスタードキュメント14を全面改訂
|
||||
|
||||
- 2026-03-05: メール通知機能を更新。MailEmail.account を xserver1〜xserver6 で識別可能に変更。Windmill mail_filter に To ヘッダー宛先補正を追加し、Gmail先行取り込みでも Xserver 宛先ラベルが崩れないよう修正。マスタードキュメント/仕様書を同期。
|
||||
|
||||
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除
|
||||
- 2026-03-02: 分配計画機能を実装。`apps/fertilizer` に DistributionPlan/DistributionGroup/DistributionGroupField 追加、API `/api/fertilizer/distribution/`、PDF出力(A4横・グループ★行+圃場サブ行)、フロントエンド `/distribution/`。マスタードキュメント `document/14_マスタードキュメント_分配計画編.md` 追加
|
||||
- 2026-03-01: 施肥計画機能を実装・本番稼働。`apps/fertilizer`(Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力, PROTECT migration 0002)、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。施肥機能全体で alert/confirm 廃止・インラインバナーに統一。マスタードキュメント `document/13_マスタードキュメント_施肥計画編.md` 追加
|
||||
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`(WeatherRecord, 5 API)、Windmill `f/weather/weather_sync`(毎朝6時)、フロントエンド `/weather`(年別集計・期間指定・Rechartsグラフ)。`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
|
||||
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
|
||||
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
||||
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
|
||||
- 2026-02-18: E-2(対応付け可視化・紐づけ管理)仕様追加。画面設計書・差異レポート・次タスク一覧を更新。完了済みタスク(A-8, D-1〜D-4, E-1)を既知の課題から除外
|
||||
- 2026-02-17: ドキュメント一斉更新(差異レポートA〜E反映、CSV→PDF統一、M:N関係、中山間モデル17列化、インライン編集方式、Navbar追加、既知の課題・次タスク一覧追加)
|
||||
- 2026-02-16: 初版作成(ハイブリッドアプローチの方針決定)
|
||||
|
||||
2. `HANDOVER.md` で前回の引き継ぎを確認する
|
||||
3. `TASK_CONTEXT.md` で現在の状況を把握する
|
||||
4. タスク対象の**マスタードキュメント**を読む
|
||||
|
||||
### 終了時(または作業の区切りで必ず実行)
|
||||
1. `HANDOVER.md` を定型フォーマットで更新する
|
||||
2. 重要な設計判断があれば `CLAUDE.md` と該当マスタードキュメントを更新
|
||||
3. 実装状況に変化があれば `TASK_CONTEXT.md` を更新
|
||||
|
||||
34
HANDOVER.md
Normal file
34
HANDOVER.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Goal
|
||||
Phase 1 全タスク完了後の安定運用。Phase 2 移行準備。
|
||||
|
||||
# Done
|
||||
- 施肥散布実績連携を実装・本番稼働(2026-03-17)
|
||||
- 運搬計画を再設計・本番稼働(2026-03-16)
|
||||
- CLAUDE.md を120行にスリム化、TASK_CONTEXT.md を分離
|
||||
|
||||
# In Progress
|
||||
- なし
|
||||
|
||||
# Pending
|
||||
- Phase 2 設計(栽培履歴管理、カレンダー表示、モバイル対応)
|
||||
- 自動テスト導入
|
||||
- フロントエンドの統一的エラーハンドリング
|
||||
|
||||
# Next Step
|
||||
Phase 2 の最初のタスクを決定する(栽培履歴管理 or カレンダー表示 or モバイル対応)
|
||||
|
||||
# Decisions
|
||||
- CLAUDE.md からデータモデル詳細・実装状況・更新履歴を分離(2026-03-18)
|
||||
- 実装状況は TASK_CONTEXT.md で管理する方針に変更
|
||||
|
||||
# Commands Run
|
||||
なし(ドキュメント整理のみ)
|
||||
|
||||
# Errors / Risks
|
||||
- 認証: ログアウト処理が未実装(トークン破棄のみ)
|
||||
- N+1問題が一部存在(現状はデータ量が少なく問題なし)
|
||||
|
||||
# Do Not Touch
|
||||
- Field ↔ OfficialKyosaiField / OfficialChusankanField の M:N 関係
|
||||
- FertilizationEntry.fertilizer の PROTECT 制約
|
||||
- 旧 is_confirmed / confirmed_at カラム(DB残留、UI未使用 — 将来のマイグレーションで削除予定)
|
||||
57
TASK_CONTEXT.md
Normal file
57
TASK_CONTEXT.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 現在の作業状況
|
||||
|
||||
> **最終更新**: 2026-04-04
|
||||
> **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中
|
||||
|
||||
## 実装済み機能(Phase 1 - MVP)
|
||||
|
||||
1. **認証**: JWT認証(アクセストークン24h、リフレッシュトークン7日)
|
||||
2. **圃場管理**: CRUD、ODS/Excelインポート、グループ機能
|
||||
3. **作付け計画**: 年度別CRUD、前年度コピー、一括更新、集計API
|
||||
4. **申請書生成**: 水稲共済細目書PDF、中山間交付金PDF
|
||||
5. **フロントエンド**: 作付け計画編集、圃場一覧/詳細、データ取込、申請書DL、ダッシュボード
|
||||
6. **対応付け可視化・紐づけ管理** (E-2): 圃場一覧「対応表」モード、共済/中山間リンク管理
|
||||
7. **メールフィルタリング**(Windmill連携):
|
||||
- Django `apps/mail`、Windmill向けAPI(APIキー認証)
|
||||
- フィードバックページ(認証不要・UUIDトークン)、ルール管理、処理履歴
|
||||
- 対応アカウント: Gmail × 2、Xserver × 6(本番稼働中、10分間隔)
|
||||
- To ヘッダー宛先補正実装済み
|
||||
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||
8. **パスワード変更**: `POST /api/auth/change-password/`、`/settings/password`
|
||||
9. **気象データ基盤**(Windmill連携):
|
||||
- Django `apps/weather`(WeatherRecord: 1日1行、2016-01-01〜)
|
||||
- Open-Meteo archive API(窪川)、Windmill毎朝6時同期
|
||||
- API: records, summary, gdd, similarity
|
||||
- フロントエンド `/weather`(年別集計・期間指定、Recharts)
|
||||
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
||||
10. **施肥計画**(本番稼働中):
|
||||
- 自動計算3方式: per_tan / even / nitrogen
|
||||
- 四捨五入トグル、PDF出力(A4横)、PROTECT制約
|
||||
- **散布実績**: 散布日単位記録、在庫USE連携、actual_bags再集計、WorkRecord自動生成
|
||||
- マスタードキュメント: `document/13_マスタードキュメント_施肥計画編.md`
|
||||
11. **運搬計画**(本番稼働中):
|
||||
- 旧 Distribution → Delivery に再設計(年度ベース、施肥計画FK廃止)
|
||||
- 軽トラ1回分単位、グループ一括割り当て、回間移動
|
||||
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
|
||||
12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert
|
||||
13. **田植え計画**(MVP実装):
|
||||
- 年度×品種単位で苗箱枚数・種もみ使用量を計画
|
||||
- 作物単位の種もみ在庫kg、品種単位の反当苗箱枚数デフォルト
|
||||
- 作付け計画から候補圃場を自動取得
|
||||
- マスタードキュメント: `document/16_マスタードキュメント_田植え計画編.md`
|
||||
|
||||
## 既知の課題・技術的負債
|
||||
|
||||
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
|
||||
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
||||
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
||||
4. **パフォーマンス**: N+1問題が一部存在
|
||||
|
||||
## 次のマイルストーン(Phase 2)
|
||||
|
||||
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
|
||||
- 作業予定のカレンダー表示
|
||||
- モバイル対応の改善(スマホでの記録入力)
|
||||
|
||||
差異レポートの全タスク(A-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2)は全件完了。
|
||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照。
|
||||
@@ -2,6 +2,7 @@ from django.contrib import admin
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +18,7 @@ class FertilizationEntryInline(admin.TabularInline):
|
||||
|
||||
@admin.register(FertilizationPlan)
|
||||
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'variety']
|
||||
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
|
||||
list_filter = ['year']
|
||||
inlines = [FertilizationEntryInline]
|
||||
|
||||
@@ -60,3 +61,15 @@ class DeliveryGroupAdmin(admin.ModelAdmin):
|
||||
class DeliveryTripAdmin(admin.ModelAdmin):
|
||||
list_display = ['delivery_plan', 'order', 'name', 'date']
|
||||
inlines = [DeliveryTripItemInline]
|
||||
|
||||
|
||||
class SpreadingSessionItemInline(admin.TabularInline):
|
||||
model = SpreadingSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(SpreadingSession)
|
||||
class SpreadingSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['year', 'date', 'name']
|
||||
list_filter = ['year', 'date']
|
||||
inlines = [SpreadingSessionItemInline]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0007_delivery_models'),
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SpreadingSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='散布日')),
|
||||
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '散布実績',
|
||||
'verbose_name_plural': '散布実績',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='fertilizationentry',
|
||||
name='actual_bags',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='実績袋数'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SpreadingSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('actual_bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='実散布袋数')),
|
||||
('planned_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='計画袋数スナップショット')),
|
||||
('delivered_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='運搬済み袋数スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.spreadingsession', verbose_name='散布実績')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '散布実績明細',
|
||||
'verbose_name_plural': '散布実績明細',
|
||||
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
|
||||
'unique_together': {('session', 'field', 'fertilizer')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -69,6 +69,13 @@ class FertilizationEntry(models.Model):
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||
actual_bags = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='実績袋数',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '施肥エントリ'
|
||||
@@ -179,3 +186,63 @@ class DeliveryTripItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}袋"
|
||||
|
||||
|
||||
class SpreadingSession(models.Model):
|
||||
"""散布日単位の実績"""
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='散布日')
|
||||
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '散布実績'
|
||||
verbose_name_plural = '散布実績'
|
||||
ordering = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
label = self.name.strip() or f'{self.date}'
|
||||
return f'{self.year} {label}'
|
||||
|
||||
|
||||
class SpreadingSessionItem(models.Model):
|
||||
"""散布実績明細:圃場×肥料ごとの実績"""
|
||||
session = models.ForeignKey(
|
||||
SpreadingSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='散布実績',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
|
||||
)
|
||||
fertilizer = models.ForeignKey(
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
actual_bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='実散布袋数')
|
||||
planned_bags_snapshot = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
verbose_name='計画袋数スナップショット',
|
||||
)
|
||||
delivered_bags_snapshot = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
verbose_name='運搬済み袋数スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '散布実績明細'
|
||||
verbose_name_plural = '散布実績明細'
|
||||
unique_together = [['session', 'field', 'fertilizer']]
|
||||
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'{self.session} / {self.field.name} / '
|
||||
f'{self.fertilizer.name}: {self.actual_bags}袋'
|
||||
)
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.workrecords.services import sync_delivery_work_record
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
DeliveryGroup,
|
||||
DeliveryGroupField,
|
||||
DeliveryPlan,
|
||||
DeliveryTrip,
|
||||
DeliveryTripItem,
|
||||
FertilizationEntry,
|
||||
FertilizationPlan,
|
||||
Fertilizer,
|
||||
SpreadingSession,
|
||||
SpreadingSessionItem,
|
||||
)
|
||||
from .services import sync_actual_bags_for_pairs, sync_spreading_session_side_effects
|
||||
|
||||
|
||||
class FertilizerSerializer(serializers.ModelSerializer):
|
||||
@@ -36,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = FertilizationEntry
|
||||
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'actual_bags',
|
||||
]
|
||||
|
||||
|
||||
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
@@ -45,15 +68,36 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
fertilizer_count = serializers.SerializerMethodField()
|
||||
planned_total_bags = serializers.SerializerMethodField()
|
||||
spread_total_bags = serializers.SerializerMethodField()
|
||||
remaining_total_bags = serializers.SerializerMethodField()
|
||||
spread_status = serializers.SerializerMethodField()
|
||||
is_confirmed = serializers.BooleanField(read_only=True)
|
||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||
is_variety_change_plan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = [
|
||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
||||
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
|
||||
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'calc_settings',
|
||||
'entries',
|
||||
'field_count',
|
||||
'fertilizer_count',
|
||||
'planned_total_bags',
|
||||
'spread_total_bags',
|
||||
'remaining_total_bags',
|
||||
'spread_status',
|
||||
'is_confirmed',
|
||||
'confirmed_at',
|
||||
'is_variety_change_plan',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_variety_name(self, obj):
|
||||
@@ -68,9 +112,35 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
def get_fertilizer_count(self, obj):
|
||||
return obj.entries.values('fertilizer').distinct().count()
|
||||
|
||||
def get_planned_total_bags(self, obj):
|
||||
total = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_spread_total_bags(self, obj):
|
||||
total = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_remaining_total_bags(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(planned - actual)
|
||||
|
||||
def get_spread_status(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
if actual <= 0:
|
||||
return 'unspread'
|
||||
if actual > planned:
|
||||
return 'over_applied'
|
||||
if actual < planned:
|
||||
return 'partial'
|
||||
return 'completed'
|
||||
|
||||
def get_is_variety_change_plan(self, obj):
|
||||
return obj.name.endswith('(品種変更移動)')
|
||||
|
||||
|
||||
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
"""保存用(entries を一括で受け取る)"""
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -80,7 +150,8 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = FertilizationPlan.objects.create(**validated_data)
|
||||
self._save_entries(plan, entries_data)
|
||||
pairs = self._save_entries(plan, entries_data)
|
||||
sync_actual_bags_for_pairs(plan.year, pairs)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -90,21 +161,23 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
self._save_entries(instance, entries_data)
|
||||
pairs = self._save_entries(instance, entries_data)
|
||||
sync_actual_bags_for_pairs(instance.year, pairs)
|
||||
return instance
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
pairs = set()
|
||||
for entry in entries_data:
|
||||
pairs.add((entry['field_id'], entry['fertilizer_id']))
|
||||
FertilizationEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
fertilizer_id=entry['fertilizer_id'],
|
||||
bags=entry['bags'],
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
# ─── 運搬計画 ────────────────────────────────────────────────────────────
|
||||
|
||||
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(source='field.id', read_only=True)
|
||||
name = serializers.CharField(source='field.name', read_only=True)
|
||||
@@ -128,18 +201,51 @@ class DeliveryGroupReadSerializer(serializers.ModelSerializer):
|
||||
class DeliveryTripItemSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
spread_bags = serializers.SerializerMethodField()
|
||||
remaining_bags = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DeliveryTripItem
|
||||
fields = ['id', 'field', 'field_name', 'fertilizer', 'fertilizer_name', 'bags']
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'spread_bags',
|
||||
'remaining_bags',
|
||||
]
|
||||
|
||||
def get_spread_bags(self, obj):
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=obj.trip.delivery_plan.year,
|
||||
field_id=obj.field_id,
|
||||
fertilizer_id=obj.fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
return str(total or Decimal('0'))
|
||||
|
||||
def get_remaining_bags(self, obj):
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=obj.trip.delivery_plan.year,
|
||||
field_id=obj.field_id,
|
||||
fertilizer_id=obj.fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
spread_total = total or Decimal('0')
|
||||
return str(obj.bags - spread_total)
|
||||
|
||||
|
||||
class DeliveryTripReadSerializer(serializers.ModelSerializer):
|
||||
items = DeliveryTripItemSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeliveryTrip
|
||||
fields = ['id', 'order', 'name', 'date', 'items']
|
||||
fields = ['id', 'order', 'name', 'date', 'work_record_id', 'items']
|
||||
|
||||
|
||||
class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
||||
@@ -149,8 +255,13 @@ class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DeliveryPlan
|
||||
fields = [
|
||||
'id', 'year', 'name', 'group_count', 'trip_count',
|
||||
'created_at', 'updated_at',
|
||||
'id',
|
||||
'year',
|
||||
'name',
|
||||
'group_count',
|
||||
'trip_count',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_group_count(self, obj):
|
||||
@@ -170,20 +281,27 @@ class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DeliveryPlan
|
||||
fields = [
|
||||
'id', 'year', 'name', 'groups', 'trips',
|
||||
'unassigned_fields', 'available_fertilizers', 'all_entries',
|
||||
'created_at', 'updated_at',
|
||||
'id',
|
||||
'year',
|
||||
'name',
|
||||
'groups',
|
||||
'trips',
|
||||
'unassigned_fields',
|
||||
'available_fertilizers',
|
||||
'all_entries',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_unassigned_fields(self, obj):
|
||||
assigned_ids = DeliveryGroupField.objects.filter(
|
||||
delivery_plan=obj
|
||||
).values_list('field_id', flat=True)
|
||||
# 年度の施肥計画に含まれる全圃場
|
||||
plan_field_ids = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).values_list('field_id', flat=True).distinct()
|
||||
from apps.fields.models import Field
|
||||
|
||||
unassigned = Field.objects.filter(
|
||||
id__in=plan_field_ids
|
||||
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
||||
@@ -197,20 +315,20 @@ class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
||||
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
||||
|
||||
def get_all_entries(self, obj):
|
||||
"""年度の全施肥計画のエントリ(フロントで袋数計算に使用)"""
|
||||
entries = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).select_related('field', 'fertilizer')
|
||||
return [
|
||||
{
|
||||
'field': e.field_id,
|
||||
'field_name': e.field.name,
|
||||
'field_area_tan': str(e.field.area_tan),
|
||||
'fertilizer': e.fertilizer_id,
|
||||
'fertilizer_name': e.fertilizer.name,
|
||||
'bags': str(e.bags),
|
||||
'field': entry.field_id,
|
||||
'field_name': entry.field.name,
|
||||
'field_area_tan': str(entry.field.area_tan),
|
||||
'fertilizer': entry.fertilizer_id,
|
||||
'fertilizer_name': entry.fertilizer.name,
|
||||
'bags': str(entry.bags),
|
||||
'actual_bags': str(entry.actual_bags) if entry.actual_bags is not None else None,
|
||||
}
|
||||
for e in entries
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
|
||||
@@ -245,13 +363,13 @@ class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
|
||||
return instance
|
||||
|
||||
def _save_groups(self, plan, groups_data):
|
||||
for g_data in groups_data:
|
||||
for group_data in groups_data:
|
||||
group = DeliveryGroup.objects.create(
|
||||
delivery_plan=plan,
|
||||
name=g_data['name'],
|
||||
order=g_data.get('order', 0),
|
||||
name=group_data['name'],
|
||||
order=group_data.get('order', 0),
|
||||
)
|
||||
for field_id in g_data.get('field_ids', []):
|
||||
for field_id in group_data.get('field_ids', []):
|
||||
DeliveryGroupField.objects.create(
|
||||
delivery_plan=plan,
|
||||
group=group,
|
||||
@@ -259,17 +377,116 @@ class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
def _save_trips(self, plan, trips_data):
|
||||
for t_data in trips_data:
|
||||
for trip_data in trips_data:
|
||||
trip = DeliveryTrip.objects.create(
|
||||
delivery_plan=plan,
|
||||
order=t_data.get('order', 0),
|
||||
name=t_data.get('name', ''),
|
||||
date=t_data.get('date'),
|
||||
order=trip_data.get('order', 0),
|
||||
name=trip_data.get('name', ''),
|
||||
date=trip_data.get('date'),
|
||||
)
|
||||
for item in t_data.get('items', []):
|
||||
for item in trip_data.get('items', []):
|
||||
DeliveryTripItem.objects.create(
|
||||
trip=trip,
|
||||
field_id=item['field_id'],
|
||||
fertilizer_id=item['fertilizer_id'],
|
||||
bags=item['bags'],
|
||||
)
|
||||
sync_delivery_work_record(trip)
|
||||
|
||||
|
||||
class SpreadingSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'actual_bags',
|
||||
'planned_bags_snapshot',
|
||||
'delivered_bags_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class SpreadingSessionSerializer(serializers.ModelSerializer):
|
||||
items = SpreadingSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'name',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
|
||||
class SpreadingSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field_id = serializers.IntegerField()
|
||||
fertilizer_id = serializers.IntegerField()
|
||||
actual_bags = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
planned_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
delivered_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
|
||||
|
||||
class SpreadingSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = SpreadingSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSession
|
||||
fields = ['id', 'year', 'date', 'name', 'notes', 'items']
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
if item['actual_bags'] <= 0:
|
||||
raise serializers.ValidationError('actual_bags は 0 より大きい値を指定してください。')
|
||||
key = (item['field_id'], item['fertilizer_id'])
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で field + fertilizer を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
session = SpreadingSession.objects.create(**validated_data)
|
||||
new_pairs = self._replace_items(session, items_data)
|
||||
sync_spreading_session_side_effects(session, new_pairs)
|
||||
return session
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
old_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
new_pairs = self._replace_items(instance, items_data)
|
||||
sync_spreading_session_side_effects(instance, old_pairs | new_pairs)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
new_pairs = set()
|
||||
for item in items_data:
|
||||
new_pairs.add((item['field_id'], item['fertilizer_id']))
|
||||
SpreadingSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field_id'],
|
||||
fertilizer_id=item['fertilizer_id'],
|
||||
actual_bags=item['actual_bags'],
|
||||
planned_bags_snapshot=item['planned_bags_snapshot'],
|
||||
delivered_bags_snapshot=item['delivered_bags_snapshot'],
|
||||
)
|
||||
return new_pairs
|
||||
|
||||
196
backend/apps/fertilizer/services.py
Normal file
196
backend/apps/fertilizer/services.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
|
||||
from apps.materials.stock_service import create_reserves_for_plan, delete_reserves_for_plan
|
||||
from apps.materials.models import StockTransaction
|
||||
from apps.workrecords.services import sync_spreading_work_record
|
||||
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem
|
||||
|
||||
|
||||
class FertilizationPlanMergeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FertilizationPlanMergeConflict(FertilizationPlanMergeError):
|
||||
def __init__(self, conflicts):
|
||||
super().__init__('merge conflict')
|
||||
self.conflicts = conflicts
|
||||
|
||||
|
||||
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
|
||||
pairs = {
|
||||
(int(field_id), int(fertilizer_id))
|
||||
for field_id, fertilizer_id in field_fertilizer_pairs
|
||||
}
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
for field_id, fertilizer_id in pairs:
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=year,
|
||||
field_id=field_id,
|
||||
fertilizer_id=fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
FertilizationEntry.objects.filter(
|
||||
plan__year=year,
|
||||
field_id=field_id,
|
||||
fertilizer_id=fertilizer_id,
|
||||
).update(actual_bags=total)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_spreading_session_side_effects(session, field_fertilizer_pairs):
|
||||
sync_actual_bags_for_pairs(session.year, field_fertilizer_pairs)
|
||||
sync_stock_uses_for_spreading_session(session)
|
||||
sync_spreading_work_record(session)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_stock_uses_for_spreading_session(session):
|
||||
StockTransaction.objects.filter(spreading_item__session=session).delete()
|
||||
|
||||
session_items = session.items.select_related('fertilizer__material')
|
||||
for item in session_items:
|
||||
material = getattr(item.fertilizer, 'material', None)
|
||||
if material is None:
|
||||
continue
|
||||
StockTransaction.objects.create(
|
||||
material=material,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
quantity=item.actual_bags,
|
||||
occurred_on=session.date,
|
||||
note=f'散布実績「{session.name.strip() or session.date}」',
|
||||
fertilization_plan=None,
|
||||
spreading_item=item,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def move_fertilization_entries_for_variety_change(change):
|
||||
moved_count = 0
|
||||
old_variety_id = change.old_variety_id
|
||||
new_variety = change.new_variety
|
||||
if old_variety_id is None or new_variety is None:
|
||||
return 0
|
||||
|
||||
old_plans = (
|
||||
FertilizationPlan.objects
|
||||
.filter(
|
||||
year=change.year,
|
||||
variety_id=old_variety_id,
|
||||
entries__field_id=change.field_id,
|
||||
)
|
||||
.distinct()
|
||||
.prefetch_related('entries')
|
||||
)
|
||||
|
||||
for old_plan in old_plans:
|
||||
entries_to_move = list(
|
||||
old_plan.entries.filter(
|
||||
field_id=change.field_id,
|
||||
).order_by('id')
|
||||
)
|
||||
if not entries_to_move:
|
||||
continue
|
||||
|
||||
new_plan = FertilizationPlan.objects.create(
|
||||
name=f'{change.year}年度 {new_variety.name} 施肥計画(品種変更移動)',
|
||||
year=change.year,
|
||||
variety=new_variety,
|
||||
calc_settings=old_plan.calc_settings,
|
||||
)
|
||||
|
||||
FertilizationEntry.objects.filter(
|
||||
id__in=[entry.id for entry in entries_to_move]
|
||||
).update(plan=new_plan)
|
||||
|
||||
create_reserves_for_plan(old_plan)
|
||||
create_reserves_for_plan(new_plan)
|
||||
moved_count += len(entries_to_move)
|
||||
|
||||
return moved_count
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def merge_fertilization_plan_into(source_plan, target_plan):
|
||||
if source_plan.id == target_plan.id:
|
||||
raise FertilizationPlanMergeError('同じ施肥計画にはマージできません。')
|
||||
if source_plan.year != target_plan.year:
|
||||
raise FertilizationPlanMergeError('年度が異なる施肥計画にはマージできません。')
|
||||
if source_plan.variety_id != target_plan.variety_id:
|
||||
raise FertilizationPlanMergeError('品種が異なる施肥計画にはマージできません。')
|
||||
if source_plan.is_confirmed or target_plan.is_confirmed:
|
||||
raise FertilizationPlanMergeError('散布確定済みの施肥計画はマージできません。')
|
||||
|
||||
source_entries = list(
|
||||
source_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
|
||||
)
|
||||
if not source_entries:
|
||||
raise FertilizationPlanMergeError('移動元の施肥計画にマージ対象の entry がありません。')
|
||||
|
||||
source_pairs = {(entry.field_id, entry.fertilizer_id) for entry in source_entries}
|
||||
target_entries = list(
|
||||
target_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
|
||||
)
|
||||
target_pairs = {(entry.field_id, entry.fertilizer_id): entry for entry in target_entries}
|
||||
|
||||
conflicts = [
|
||||
{
|
||||
'field_id': entry.field_id,
|
||||
'field_name': entry.field.name,
|
||||
'fertilizer_id': entry.fertilizer_id,
|
||||
'fertilizer_name': entry.fertilizer.name,
|
||||
}
|
||||
for entry in source_entries
|
||||
if (entry.field_id, entry.fertilizer_id) in target_pairs
|
||||
]
|
||||
if conflicts:
|
||||
raise FertilizationPlanMergeConflict(conflicts)
|
||||
|
||||
FertilizationEntry.objects.filter(
|
||||
id__in=[entry.id for entry in source_entries]
|
||||
).update(plan=target_plan)
|
||||
|
||||
target_plan.calc_settings = _merge_calc_settings(
|
||||
target_plan.calc_settings,
|
||||
source_plan.calc_settings,
|
||||
)
|
||||
target_plan.save()
|
||||
|
||||
create_reserves_for_plan(target_plan)
|
||||
|
||||
moved_count = len(source_entries)
|
||||
deleted_source_plan = False
|
||||
if not FertilizationEntry.objects.filter(plan=source_plan).exists():
|
||||
delete_reserves_for_plan(source_plan)
|
||||
source_plan.delete()
|
||||
deleted_source_plan = True
|
||||
else:
|
||||
create_reserves_for_plan(source_plan)
|
||||
|
||||
return {
|
||||
'moved_entry_count': moved_count,
|
||||
'deleted_source_plan': deleted_source_plan,
|
||||
}
|
||||
|
||||
|
||||
def _merge_calc_settings(target_settings, source_settings):
|
||||
merged = list(target_settings or [])
|
||||
existing_fertilizer_ids = {
|
||||
setting.get('fertilizer_id')
|
||||
for setting in merged
|
||||
if isinstance(setting, dict)
|
||||
}
|
||||
for setting in source_settings or []:
|
||||
if not isinstance(setting, dict):
|
||||
continue
|
||||
fertilizer_id = setting.get('fertilizer_id')
|
||||
if fertilizer_id in existing_fertilizer_ids:
|
||||
continue
|
||||
merged.append(setting)
|
||||
existing_fertilizer_ids.add(fertilizer_id)
|
||||
return merged
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load fertilizer_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
@@ -47,7 +48,7 @@
|
||||
<tr class="group-row">
|
||||
<td class="col-name"><span class="group-star">★</span>{{ group.name }}</td>
|
||||
{% for total in group.totals %}
|
||||
<td>{% if total %}{{ total }}{% else %}<span class="zero">-</span>{% endif %}</td>
|
||||
<td>{% if total %}{{ total|bags_fmt }}{% else %}<span class="zero">-</span>{% endif %}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{# 圃場サブ行 #}
|
||||
@@ -55,7 +56,7 @@
|
||||
<tr class="field-row">
|
||||
<td class="col-name field-indent">{{ row.field.name }}({{ row.field.area_tan }}反)</td>
|
||||
{% for cell in row.cells %}
|
||||
<td>{% if cell %}{{ cell }}{% else %}<span class="zero">-</span>{% endif %}</td>
|
||||
<td>{% if cell %}{{ cell|bags_fmt }}{% else %}<span class="zero">-</span>{% endif %}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -65,7 +66,7 @@
|
||||
<tr class="total-row">
|
||||
<td class="col-name">合計</td>
|
||||
{% for total in page.fert_totals %}
|
||||
<td>{{ total }}</td>
|
||||
<td>{{ total|bags_fmt }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
0
backend/apps/fertilizer/templatetags/__init__.py
Normal file
0
backend/apps/fertilizer/templatetags/__init__.py
Normal file
15
backend/apps/fertilizer/templatetags/fertilizer_tags.py
Normal file
15
backend/apps/fertilizer/templatetags/fertilizer_tags.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from decimal import Decimal
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def bags_fmt(value):
|
||||
"""袋数を整数 or 小数点以下1桁で表示する。"""
|
||||
if value is None or value == '':
|
||||
return value
|
||||
d = Decimal(str(value))
|
||||
if d == d.to_integral_value():
|
||||
return str(int(d))
|
||||
return str(d.quantize(Decimal('0.1')))
|
||||
156
backend/apps/fertilizer/tests.py
Normal file
156
backend/apps/fertilizer/tests.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import Material, StockTransaction
|
||||
from apps.materials.stock_service import create_reserves_for_plan
|
||||
from apps.plans.models import Crop, Variety
|
||||
|
||||
from .models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||
|
||||
|
||||
class FertilizationPlanMergeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username='merge-user',
|
||||
password='secret12345',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
crop = Crop.objects.create(name='水稲')
|
||||
self.variety = Variety.objects.create(crop=crop, name='たちはるか特栽')
|
||||
self.field_a = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
self.field_b = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
material_a = Material.objects.create(
|
||||
name='高度化成14号',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
material_b = Material.objects.create(
|
||||
name='分げつ一発',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
self.fertilizer_a = Fertilizer.objects.create(name='高度化成14号', material=material_a)
|
||||
self.fertilizer_b = Fertilizer.objects.create(name='分げつ一発', material=material_b)
|
||||
|
||||
def test_merge_into_moves_entries_and_deletes_empty_source_plan(self):
|
||||
target_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 元肥',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
calc_settings=[{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'}],
|
||||
)
|
||||
source_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
calc_settings=[{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'}],
|
||||
)
|
||||
target_entry = FertilizationEntry.objects.create(
|
||||
plan=target_plan,
|
||||
field=self.field_a,
|
||||
fertilizer=self.fertilizer_a,
|
||||
bags='3.00',
|
||||
actual_bags='1.0000',
|
||||
)
|
||||
source_entry = FertilizationEntry.objects.create(
|
||||
plan=source_plan,
|
||||
field=self.field_b,
|
||||
fertilizer=self.fertilizer_b,
|
||||
bags='2.00',
|
||||
actual_bags='2.0000',
|
||||
)
|
||||
create_reserves_for_plan(target_plan)
|
||||
create_reserves_for_plan(source_plan)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
|
||||
{'target_plan_id': target_plan.id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['moved_entry_count'], 1)
|
||||
self.assertTrue(response.data['deleted_source_plan'])
|
||||
|
||||
source_entry.refresh_from_db()
|
||||
self.assertEqual(source_entry.plan_id, target_plan.id)
|
||||
self.assertFalse(FertilizationPlan.objects.filter(id=source_plan.id).exists())
|
||||
|
||||
target_plan.refresh_from_db()
|
||||
self.assertEqual(
|
||||
target_plan.calc_settings,
|
||||
[
|
||||
{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'},
|
||||
{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'},
|
||||
],
|
||||
)
|
||||
reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=target_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
self.assertEqual(len(reserves), 2)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in reserves},
|
||||
{
|
||||
(self.fertilizer_a.material_id, Decimal(str(target_entry.bags))),
|
||||
(self.fertilizer_b.material_id, Decimal(str(source_entry.bags))),
|
||||
},
|
||||
)
|
||||
|
||||
def test_merge_into_stops_on_field_fertilizer_conflict(self):
|
||||
target_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 元肥',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
)
|
||||
source_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
)
|
||||
FertilizationEntry.objects.create(
|
||||
plan=target_plan,
|
||||
field=self.field_a,
|
||||
fertilizer=self.fertilizer_a,
|
||||
bags='3.00',
|
||||
)
|
||||
source_entry = FertilizationEntry.objects.create(
|
||||
plan=source_plan,
|
||||
field=self.field_a,
|
||||
fertilizer=self.fertilizer_a,
|
||||
bags='2.00',
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
|
||||
{'target_plan_id': target_plan.id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(len(response.data['conflicts']), 1)
|
||||
source_entry.refresh_from_db()
|
||||
self.assertEqual(source_entry.plan_id, source_plan.id)
|
||||
self.assertTrue(FertilizationPlan.objects.filter(id=source_plan.id).exists())
|
||||
@@ -6,9 +6,11 @@ router = DefaultRouter()
|
||||
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
||||
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||
path('spreading/candidates/', views.SpreadingCandidatesView.as_view(), name='spreading-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -12,15 +12,14 @@ from weasyprint import HTML
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.stock_service import (
|
||||
confirm_spreading as confirm_spreading_service,
|
||||
create_reserves_for_plan,
|
||||
delete_reserves_for_plan,
|
||||
unconfirm_spreading,
|
||||
)
|
||||
from apps.plans.models import Plan, Variety
|
||||
from apps.plans.models import Plan
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
from .serializers import (
|
||||
FertilizerSerializer,
|
||||
@@ -29,6 +28,14 @@ from .serializers import (
|
||||
DeliveryPlanListSerializer,
|
||||
DeliveryPlanReadSerializer,
|
||||
DeliveryPlanWriteSerializer,
|
||||
SpreadingSessionSerializer,
|
||||
SpreadingSessionWriteSerializer,
|
||||
)
|
||||
from .services import (
|
||||
FertilizationPlanMergeConflict,
|
||||
FertilizationPlanMergeError,
|
||||
merge_fertilization_plan_into,
|
||||
sync_actual_bags_for_pairs,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,8 +67,6 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.is_confirmed:
|
||||
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
|
||||
instance = serializer.save()
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
@@ -123,67 +128,54 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='confirm_spreading')
|
||||
def confirm_spreading(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
@action(detail=True, methods=['get'])
|
||||
def merge_targets(self, request, pk=None):
|
||||
source_plan = self.get_object()
|
||||
targets = (
|
||||
FertilizationPlan.objects
|
||||
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
|
||||
.exclude(id=source_plan.id)
|
||||
.prefetch_related('entries')
|
||||
.order_by('-updated_at', 'id')
|
||||
)
|
||||
data = [
|
||||
{
|
||||
'id': plan.id,
|
||||
'name': plan.name,
|
||||
'field_count': plan.entries.values('field').distinct().count(),
|
||||
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
|
||||
'is_confirmed': plan.is_confirmed,
|
||||
}
|
||||
for plan in targets
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
if plan.is_confirmed:
|
||||
@action(detail=True, methods=['post'])
|
||||
def merge_into(self, request, pk=None):
|
||||
source_plan = self.get_object()
|
||||
target_plan_id = request.data.get('target_plan_id')
|
||||
if not target_plan_id:
|
||||
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
|
||||
except FertilizationPlan.DoesNotExist:
|
||||
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
result = merge_fertilization_plan_into(source_plan, target_plan)
|
||||
except FertilizationPlanMergeConflict as exc:
|
||||
return Response(
|
||||
{'detail': 'この計画は既に散布確定済みです。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
entries_data = request.data.get('entries', [])
|
||||
if not entries_data:
|
||||
return Response(
|
||||
{'detail': '実績データが空です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
actual_entries = []
|
||||
for entry in entries_data:
|
||||
field_id = entry.get('field_id')
|
||||
fertilizer_id = entry.get('fertilizer_id')
|
||||
if not field_id or not fertilizer_id:
|
||||
return Response(
|
||||
{'detail': 'field_id と fertilizer_id が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
actual_bags = Decimal(str(entry.get('actual_bags', 0)))
|
||||
except InvalidOperation:
|
||||
return Response(
|
||||
{'detail': 'actual_bags は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
actual_entries.append(
|
||||
{
|
||||
'field_id': field_id,
|
||||
'fertilizer_id': fertilizer_id,
|
||||
'actual_bags': actual_bags,
|
||||
}
|
||||
'error': '競合する圃場・肥料があるためマージできません。',
|
||||
'conflicts': exc.conflicts,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except FertilizationPlanMergeError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
confirm_spreading_service(plan, actual_entries)
|
||||
plan.refresh_from_db()
|
||||
serializer = self.get_serializer(plan)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='unconfirm')
|
||||
def unconfirm(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
|
||||
if not plan.is_confirmed:
|
||||
return Response(
|
||||
{'detail': 'この計画はまだ確定されていません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
unconfirm_spreading(plan)
|
||||
plan.refresh_from_db()
|
||||
serializer = self.get_serializer(plan)
|
||||
return Response(serializer.data)
|
||||
|
||||
return Response(result)
|
||||
|
||||
class CandidateFieldsView(APIView):
|
||||
"""作付け計画から圃場候補を返す"""
|
||||
@@ -421,3 +413,236 @@ class DeliveryPlanViewSet(viewsets.ModelViewSet):
|
||||
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SpreadingSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = SpreadingSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__fertilizer',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return SpreadingSessionWriteSerializer
|
||||
return SpreadingSessionSerializer
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
from apps.materials.models import StockTransaction
|
||||
year = instance.year
|
||||
affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||
StockTransaction.objects.filter(spreading_item__session=instance).delete()
|
||||
instance.delete()
|
||||
sync_actual_bags_for_pairs(year, affected_pairs)
|
||||
|
||||
|
||||
class SpreadingCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
session_id = request.query_params.get('session_id')
|
||||
delivery_plan_id = request.query_params.get('delivery_plan_id')
|
||||
plan_id = request.query_params.get('plan_id')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if delivery_plan_id:
|
||||
try:
|
||||
delivery_plan_id = int(delivery_plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'delivery_plan_id は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if plan_id:
|
||||
try:
|
||||
plan_id = int(plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'plan_id は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_session = None
|
||||
current_map = {}
|
||||
if session_id:
|
||||
try:
|
||||
current_session = SpreadingSession.objects.prefetch_related('items').get(
|
||||
pk=session_id,
|
||||
year=year,
|
||||
)
|
||||
except SpreadingSession.DoesNotExist:
|
||||
return Response(
|
||||
{'detail': '散布実績が見つかりません。'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
current_map = {
|
||||
(item.field_id, item.fertilizer_id): {
|
||||
'actual_bags': item.actual_bags,
|
||||
'field_name': item.field.name,
|
||||
'field_area_tan': str(item.field.area_tan),
|
||||
'fertilizer_name': item.fertilizer.name,
|
||||
}
|
||||
for item in current_session.items.all()
|
||||
}
|
||||
|
||||
candidates = {}
|
||||
|
||||
plan_queryset = FertilizationEntry.objects.filter(plan__year=year)
|
||||
if plan_id:
|
||||
plan_queryset = plan_queryset.filter(plan_id=plan_id)
|
||||
plan_rows = (
|
||||
plan_queryset
|
||||
.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
)
|
||||
.annotate(planned_bags=Sum('bags'))
|
||||
)
|
||||
for row in plan_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['planned_bags'] = row['planned_bags'] or Decimal('0')
|
||||
|
||||
delivery_queryset = DeliveryTripItem.objects.filter(trip__delivery_plan__year=year)
|
||||
if delivery_plan_id:
|
||||
delivery_queryset = delivery_queryset.filter(trip__delivery_plan_id=delivery_plan_id)
|
||||
else:
|
||||
delivery_queryset = delivery_queryset.filter(trip__date__isnull=False)
|
||||
delivery_rows = delivery_queryset.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
).annotate(delivered_bags=Sum('bags'))
|
||||
for row in delivery_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['delivered_bags'] = row['delivered_bags'] or Decimal('0')
|
||||
|
||||
spread_queryset = SpreadingSessionItem.objects.filter(session__year=year)
|
||||
if current_session is not None:
|
||||
spread_queryset = spread_queryset.exclude(session=current_session)
|
||||
spread_rows = (
|
||||
spread_queryset
|
||||
.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
)
|
||||
.annotate(spread_bags=Sum('actual_bags'))
|
||||
)
|
||||
for row in spread_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['spread_bags'] = row['spread_bags'] or Decimal('0')
|
||||
|
||||
for key, current_data in current_map.items():
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': key[0],
|
||||
'field_name': current_data['field_name'],
|
||||
'field_area_tan': current_data['field_area_tan'],
|
||||
'fertilizer': key[1],
|
||||
'fertilizer_name': current_data['fertilizer_name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['current_session_bags'] = current_data['actual_bags'] or Decimal('0')
|
||||
|
||||
rows = []
|
||||
for candidate in candidates.values():
|
||||
delivered = candidate['delivered_bags']
|
||||
planned = candidate['planned_bags']
|
||||
current_bags = candidate['current_session_bags']
|
||||
if delivery_plan_id:
|
||||
include_row = delivered > 0 or current_bags > 0
|
||||
elif plan_id:
|
||||
include_row = planned > 0 or current_bags > 0
|
||||
else:
|
||||
include_row = delivered > 0 or current_bags > 0
|
||||
if not include_row:
|
||||
continue
|
||||
remaining = delivered - candidate['spread_bags']
|
||||
rows.append(
|
||||
{
|
||||
'field': candidate['field'],
|
||||
'field_name': candidate['field_name'],
|
||||
'field_area_tan': candidate['field_area_tan'],
|
||||
'fertilizer': candidate['fertilizer'],
|
||||
'fertilizer_name': candidate['fertilizer_name'],
|
||||
'planned_bags': str(planned),
|
||||
'delivered_bags': str(delivered),
|
||||
'spread_bags': str(candidate['spread_bags'] + current_bags),
|
||||
'spread_bags_other': str(candidate['spread_bags']),
|
||||
'current_session_bags': str(current_bags),
|
||||
'remaining_bags': str(remaining),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row['field_name'], row['fertilizer_name']))
|
||||
return Response(rows)
|
||||
|
||||
1
backend/apps/levee_work/__init__.py
Normal file
1
backend/apps/levee_work/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
backend/apps/levee_work/admin.py
Normal file
17
backend/apps/levee_work/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemInline(admin.TabularInline):
|
||||
model = LeveeWorkSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(LeveeWorkSession)
|
||||
class LeveeWorkSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['date', 'title', 'year', 'created_at']
|
||||
list_filter = ['year', 'date']
|
||||
search_fields = ['title', 'items__field__name']
|
||||
inlines = [LeveeWorkSessionItemInline]
|
||||
|
||||
8
backend/apps/levee_work/apps.py
Normal file
8
backend/apps/levee_work/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LeveeWorkConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.levee_work'
|
||||
verbose_name = '畔塗作業'
|
||||
|
||||
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2 on 2026-04-04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0004_crop_base_temp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='畔塗日')),
|
||||
('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗記録',
|
||||
'verbose_name_plural': '畔塗記録',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
|
||||
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗対象圃場',
|
||||
'verbose_name_plural': '畔塗対象圃場',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('session', 'field')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
1
backend/apps/levee_work/migrations/__init__.py
Normal file
1
backend/apps/levee_work/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
59
backend/apps/levee_work/models.py
Normal file
59
backend/apps/levee_work/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class LeveeWorkSession(models.Model):
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='畔塗日')
|
||||
title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗記録'
|
||||
verbose_name_plural = '畔塗記録'
|
||||
ordering = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.date} {self.title}'
|
||||
|
||||
|
||||
class LeveeWorkSessionItem(models.Model):
|
||||
session = models.ForeignKey(
|
||||
LeveeWorkSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='畔塗記録',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field',
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name='圃場',
|
||||
)
|
||||
plan = models.ForeignKey(
|
||||
'plans.Plan',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='+',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
|
||||
variety_name_snapshot = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='品種名スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗対象圃場'
|
||||
verbose_name_plural = '畔塗対象圃場'
|
||||
unique_together = [['session', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.session} / {self.field.name}'
|
||||
|
||||
149
backend/apps/levee_work/serializers.py
Normal file
149
backend/apps/levee_work/serializers.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from django.db import transaction
|
||||
from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from apps.workrecords.services import sync_levee_work_record
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'group_name',
|
||||
'plan',
|
||||
'crop_name_snapshot',
|
||||
'variety_name_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
item_count = serializers.SerializerMethodField()
|
||||
total_area_tan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'title',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'item_count',
|
||||
'total_area_tan',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_item_count(self, obj):
|
||||
return len(obj.items.all())
|
||||
|
||||
def get_total_area_tan(self, obj):
|
||||
total = sum((item.field.area_tan or Decimal('0')) for item in obj.items.all())
|
||||
return str(total)
|
||||
|
||||
|
||||
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field = serializers.IntegerField()
|
||||
plan = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
|
||||
|
||||
def validate(self, attrs):
|
||||
year = attrs.get('year', getattr(self.instance, 'year', None))
|
||||
date = attrs.get('date', getattr(self.instance, 'date', None))
|
||||
if year is not None and date is not None and year != date.year:
|
||||
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
|
||||
return attrs
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
key = item['field']
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
|
||||
session = LeveeWorkSession.objects.create(**validated_data)
|
||||
self._replace_items(session, items_data)
|
||||
sync_levee_work_record(session)
|
||||
return session
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', None)
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'title':
|
||||
value = (value or '').strip() or '水稲畔塗'
|
||||
setattr(instance, attr, value)
|
||||
if 'title' not in validated_data:
|
||||
instance.title = (instance.title or '').strip() or '水稲畔塗'
|
||||
instance.save()
|
||||
if items_data is not None:
|
||||
self._replace_items(instance, items_data)
|
||||
sync_levee_work_record(instance)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
for item in items_data:
|
||||
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field'],
|
||||
plan=plan,
|
||||
crop_name_snapshot=plan.crop.name,
|
||||
variety_name_snapshot=plan.variety.name if plan.variety else '',
|
||||
)
|
||||
|
||||
def _resolve_plan(self, year, field_id, plan_id):
|
||||
queryset = Plan.objects.select_related('crop', 'variety').filter(
|
||||
year=year,
|
||||
field_id=field_id,
|
||||
crop__name='水稲',
|
||||
)
|
||||
if plan_id is not None:
|
||||
try:
|
||||
return queryset.get(id=plan_id)
|
||||
except Plan.DoesNotExist as exc:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
|
||||
) from exc
|
||||
|
||||
plan = queryset.first()
|
||||
if plan is None:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
|
||||
)
|
||||
return plan
|
||||
58
backend/apps/levee_work/tests.py
Normal file
58
backend/apps/levee_work/tests.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.plans.models import Crop, Plan, Variety
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
from .serializers import LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializerTests(TestCase):
|
||||
def test_total_area_tan_is_included(self):
|
||||
crop = Crop.objects.create(name='水稲')
|
||||
variety = Variety.objects.create(crop=crop, name='にこまる')
|
||||
field_a = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
field_b = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
plan_a = Plan.objects.create(field=field_a, year=2026, crop=crop, variety=variety, notes='')
|
||||
plan_b = Plan.objects.create(field=field_b, year=2026, crop=crop, variety=variety, notes='')
|
||||
session = LeveeWorkSession.objects.create(
|
||||
year=2026,
|
||||
date='2026-04-06',
|
||||
title='水稲畔塗',
|
||||
notes='',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_a,
|
||||
plan=plan_a,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_b,
|
||||
plan=plan_b,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
|
||||
data = LeveeWorkSessionSerializer(session).data
|
||||
|
||||
self.assertEqual(data['item_count'], 2)
|
||||
self.assertEqual(data['total_area_tan'], '2.0000')
|
||||
13
backend/apps/levee_work/urls.py
Normal file
13
backend/apps/levee_work/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
70
backend/apps/levee_work/views.py
Normal file
70
backend/apps/levee_work/views.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from .models import LeveeWorkSession
|
||||
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = LeveeWorkSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__plan',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return LeveeWorkSessionWriteSerializer
|
||||
return LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
plans = (
|
||||
Plan.objects.select_related('field', 'crop', 'variety')
|
||||
.filter(year=year, crop__name='水稲')
|
||||
.order_by('field__display_order', 'field__id')
|
||||
)
|
||||
|
||||
data = [
|
||||
{
|
||||
'field_id': plan.field_id,
|
||||
'field_name': plan.field.name,
|
||||
'field_area_tan': str(plan.field.area_tan),
|
||||
'group_name': plan.field.group_name,
|
||||
'plan_id': plan.id,
|
||||
'crop_name': plan.crop.name,
|
||||
'variety_name': plan.variety.name if plan.variety else '',
|
||||
'selected': True,
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0002_stocktransaction_fertilization_plan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='transaction_type',
|
||||
field=models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('reserve', '引当'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 10:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0003_stocktransaction_spreading_item_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
]
|
||||
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0004_fix_spreading_item_on_delete'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='material',
|
||||
name='material_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('fertilizer', '肥料'),
|
||||
('pesticide', '農薬'),
|
||||
('seed', '種子'),
|
||||
('seedling', '種苗'),
|
||||
('other', 'その他'),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name='資材種別',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ class Material(models.Model):
|
||||
class MaterialType(models.TextChoices):
|
||||
FERTILIZER = 'fertilizer', '肥料'
|
||||
PESTICIDE = 'pesticide', '農薬'
|
||||
SEED = 'seed', '種子'
|
||||
SEEDLING = 'seedling', '種苗'
|
||||
OTHER = 'other', 'その他'
|
||||
|
||||
@@ -205,6 +206,14 @@ class StockTransaction(models.Model):
|
||||
related_name='stock_reservations',
|
||||
verbose_name='施肥計画',
|
||||
)
|
||||
spreading_item = models.ForeignKey(
|
||||
'fertilizer.SpreadingSessionItem',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='stock_transactions',
|
||||
verbose_name='散布実績明細',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -112,11 +112,15 @@ class MaterialWriteSerializer(serializers.ModelSerializer):
|
||||
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
|
||||
)
|
||||
if (
|
||||
material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER}
|
||||
material_type in {
|
||||
Material.MaterialType.SEED,
|
||||
Material.MaterialType.SEEDLING,
|
||||
Material.MaterialType.OTHER,
|
||||
}
|
||||
and (fertilizer_profile or pesticide_profile)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'種苗・その他には詳細プロファイルを設定できません。'
|
||||
'種子・種苗・その他には詳細プロファイルを設定できません。'
|
||||
)
|
||||
return attrs
|
||||
|
||||
@@ -179,6 +183,7 @@ class StockTransactionSerializer(serializers.ModelSerializer):
|
||||
source='get_transaction_type_display',
|
||||
read_only=True,
|
||||
)
|
||||
is_locked = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StockTransaction
|
||||
@@ -195,10 +200,15 @@ class StockTransactionSerializer(serializers.ModelSerializer):
|
||||
'occurred_on',
|
||||
'note',
|
||||
'fertilization_plan',
|
||||
'spreading_item',
|
||||
'is_locked',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
def get_is_locked(self, obj):
|
||||
return bool(obj.fertilization_plan_id or obj.spreading_item_id)
|
||||
|
||||
|
||||
class StockSummarySerializer(serializers.Serializer):
|
||||
material_id = serializers.IntegerField()
|
||||
|
||||
@@ -14,9 +14,6 @@ def create_reserves_for_plan(plan):
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).delete()
|
||||
|
||||
if plan.is_confirmed:
|
||||
return
|
||||
|
||||
occurred_on = (
|
||||
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
serializer_class = StockTransactionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
http_method_names = ['get', 'post', 'delete', 'head', 'options']
|
||||
http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = StockTransaction.objects.select_related('material')
|
||||
@@ -77,6 +77,33 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return queryset
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.fertilization_plan_id or instance.spreading_item_id:
|
||||
return Response(
|
||||
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.fertilization_plan_id or instance.spreading_item_id:
|
||||
return Response(
|
||||
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.fertilization_plan_id or instance.spreading_item_id:
|
||||
return Response(
|
||||
{'detail': '計画や実績に紐づく入出庫履歴は削除できません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StockSummaryView(generics.ListAPIView):
|
||||
"""在庫集計一覧"""
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.2 on 2026-04-04 00:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0004_crop_base_temp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='crop',
|
||||
name='seed_inventory_kg',
|
||||
field=models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='種もみ在庫(kg)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='variety',
|
||||
name='default_seedling_boxes_per_tan',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数デフォルト'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RiceTransplantPlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='計画名')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('default_seed_grams_per_box', models.DecimalField(decimal_places=2, default=0, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)デフォルト')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rice_transplant_plans', to='plans.variety', verbose_name='品種')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '田植え計画',
|
||||
'verbose_name_plural': '田植え計画',
|
||||
'ordering': ['-year', 'variety'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RiceTransplantEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('seedling_boxes_per_tan', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='反当苗箱枚数')),
|
||||
('seed_grams_per_box', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rice_transplant_entries', to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='plans.ricetransplantplan', verbose_name='田植え計画')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '田植え計画エントリ',
|
||||
'verbose_name_plural': '田植え計画エントリ',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('plan', 'field')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='ricetransplantentry',
|
||||
old_name='seedling_boxes_per_tan',
|
||||
new_name='installed_seedling_boxes',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ricetransplantplan',
|
||||
name='seedling_boxes_per_tan',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数'),
|
||||
),
|
||||
]
|
||||
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0005_material_seed_type'),
|
||||
('plans', '0007_ricetransplantplan_seedling_boxes_per_tan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='variety',
|
||||
name='seed_material',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='varieties',
|
||||
to='materials.material',
|
||||
verbose_name='種子在庫資材',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0008_variety_seed_material'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ricetransplantentry',
|
||||
name='installed_seedling_boxes',
|
||||
field=models.DecimalField(
|
||||
decimal_places=2,
|
||||
max_digits=8,
|
||||
verbose_name='設置苗箱枚数',
|
||||
),
|
||||
),
|
||||
]
|
||||
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0009_alter_ricetransplantentry_installed_seedling_boxes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlanVarietyChange',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='作付年度')),
|
||||
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='変更日時')),
|
||||
('reason', models.TextField(blank=True, default='', verbose_name='変更理由')),
|
||||
('fertilizer_moved_entry_count', models.IntegerField(default=0, verbose_name='施肥移動エントリ数')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='plan_variety_changes', to='fields.field', verbose_name='圃場')),
|
||||
('new_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='new_plan_variety_changes', to='plans.variety', verbose_name='変更後品種')),
|
||||
('old_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='old_plan_variety_changes', to='plans.variety', verbose_name='変更前品種')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variety_changes', to='plans.plan', verbose_name='作付け計画')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '作付け計画品種変更履歴',
|
||||
'verbose_name_plural': '作付け計画品種変更履歴',
|
||||
'ordering': ['-changed_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -5,6 +5,12 @@ from apps.fields.models import Field
|
||||
class Crop(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
||||
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
|
||||
seed_inventory_kg = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=3,
|
||||
default=0,
|
||||
verbose_name="種もみ在庫(kg)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "作物マスタ"
|
||||
@@ -17,6 +23,21 @@ class Crop(models.Model):
|
||||
class Variety(models.Model):
|
||||
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
||||
name = models.CharField(max_length=100, verbose_name="品種名")
|
||||
default_seedling_boxes_per_tan = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="反当苗箱枚数デフォルト",
|
||||
)
|
||||
seed_material = models.ForeignKey(
|
||||
'materials.Material',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='varieties',
|
||||
verbose_name='種子在庫資材',
|
||||
blank=True,
|
||||
null=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "品種マスタ"
|
||||
@@ -42,3 +63,116 @@ class Plan(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.field.name} - {self.year} - {self.crop.name}"
|
||||
|
||||
|
||||
class PlanVarietyChange(models.Model):
|
||||
field = models.ForeignKey(
|
||||
Field,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='plan_variety_changes',
|
||||
verbose_name='圃場',
|
||||
)
|
||||
year = models.IntegerField(verbose_name='作付年度')
|
||||
plan = models.ForeignKey(
|
||||
Plan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='variety_changes',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
changed_at = models.DateTimeField(auto_now_add=True, verbose_name='変更日時')
|
||||
old_variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='old_plan_variety_changes',
|
||||
verbose_name='変更前品種',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
new_variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='new_plan_variety_changes',
|
||||
verbose_name='変更後品種',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
reason = models.TextField(blank=True, default='', verbose_name='変更理由')
|
||||
fertilizer_moved_entry_count = models.IntegerField(default=0, verbose_name='施肥移動エントリ数')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '作付け計画品種変更履歴'
|
||||
verbose_name_plural = '作付け計画品種変更履歴'
|
||||
ordering = ['-changed_at', '-id']
|
||||
|
||||
def __str__(self):
|
||||
old_name = self.old_variety.name if self.old_variety else '未設定'
|
||||
new_name = self.new_variety.name if self.new_variety else '未設定'
|
||||
return f'{self.field.name} {self.year}: {old_name} -> {new_name}'
|
||||
|
||||
|
||||
class RiceTransplantPlan(models.Model):
|
||||
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rice_transplant_plans',
|
||||
verbose_name='品種',
|
||||
)
|
||||
default_seed_grams_per_box = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
|
||||
)
|
||||
seedling_boxes_per_tan = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name='反当苗箱枚数',
|
||||
)
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '田植え計画'
|
||||
verbose_name_plural = '田植え計画'
|
||||
ordering = ['-year', 'variety']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.year} {self.name}'
|
||||
|
||||
|
||||
class RiceTransplantEntry(models.Model):
|
||||
plan = models.ForeignKey(
|
||||
RiceTransplantPlan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='entries',
|
||||
verbose_name='田植え計画',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
Field,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rice_transplant_entries',
|
||||
verbose_name='圃場',
|
||||
)
|
||||
installed_seedling_boxes = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
verbose_name='設置苗箱枚数',
|
||||
)
|
||||
seed_grams_per_box = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
verbose_name='苗箱1枚あたり種もみ(g)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '田植え計画エントリ'
|
||||
verbose_name_plural = '田植え計画エントリ'
|
||||
unique_together = [['plan', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.plan} / {self.field} / {self.installed_seedling_boxes}枚'
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import StockTransaction
|
||||
from .models import Crop, Variety, Plan
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
from .services import NO_CHANGE, update_plan_with_variety_tracking
|
||||
|
||||
|
||||
class VarietySerializer(serializers.ModelSerializer):
|
||||
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Variety
|
||||
fields = '__all__'
|
||||
fields = [
|
||||
'id',
|
||||
'crop',
|
||||
'name',
|
||||
'default_seedling_boxes_per_tan',
|
||||
'seed_material',
|
||||
'seed_material_name',
|
||||
]
|
||||
|
||||
|
||||
class CropSerializer(serializers.ModelSerializer):
|
||||
@@ -20,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||
field_name = serializers.ReadOnlyField(source='field.name')
|
||||
variety_change_count = serializers.SerializerMethodField()
|
||||
latest_variety_change = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Plan
|
||||
@@ -30,7 +47,215 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
return Plan.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
return update_plan_with_variety_tracking(
|
||||
instance,
|
||||
crop=validated_data.get('crop', NO_CHANGE),
|
||||
variety=validated_data.get('variety', NO_CHANGE),
|
||||
notes=validated_data.get('notes', NO_CHANGE),
|
||||
)
|
||||
|
||||
def get_variety_change_count(self, obj):
|
||||
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||
changes = prefetched.get('variety_changes')
|
||||
if changes is not None:
|
||||
return len(changes)
|
||||
return obj.variety_changes.count()
|
||||
|
||||
def get_latest_variety_change(self, obj):
|
||||
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||
changes = prefetched.get('variety_changes')
|
||||
if changes is not None:
|
||||
latest = changes[0] if changes else None
|
||||
else:
|
||||
latest = obj.variety_changes.select_related('old_variety', 'new_variety').first()
|
||||
if latest is None:
|
||||
return None
|
||||
return {
|
||||
'id': latest.id,
|
||||
'changed_at': latest.changed_at,
|
||||
'old_variety_id': latest.old_variety_id,
|
||||
'old_variety_name': latest.old_variety.name if latest.old_variety else None,
|
||||
'new_variety_id': latest.new_variety_id,
|
||||
'new_variety_name': latest.new_variety.name if latest.new_variety else None,
|
||||
'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count,
|
||||
}
|
||||
|
||||
|
||||
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
planned_boxes = serializers.SerializerMethodField()
|
||||
default_seedling_boxes = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantEntry
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'installed_seedling_boxes',
|
||||
'default_seedling_boxes',
|
||||
'planned_boxes',
|
||||
]
|
||||
|
||||
def get_default_seedling_boxes(self, obj):
|
||||
area = Decimal(str(obj.field.area_tan))
|
||||
default_boxes_per_tan = obj.plan.seedling_boxes_per_tan
|
||||
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
|
||||
|
||||
def get_planned_boxes(self, obj):
|
||||
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
|
||||
|
||||
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
|
||||
variety_name = serializers.CharField(source='variety.name', read_only=True)
|
||||
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
|
||||
seed_material_name = serializers.CharField(source='variety.seed_material.name', read_only=True)
|
||||
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
total_seedling_boxes = serializers.SerializerMethodField()
|
||||
total_seed_kg = serializers.SerializerMethodField()
|
||||
variety_seed_inventory_kg = serializers.SerializerMethodField()
|
||||
remaining_seed_kg = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'seed_material_name',
|
||||
'entries',
|
||||
'field_count',
|
||||
'total_seedling_boxes',
|
||||
'total_seed_kg',
|
||||
'variety_seed_inventory_kg',
|
||||
'remaining_seed_kg',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_field_count(self, obj):
|
||||
return obj.entries.count()
|
||||
|
||||
def get_total_seedling_boxes(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.01')))
|
||||
|
||||
def get_total_seed_kg(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
* obj.default_seed_grams_per_box
|
||||
/ Decimal('1000')
|
||||
)
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.001')))
|
||||
|
||||
def get_variety_seed_inventory_kg(self, obj):
|
||||
return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001')))
|
||||
|
||||
def get_remaining_seed_kg(self, obj):
|
||||
total_seed = Decimal(self.get_total_seed_kg(obj))
|
||||
return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001')))
|
||||
|
||||
def _get_seed_inventory_kg(self, obj):
|
||||
material = obj.variety.seed_material
|
||||
if material is None:
|
||||
return Decimal('0')
|
||||
|
||||
transactions = list(material.stock_transactions.all())
|
||||
increase = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
decrease = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return increase - decrease
|
||||
|
||||
|
||||
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'entries',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = RiceTransplantPlan.objects.create(**validated_data)
|
||||
self._save_entries(plan, entries_data)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
entries_data = validated_data.pop('entries', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
self._save_entries(instance, entries_data)
|
||||
return instance
|
||||
|
||||
def validate(self, attrs):
|
||||
entries_data = attrs.get('entries')
|
||||
if entries_data is None:
|
||||
return attrs
|
||||
|
||||
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
|
||||
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
|
||||
missing_ids = sorted(set(field_ids) - existing_ids)
|
||||
if missing_ids:
|
||||
raise serializers.ValidationError({
|
||||
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
|
||||
})
|
||||
return attrs
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
for entry in entries_data:
|
||||
RiceTransplantEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
installed_seedling_boxes=entry['installed_seedling_boxes'],
|
||||
seed_grams_per_box=plan.default_seed_grams_per_box,
|
||||
)
|
||||
|
||||
74
backend/apps/plans/services.py
Normal file
74
backend/apps/plans/services.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Plan, PlanVarietyChange
|
||||
|
||||
|
||||
class _NoChange:
|
||||
pass
|
||||
|
||||
|
||||
NO_CHANGE = _NoChange()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_plan_with_variety_tracking(
|
||||
plan: Plan,
|
||||
*,
|
||||
crop=NO_CHANGE,
|
||||
variety=NO_CHANGE,
|
||||
notes=NO_CHANGE,
|
||||
reason: str = '',
|
||||
):
|
||||
old_variety = plan.variety
|
||||
updated_fields = []
|
||||
|
||||
if crop is not NO_CHANGE:
|
||||
plan.crop = crop
|
||||
updated_fields.append('crop')
|
||||
if variety is not NO_CHANGE:
|
||||
plan.variety = variety
|
||||
updated_fields.append('variety')
|
||||
if notes is not NO_CHANGE:
|
||||
plan.notes = notes
|
||||
updated_fields.append('notes')
|
||||
|
||||
if updated_fields:
|
||||
plan.save(update_fields=updated_fields)
|
||||
|
||||
if variety is not NO_CHANGE and _get_variety_id(old_variety) != _get_variety_id(plan.variety):
|
||||
handle_plan_variety_change(plan, old_variety=old_variety, new_variety=plan.variety, reason=reason)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: str = ''):
|
||||
if _get_variety_id(old_variety) == _get_variety_id(new_variety):
|
||||
return None
|
||||
|
||||
change = PlanVarietyChange.objects.create(
|
||||
field=plan.field,
|
||||
year=plan.year,
|
||||
plan=plan,
|
||||
old_variety=old_variety,
|
||||
new_variety=new_variety,
|
||||
reason=reason,
|
||||
)
|
||||
process_plan_variety_change(change)
|
||||
return change
|
||||
|
||||
|
||||
def process_plan_variety_change(change: PlanVarietyChange):
|
||||
from apps.fertilizer.services import move_fertilization_entries_for_variety_change
|
||||
from .services_rice_transplant import move_rice_transplant_entries_for_variety_change
|
||||
|
||||
moved_count = move_fertilization_entries_for_variety_change(change)
|
||||
move_rice_transplant_entries_for_variety_change(change)
|
||||
if moved_count != change.fertilizer_moved_entry_count:
|
||||
change.fertilizer_moved_entry_count = moved_count
|
||||
change.save(update_fields=['fertilizer_moved_entry_count'])
|
||||
return change
|
||||
|
||||
|
||||
def _get_variety_id(variety):
|
||||
return getattr(variety, 'id', None)
|
||||
46
backend/apps/plans/services_rice_transplant.py
Normal file
46
backend/apps/plans/services_rice_transplant.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import transaction
|
||||
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def move_rice_transplant_entries_for_variety_change(change):
|
||||
old_variety_id = change.old_variety_id
|
||||
new_variety = change.new_variety
|
||||
if old_variety_id is None or new_variety is None:
|
||||
return 0
|
||||
|
||||
old_plans = (
|
||||
RiceTransplantPlan.objects
|
||||
.filter(
|
||||
year=change.year,
|
||||
variety_id=old_variety_id,
|
||||
entries__field_id=change.field_id,
|
||||
)
|
||||
.distinct()
|
||||
.prefetch_related('entries')
|
||||
)
|
||||
|
||||
moved_count = 0
|
||||
for old_plan in old_plans:
|
||||
entries_to_move = list(
|
||||
old_plan.entries.filter(field_id=change.field_id).order_by('id')
|
||||
)
|
||||
if not entries_to_move:
|
||||
continue
|
||||
|
||||
new_plan = RiceTransplantPlan.objects.create(
|
||||
name=f'{change.year}年度 {new_variety.name} 田植え計画(品種変更移動)',
|
||||
year=change.year,
|
||||
variety=new_variety,
|
||||
default_seed_grams_per_box=old_plan.default_seed_grams_per_box,
|
||||
seedling_boxes_per_tan=old_plan.seedling_boxes_per_tan,
|
||||
notes=old_plan.notes,
|
||||
)
|
||||
|
||||
RiceTransplantEntry.objects.filter(
|
||||
id__in=[entry.id for entry in entries_to_move]
|
||||
).update(plan=new_plan)
|
||||
moved_count += len(entries_to_move)
|
||||
|
||||
return moved_count
|
||||
@@ -1,3 +1,263 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from decimal import Decimal
|
||||
|
||||
# Create your tests here.
|
||||
from apps.fertilizer.models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import Material, StockTransaction
|
||||
from apps.materials.stock_service import create_reserves_for_plan
|
||||
from .models import (
|
||||
Crop,
|
||||
Plan,
|
||||
PlanVarietyChange,
|
||||
RiceTransplantEntry,
|
||||
RiceTransplantPlan,
|
||||
Variety,
|
||||
)
|
||||
from .serializers import PlanSerializer
|
||||
from .views import PlanViewSet
|
||||
|
||||
|
||||
class PlanVarietyChangeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username='tester',
|
||||
password='secret12345',
|
||||
)
|
||||
self.crop = Crop.objects.create(name='水稲')
|
||||
self.old_variety = Variety.objects.create(crop=self.crop, name='にこまる')
|
||||
self.new_variety = Variety.objects.create(crop=self.crop, name='たちはるか特栽')
|
||||
self.field = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
self.plan = Plan.objects.create(
|
||||
field=self.field,
|
||||
year=2026,
|
||||
crop=self.crop,
|
||||
variety=self.old_variety,
|
||||
notes='',
|
||||
)
|
||||
self.other_field = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
|
||||
def test_serializer_update_creates_history_when_variety_changes(self):
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.field_id, self.field.id)
|
||||
self.assertEqual(change.year, 2026)
|
||||
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||
self.assertEqual(change.fertilizer_moved_entry_count, 0)
|
||||
|
||||
def test_serializer_update_does_not_create_history_without_variety_change(self):
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'notes': 'メモ更新'},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.notes, 'メモ更新')
|
||||
self.assertFalse(PlanVarietyChange.objects.exists())
|
||||
|
||||
def test_bulk_update_creates_history_for_existing_plan(self):
|
||||
view = PlanViewSet.as_view({'post': 'bulk_update'})
|
||||
request = self.factory.post(
|
||||
'/api/plans/bulk_update/',
|
||||
{
|
||||
'field_ids': [self.field.id],
|
||||
'year': 2026,
|
||||
'crop': self.crop.id,
|
||||
'variety': self.new_variety.id,
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = view(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||
|
||||
def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self):
|
||||
material_target = Material.objects.create(
|
||||
name='高度化成14号',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
material_spread = Material.objects.create(
|
||||
name='分げつ一発',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
fertilizer_target = Fertilizer.objects.create(
|
||||
name='高度化成14号',
|
||||
material=material_target,
|
||||
)
|
||||
fertilizer_spread = Fertilizer.objects.create(
|
||||
name='分げつ一発',
|
||||
material=material_spread,
|
||||
)
|
||||
old_fertilization_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 にこまる 元肥',
|
||||
year=2026,
|
||||
variety=self.old_variety,
|
||||
calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}],
|
||||
)
|
||||
target_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.field,
|
||||
fertilizer=fertilizer_target,
|
||||
bags='4.00',
|
||||
actual_bags=None,
|
||||
)
|
||||
spread_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.field,
|
||||
fertilizer=fertilizer_spread,
|
||||
bags='3.00',
|
||||
actual_bags='1.0000',
|
||||
)
|
||||
untouched_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.other_field,
|
||||
fertilizer=fertilizer_target,
|
||||
bags='2.00',
|
||||
actual_bags=None,
|
||||
)
|
||||
create_reserves_for_plan(old_fertilization_plan)
|
||||
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.fertilizer_moved_entry_count, 2)
|
||||
|
||||
old_fertilization_plan.refresh_from_db()
|
||||
new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get(
|
||||
year=2026,
|
||||
variety=self.new_variety,
|
||||
)
|
||||
self.assertEqual(
|
||||
new_plan.name,
|
||||
f'2026年度 {self.new_variety.name} 施肥計画(品種変更移動)',
|
||||
)
|
||||
self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings)
|
||||
|
||||
target_entry.refresh_from_db()
|
||||
spread_entry.refresh_from_db()
|
||||
untouched_entry.refresh_from_db()
|
||||
|
||||
self.assertEqual(target_entry.plan_id, new_plan.id)
|
||||
self.assertEqual(spread_entry.plan_id, new_plan.id)
|
||||
self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id)
|
||||
|
||||
old_reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=old_fertilization_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
new_reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=new_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
self.assertEqual(len(old_reserves), 1)
|
||||
self.assertEqual(len(new_reserves), 2)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in old_reserves},
|
||||
{
|
||||
(material_target.id, untouched_entry.bags),
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in new_reserves},
|
||||
{
|
||||
(material_target.id, target_entry.bags),
|
||||
(material_spread.id, spread_entry.bags),
|
||||
},
|
||||
)
|
||||
|
||||
def test_serializer_update_moves_rice_transplant_entries_for_target_field(self):
|
||||
old_rice_plan = RiceTransplantPlan.objects.create(
|
||||
name='2026年度 にこまる 田植え計画',
|
||||
year=2026,
|
||||
variety=self.old_variety,
|
||||
default_seed_grams_per_box='200.00',
|
||||
seedling_boxes_per_tan='12.00',
|
||||
notes='旧計画メモ',
|
||||
)
|
||||
target_entry = RiceTransplantEntry.objects.create(
|
||||
plan=old_rice_plan,
|
||||
field=self.field,
|
||||
installed_seedling_boxes='14.40',
|
||||
seed_grams_per_box='200.00',
|
||||
)
|
||||
other_entry = RiceTransplantEntry.objects.create(
|
||||
plan=old_rice_plan,
|
||||
field=self.other_field,
|
||||
installed_seedling_boxes='9.60',
|
||||
seed_grams_per_box='200.00',
|
||||
)
|
||||
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
target_entry.refresh_from_db()
|
||||
other_entry.refresh_from_db()
|
||||
|
||||
new_rice_plan = RiceTransplantPlan.objects.exclude(id=old_rice_plan.id).get(
|
||||
year=2026,
|
||||
variety=self.new_variety,
|
||||
)
|
||||
self.assertEqual(
|
||||
new_rice_plan.name,
|
||||
f'2026年度 {self.new_variety.name} 田植え計画(品種変更移動)',
|
||||
)
|
||||
self.assertEqual(new_rice_plan.default_seed_grams_per_box, Decimal('200.00'))
|
||||
self.assertEqual(new_rice_plan.seedling_boxes_per_tan, Decimal('12.00'))
|
||||
self.assertEqual(new_rice_plan.notes, old_rice_plan.notes)
|
||||
self.assertEqual(target_entry.plan_id, new_rice_plan.id)
|
||||
self.assertEqual(other_entry.plan_id, old_rice_plan.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ from . import views
|
||||
router = DefaultRouter()
|
||||
router.register(r'crops', views.CropViewSet)
|
||||
router.register(r'varieties', views.VarietyViewSet)
|
||||
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
|
||||
router.register(r'', views.PlanViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -2,8 +2,15 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Sum
|
||||
from .models import Crop, Variety, Plan
|
||||
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
|
||||
from .models import Crop, Variety, Plan, RiceTransplantPlan
|
||||
from .serializers import (
|
||||
CropSerializer,
|
||||
VarietySerializer,
|
||||
PlanSerializer,
|
||||
RiceTransplantPlanSerializer,
|
||||
RiceTransplantPlanWriteSerializer,
|
||||
)
|
||||
from .services import update_plan_with_variety_tracking
|
||||
from apps.fields.models import Field
|
||||
|
||||
|
||||
@@ -13,16 +20,20 @@ class CropViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class VarietyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Variety.objects.all()
|
||||
queryset = Variety.objects.select_related('seed_material', 'crop').all()
|
||||
serializer_class = VarietySerializer
|
||||
|
||||
|
||||
class PlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = Plan.objects.all()
|
||||
queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related(
|
||||
'variety_changes',
|
||||
'variety_changes__old_variety',
|
||||
'variety_changes__new_variety',
|
||||
)
|
||||
serializer_class = PlanSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Plan.objects.all()
|
||||
queryset = self.queryset
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
@@ -114,19 +125,78 @@ class PlanViewSet(viewsets.ModelViewSet):
|
||||
updated = 0
|
||||
created = 0
|
||||
for field_id in field_ids:
|
||||
plan, was_created = Plan.objects.update_or_create(
|
||||
field_id=field_id,
|
||||
year=year,
|
||||
defaults={'crop': crop, 'variety': variety}
|
||||
)
|
||||
if was_created:
|
||||
plan = Plan.objects.filter(field_id=field_id, year=year).first()
|
||||
if plan is None:
|
||||
Plan.objects.create(
|
||||
field_id=field_id,
|
||||
year=year,
|
||||
crop=crop,
|
||||
variety=variety,
|
||||
)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
continue
|
||||
|
||||
update_plan_with_variety_tracking(
|
||||
plan,
|
||||
crop=crop,
|
||||
variety=variety,
|
||||
)
|
||||
updated += 1
|
||||
|
||||
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def get_crops_with_varieties(self, request):
|
||||
crops = Crop.objects.prefetch_related('varieties').all()
|
||||
crops = Crop.objects.prefetch_related('varieties__seed_material').all()
|
||||
return Response(CropSerializer(crops, many=True).data)
|
||||
|
||||
|
||||
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = RiceTransplantPlan.objects.select_related(
|
||||
'variety',
|
||||
'variety__crop',
|
||||
'variety__seed_material',
|
||||
).prefetch_related(
|
||||
'variety__seed_material__stock_transactions',
|
||||
'entries',
|
||||
'entries__field',
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return RiceTransplantPlanWriteSerializer
|
||||
return RiceTransplantPlanSerializer
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def candidate_fields(self, request):
|
||||
year = request.query_params.get('year')
|
||||
variety_id = request.query_params.get('variety_id')
|
||||
if not year or not variety_id:
|
||||
return Response(
|
||||
{'error': 'year と variety_id が必要です'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
field_ids = Plan.objects.filter(
|
||||
year=year,
|
||||
variety_id=variety_id,
|
||||
).values_list('field_id', flat=True)
|
||||
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
|
||||
data = [
|
||||
{
|
||||
'id': field.id,
|
||||
'name': field.name,
|
||||
'area_tan': str(field.area_tan),
|
||||
'area_m2': field.area_m2,
|
||||
'group_name': field.group_name,
|
||||
}
|
||||
for field in fields
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
1
backend/apps/workrecords/__init__.py
Normal file
1
backend/apps/workrecords/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
backend/apps/workrecords/admin.py
Normal file
11
backend/apps/workrecords/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
@admin.register(WorkRecord)
|
||||
class WorkRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ['work_date', 'work_type', 'title', 'year', 'auto_created']
|
||||
list_filter = ['work_type', 'year', 'auto_created']
|
||||
search_fields = ['title']
|
||||
|
||||
8
backend/apps/workrecords/apps.py
Normal file
8
backend/apps/workrecords/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkrecordsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.workrecords'
|
||||
verbose_name = '作業記録'
|
||||
|
||||
36
backend/apps/workrecords/migrations/0001_initial.py
Normal file
36
backend/apps/workrecords/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('work_date', models.DateField(verbose_name='作業日')),
|
||||
('work_type', models.CharField(choices=[('fertilizer_delivery', '肥料運搬'), ('fertilizer_spreading', '肥料散布')], max_length=40, verbose_name='作業種別')),
|
||||
('title', models.CharField(max_length=200, verbose_name='タイトル')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('auto_created', models.BooleanField(default=True, verbose_name='自動生成')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('delivery_trip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.deliverytrip', verbose_name='運搬回')),
|
||||
('spreading_session', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.spreadingsession', verbose_name='散布実績')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '作業記録',
|
||||
'verbose_name_plural': '作業記録',
|
||||
'ordering': ['-work_date', '-updated_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2 on 2026-04-04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('levee_work', '0001_initial'),
|
||||
('workrecords', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workrecord',
|
||||
name='work_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('fertilizer_delivery', '肥料運搬'),
|
||||
('fertilizer_spreading', '肥料散布'),
|
||||
('levee_work', '畔塗'),
|
||||
],
|
||||
max_length=40,
|
||||
verbose_name='作業種別',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workrecord',
|
||||
name='levee_work_session',
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='work_record',
|
||||
to='levee_work.leveeworksession',
|
||||
verbose_name='畔塗記録',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
1
backend/apps/workrecords/migrations/__init__.py
Normal file
1
backend/apps/workrecords/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
52
backend/apps/workrecords/models.py
Normal file
52
backend/apps/workrecords/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class WorkRecord(models.Model):
|
||||
class WorkType(models.TextChoices):
|
||||
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
||||
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
||||
LEVEE_WORK = 'levee_work', '畔塗'
|
||||
|
||||
work_date = models.DateField(verbose_name='作業日')
|
||||
work_type = models.CharField(
|
||||
max_length=40,
|
||||
choices=WorkType.choices,
|
||||
verbose_name='作業種別',
|
||||
)
|
||||
title = models.CharField(max_length=200, verbose_name='タイトル')
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
auto_created = models.BooleanField(default=True, verbose_name='自動生成')
|
||||
delivery_trip = models.OneToOneField(
|
||||
'fertilizer.DeliveryTrip',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='work_record',
|
||||
verbose_name='運搬回',
|
||||
)
|
||||
spreading_session = models.OneToOneField(
|
||||
'fertilizer.SpreadingSession',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='work_record',
|
||||
verbose_name='散布実績',
|
||||
)
|
||||
levee_work_session = models.OneToOneField(
|
||||
'levee_work.LeveeWorkSession',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='work_record',
|
||||
verbose_name='畔塗記録',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-work_date', '-updated_at', '-id']
|
||||
verbose_name = '作業記録'
|
||||
verbose_name_plural = '作業記録'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.work_date} {self.get_work_type_display()}'
|
||||
38
backend/apps/workrecords/serializers.py
Normal file
38
backend/apps/workrecords/serializers.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
class WorkRecordSerializer(serializers.ModelSerializer):
|
||||
work_type_display = serializers.CharField(source='get_work_type_display', read_only=True)
|
||||
delivery_plan_id = serializers.SerializerMethodField()
|
||||
delivery_plan_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WorkRecord
|
||||
fields = [
|
||||
'id',
|
||||
'work_date',
|
||||
'work_type',
|
||||
'work_type_display',
|
||||
'title',
|
||||
'year',
|
||||
'auto_created',
|
||||
'delivery_trip',
|
||||
'delivery_plan_id',
|
||||
'delivery_plan_name',
|
||||
'spreading_session',
|
||||
'levee_work_session',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_delivery_plan_id(self, obj):
|
||||
if obj.delivery_trip_id:
|
||||
return obj.delivery_trip.delivery_plan_id
|
||||
return None
|
||||
|
||||
def get_delivery_plan_name(self, obj):
|
||||
if obj.delivery_trip_id:
|
||||
return obj.delivery_trip.delivery_plan.name
|
||||
return None
|
||||
48
backend/apps/workrecords/services.py
Normal file
48
backend/apps/workrecords/services.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
def sync_delivery_work_record(trip):
|
||||
if trip.date is None:
|
||||
WorkRecord.objects.filter(delivery_trip=trip).delete()
|
||||
return
|
||||
|
||||
WorkRecord.objects.update_or_create(
|
||||
delivery_trip=trip,
|
||||
defaults={
|
||||
'work_date': trip.date,
|
||||
'work_type': WorkRecord.WorkType.FERTILIZER_DELIVERY,
|
||||
'title': f'肥料運搬: {trip.delivery_plan.name} {trip.order + 1}回目',
|
||||
'year': trip.delivery_plan.year,
|
||||
'auto_created': True,
|
||||
'spreading_session': None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sync_spreading_work_record(session):
|
||||
WorkRecord.objects.update_or_create(
|
||||
spreading_session=session,
|
||||
defaults={
|
||||
'work_date': session.date,
|
||||
'work_type': WorkRecord.WorkType.FERTILIZER_SPREADING,
|
||||
'title': f'肥料散布: {session.name.strip() or session.date}',
|
||||
'year': session.year,
|
||||
'auto_created': True,
|
||||
'delivery_trip': None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sync_levee_work_record(session):
|
||||
WorkRecord.objects.update_or_create(
|
||||
levee_work_session=session,
|
||||
defaults={
|
||||
'work_date': session.date,
|
||||
'work_type': WorkRecord.WorkType.LEVEE_WORK,
|
||||
'title': session.title,
|
||||
'year': session.year,
|
||||
'auto_created': True,
|
||||
'delivery_trip': None,
|
||||
'spreading_session': None,
|
||||
},
|
||||
)
|
||||
12
backend/apps/workrecords/urls.py
Normal file
12
backend/apps/workrecords/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import WorkRecordViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', WorkRecordViewSet, basename='workrecord')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
22
backend/apps/workrecords/views.py
Normal file
22
backend/apps/workrecords/views.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from .models import WorkRecord
|
||||
from .serializers import WorkRecordSerializer
|
||||
|
||||
|
||||
class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = WorkRecordSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = WorkRecord.objects.select_related(
|
||||
'delivery_trip',
|
||||
'delivery_trip__delivery_plan',
|
||||
'spreading_session',
|
||||
'levee_work_session',
|
||||
)
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
@@ -45,6 +45,8 @@ INSTALLED_APPS = [
|
||||
'apps.weather',
|
||||
'apps.fertilizer',
|
||||
'apps.materials',
|
||||
'apps.workrecords',
|
||||
'apps.levee_work',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -59,4 +59,6 @@ urlpatterns = [
|
||||
path('api/weather/', include('apps.weather.urls')),
|
||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||
path('api/materials/', include('apps.materials.urls')),
|
||||
path('api/workrecords/', include('apps.workrecords.urls')),
|
||||
path('api/levee-work/', include('apps.levee_work.urls')),
|
||||
]
|
||||
|
||||
1
butler.pid
Normal file
1
butler.pid
Normal file
@@ -0,0 +1 @@
|
||||
3396
|
||||
37
deploy_local.sh
Executable file
37
deploy_local.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# ローカル本番同等環境の起動スクリプト
|
||||
# 使用: bash deploy_local.sh
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== KeinaSystem ローカル本番環境 ==="
|
||||
|
||||
# .env ファイル確認
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "エラー: .env ファイルがありません"
|
||||
echo " .env.production.example を .env にコピーして値を設定してください"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/4] 停止..."
|
||||
docker compose -f docker-compose.local.yml down
|
||||
|
||||
echo "[2/4] ビルド..."
|
||||
docker compose -f docker-compose.local.yml build
|
||||
|
||||
echo "[3/4] 起動..."
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
echo "[4/4] マイグレーション..."
|
||||
sleep 5
|
||||
docker compose -f docker-compose.local.yml exec backend python manage.py migrate
|
||||
|
||||
echo ""
|
||||
echo "=== 起動完了 ==="
|
||||
docker compose -f docker-compose.local.yml ps
|
||||
echo ""
|
||||
echo " フロントエンド: http://localhost:3000"
|
||||
echo " バックエンドAPI: http://localhost:8000/api/"
|
||||
echo ""
|
||||
echo "DBをサーバーと同期する場合: bash sync_db.sh"
|
||||
59
docker-compose.local.yml
Normal file
59
docker-compose.local.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
# ローカルでの本番同等テスト用
|
||||
# Traefikなし、ポート直接公開、本番用Dockerfileを使用
|
||||
# 使用: docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgis/postgis:16-3.4
|
||||
container_name: keinasystem_db
|
||||
environment:
|
||||
POSTGRES_DB: keinasystem
|
||||
POSTGRES_USER: keinasystem
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data_local:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keinasystem -d keinasystem"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: keinasystem_backend
|
||||
environment:
|
||||
DB_NAME: keinasystem
|
||||
DB_USER: keinasystem
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DEBUG: "False"
|
||||
ALLOWED_HOSTS: localhost,127.0.0.1
|
||||
CORS_ALLOWED_ORIGINS: http://localhost:3000
|
||||
MAIL_API_KEY: ${MAIL_API_KEY}
|
||||
FRONTEND_URL: http://localhost:3000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||
container_name: keinasystem_frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data_local:
|
||||
@@ -1,16 +1,16 @@
|
||||
# マスタードキュメント:施肥計画機能
|
||||
|
||||
> **作成**: 2026-03-01
|
||||
> **最終更新**: 2026-03-15
|
||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布確定)
|
||||
> **実装状況**: 実装完了・本番稼働中
|
||||
> **最終更新**: 2026-03-17
|
||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布実績記録)
|
||||
> **実装状況**: 実装完了・本番稼働中(散布実績連携追加)
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力に加えて在庫引当と散布確定まで一連で扱う。
|
||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力・在庫引当・散布実績記録・作業記録索引生成まで一連で扱う。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
|---|---|
|
||||
| 肥料マスタ管理 | 肥料購入管理 |
|
||||
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照) |
|
||||
| 3方式の自動計算 | 個別作業日報の詳細管理 |
|
||||
| 作付け計画からの圃場自動取得 | |
|
||||
| PDF出力(圃場×肥料マトリクス表) | |
|
||||
| 3方式の自動計算 | 運搬便ごとの散布充当追跡 |
|
||||
| 作付け計画からの圃場自動取得 | 相手先ごとのPDF様式実装 |
|
||||
| PDF出力(圃場×肥料マトリクス表) | 残肥返却・再入庫管理 |
|
||||
| 在庫引当・引当解除 | |
|
||||
| 散布確定(計画値確認 + 実績入力) | |
|
||||
| 散布実績記録(日付単位・運搬済み肥料ベース) | |
|
||||
| 作業記録索引(WorkRecord)自動生成 | |
|
||||
| 在庫USE連携(散布実績保存時) | |
|
||||
| 施肥計画進捗表示(未散布/一部散布/完了/計画超過) | |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,11 +52,20 @@
|
||||
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
||||
| year | int | required | 年度 |
|
||||
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
||||
| is_confirmed | bool | default=False | 散布確定済みフラグ |
|
||||
| confirmed_at | datetime | nullable | 散布確定日時 |
|
||||
| is_confirmed | bool | default=False | ~~散布確定済みフラグ~~(deprecated: 新UIでは使用しない) |
|
||||
| confirmed_at | datetime | nullable | ~~散布確定日時~~(deprecated: 新UIでは使用しない) |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
#### 表示用計算項目(APIレスポンスに含まれる)
|
||||
|
||||
| 項目 | 型 | 説明 |
|
||||
|---|---|---|
|
||||
| spread_status | string | `unspread` / `partial` / `completed` / `over_applied` |
|
||||
| planned_total_bags | decimal | 計画袋数合計(全entries.bagsの合計) |
|
||||
| spread_total_bags | decimal | 散布済み袋数合計(全entries.actual_bagsの合計) |
|
||||
| remaining_total_bags | decimal | 残袋数(planned_total_bags - spread_total_bags) |
|
||||
|
||||
### FertilizationEntry(施肥エントリ:圃場×肥料×袋数)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
@@ -62,11 +74,60 @@
|
||||
| plan | FK(FertilizationPlan) | CASCADE | |
|
||||
| field | FK(fields.Field) | CASCADE | |
|
||||
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
|
||||
| bags | decimal(8,2) | required | 袋数 |
|
||||
| bags | decimal(8,2) | required | 袋数(計画値) |
|
||||
| actual_bags | decimal(10,4) | nullable | 散布実績集計値(SpreadingSessionItemから自動集計) |
|
||||
|
||||
- `unique_together = ['plan', 'field', 'fertilizer']`
|
||||
- 順序: `field__display_order, field__id, fertilizer__name`
|
||||
|
||||
### SpreadingSession(散布実績セッション)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| year | int | required | 年度フィルタ用 |
|
||||
| date | DateField | required | 散布日 |
|
||||
| name | varchar(100) | required | セッション名(必須) |
|
||||
| notes | text | blank | 備考 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `year + date` の一意制約は付けない(同日に午前・午後やエリア別で複数記録可能)
|
||||
|
||||
### SpreadingSessionItem(散布実績明細)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| session | FK(SpreadingSession) | CASCADE | |
|
||||
| field | FK(fields.Field) | PROTECT | |
|
||||
| fertilizer | FK(Fertilizer) | PROTECT | |
|
||||
| actual_bags | decimal(10,4) | required | 実散布袋数 |
|
||||
| planned_bags_snapshot | decimal(10,4) | required | 表示時点の計画値 |
|
||||
| delivered_bags_snapshot | decimal(10,4) | required | 表示時点の運搬済み合計 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `unique_together = ['session', 'field', 'fertilizer']`
|
||||
|
||||
### WorkRecord(作業記録索引)
|
||||
|
||||
別アプリ `apps/workrecords/` で管理。施肥・運搬の作業を日付順に一覧するための索引テーブル。
|
||||
詳細の本体は各業務テーブル側(DeliveryTrip / SpreadingSession)に持つ。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| work_date | DateField | required | 作業日 |
|
||||
| work_type | varchar | required | `fertilizer_delivery` / `fertilizer_spreading` |
|
||||
| title | varchar(200) | required | 一覧表示名 |
|
||||
| year | int | required | 年度フィルタ補助 |
|
||||
| auto_created | bool | default=True | 自動生成フラグ |
|
||||
| delivery_trip | OneToOne FK(DeliveryTrip) | nullable | 運搬由来 |
|
||||
| spreading_session | OneToOne FK(SpreadingSession) | nullable | 散布由来 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
---
|
||||
|
||||
## API エンドポイント
|
||||
@@ -106,8 +167,8 @@
|
||||
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
||||
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
||||
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
||||
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定(引当 → 使用へ変換) |
|
||||
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | 散布確定取消(使用 → 引当に戻す) |
|
||||
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | ~~散布確定~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | ~~散布確定取消~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
||||
|
||||
一覧レスポンス例(FertilizationPlan):
|
||||
@@ -154,18 +215,60 @@ POST/PUT リクエスト例:
|
||||
|
||||
PUT 時は entries が全置換(削除→再作成)。entries を省略した場合は既存を維持。
|
||||
|
||||
散布確定 API リクエスト例:
|
||||
### 散布実績(新規)
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/fertilizer/spreading/?year={year}` | 年度別一覧 |
|
||||
| POST | `/api/fertilizer/spreading/` | 新規作成 |
|
||||
| GET | `/api/fertilizer/spreading/{id}/` | 詳細 |
|
||||
| PUT | `/api/fertilizer/spreading/{id}/` | 更新 |
|
||||
| DELETE | `/api/fertilizer/spreading/{id}/` | 削除 |
|
||||
| GET | `/api/fertilizer/spreading/candidates/?year={year}` | 散布候補一覧 |
|
||||
|
||||
散布候補一覧レスポンス例:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"field": 5,
|
||||
"field_name": "田中上",
|
||||
"fertilizer": 1,
|
||||
"fertilizer_name": "電気炉さい",
|
||||
"planned_bags": "4.0000",
|
||||
"delivered_bags": "4.0000",
|
||||
"spread_bags": "1.5000",
|
||||
"remaining_bags": "2.5000",
|
||||
"remaining_plan_bags": "2.5000",
|
||||
"delivery_gap": "0.0000"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
散布実績 POST リクエスト例:
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{"field_id": 5, "fertilizer_id": 1, "actual_bags": 2.4},
|
||||
{"field_id": 6, "fertilizer_id": 1, "actual_bags": 0}
|
||||
"year": 2026,
|
||||
"date": "2026-04-15",
|
||||
"name": "午前・田中エリア",
|
||||
"notes": "",
|
||||
"items": [
|
||||
{
|
||||
"field": 5,
|
||||
"fertilizer": 1,
|
||||
"actual_bags": "2.5000",
|
||||
"planned_bags_snapshot": "4.0000",
|
||||
"delivered_bags_snapshot": "4.0000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `actual_bags > 0`: 対応する引当を使用実績へ変換
|
||||
- `actual_bags = 0`: 未散布として引当解除
|
||||
### 作業記録(新規・別アプリ)
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/workrecords/?year={year}` | 一覧 |
|
||||
| GET | `/api/workrecords/{id}/` | 詳細(元レコードへのリンク情報を返す) |
|
||||
|
||||
### 圃場候補取得
|
||||
|
||||
@@ -290,9 +393,11 @@ GET /api/plans/crops/
|
||||
### 施肥計画一覧(`/fertilizer`)
|
||||
|
||||
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布確定状態
|
||||
- 操作ボタン: PDF出力・編集・削除・散布確定
|
||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布進捗
|
||||
- 操作ボタン: PDF出力・編集・削除
|
||||
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
||||
- 進捗表示: `未散布` / `一部散布 3.5 / 8.0袋` / `散布完了` / `計画超過`
|
||||
- 計画値と実績値を並べて表示
|
||||
|
||||
### 肥料マスタ(`/fertilizer/masters`)
|
||||
|
||||
@@ -316,11 +421,11 @@ GET /api/plans/crops/
|
||||
6. **手動調整**: マトリクス表のセルを直接編集
|
||||
7. **保存**: 「保存」ボタンで entries を一括送信
|
||||
|
||||
#### 在庫連携・確定状態
|
||||
#### 在庫連携・実績表示
|
||||
|
||||
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
|
||||
- 散布確定済みの計画は情報バナーを表示し、編集操作をロック
|
||||
- 「確定取消」で使用実績を引当に戻し、再編集できる
|
||||
- マトリクス表で `bags`(計画値)を編集可能、`actual_bags`(実績値)は読み取り専用で参照表示
|
||||
- 散布実績画面(`/fertilizer/spreading`)へのリンクを表示
|
||||
|
||||
#### マトリクスの表示仕様
|
||||
|
||||
@@ -329,16 +434,24 @@ GET /api/plans/crops/
|
||||
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
||||
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
||||
|
||||
### 散布確定モーダル(`/fertilizer` 一覧から起動)
|
||||
### 散布実績画面(`/fertilizer/spreading`)
|
||||
|
||||
- 全画面遷移ではなくモーダル表示
|
||||
- 施肥計画編集と同じ視線移動になるよう、`圃場 = 行`、`肥料 = 列` のマトリクス表を採用
|
||||
- 画面上部に計画名・年度・作物/品種・対象圃場数・肥料数を表示
|
||||
- 各セルは「薄いグレーの計画値」+「実績入力欄」の2段表示
|
||||
- 行末に圃場ごとの実績合計、表フッターに肥料別合計と総合計を表示
|
||||
- `0` を入力したセルは未散布として扱い、対応する引当を解除する
|
||||
- 年度セレクタ(localStorage `fertilizerYear` と連動)
|
||||
- 散布日入力(DateField)
|
||||
- セッション名入力(必須)
|
||||
- 運搬済み・未散布候補一覧を表示(`candidates` APIから取得)
|
||||
- 圃場単位で選択可能(全部または一部)
|
||||
- 実績袋数の編集
|
||||
- 差異がある場合はインライン警告表示
|
||||
- 保存時に在庫USE連携・WorkRecord自動生成・FertilizationEntry.actual_bags再集計を実行
|
||||
|
||||
#### State 構成
|
||||
### 作業記録画面(`/workrecords`)
|
||||
|
||||
- 年度セレクタ
|
||||
- 日付・作業種別・タイトルの一覧表示
|
||||
- 元データ(運搬回 / 散布セッション)への遷移リンク
|
||||
|
||||
#### State 構成(施肥計画編集画面)
|
||||
|
||||
```typescript
|
||||
// 基本情報
|
||||
@@ -374,13 +487,15 @@ backend/apps/fertilizer/
|
||||
├── __init__.py
|
||||
├── admin.py # Django admin 登録
|
||||
├── apps.py # FertilizerConfig
|
||||
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry
|
||||
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
|
||||
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
|
||||
├── urls.py # DefaultRouter + candidate_fields/ + calculate/
|
||||
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry, SpreadingSession, SpreadingSessionItem
|
||||
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer, SpreadingSessionSerializer
|
||||
├── services.py # actual_bags再集計、WorkRecord自動生成、在庫USE連携
|
||||
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, SpreadingSessionViewSet, CandidateFieldsView, CalculateView
|
||||
├── urls.py # DefaultRouter + candidate_fields/ + calculate/ + spreading/
|
||||
├── migrations/
|
||||
│ ├── 0001_initial.py
|
||||
│ └── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
||||
│ ├── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
||||
│ └── ... # SpreadingSession, SpreadingSessionItem, actual_bags 追加
|
||||
└── templates/
|
||||
└── fertilizer/
|
||||
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
||||
@@ -398,25 +513,131 @@ frontend/src/app/fertilizer/
|
||||
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
||||
├── masters/
|
||||
│ └── page.tsx # 肥料マスタ管理
|
||||
├── spreading/
|
||||
│ └── ... # 散布実績画面(一覧・作成・編集)
|
||||
└── _components/
|
||||
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
||||
|
||||
frontend/src/app/workrecords/
|
||||
└── ... # 作業記録画面(一覧・詳細)
|
||||
```
|
||||
|
||||
### 変更されたファイル
|
||||
|
||||
| ファイル | 変更内容 |
|
||||
|---|---|
|
||||
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'` を追加 |
|
||||
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
|
||||
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
|
||||
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'`, `'apps.workrecords'` を追加 |
|
||||
| `backend/keinasystem/urls.py` | `api/fertilizer/`, `api/workrecords/` を追加 |
|
||||
| `backend/apps/materials/models.py` | `StockTransaction.spreading_item` FK 追加(`on_delete=SET_NULL`) |
|
||||
| `backend/apps/workrecords/` | 作業記録索引アプリ(WorkRecord モデル・API・services) |
|
||||
| `frontend/src/types/index.ts` | 施肥・散布・作業記録の型を追加 |
|
||||
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |
|
||||
|
||||
---
|
||||
|
||||
## 在庫連携
|
||||
|
||||
### RESERVE(施肥計画保存時)
|
||||
|
||||
- 従来どおり計画値 `bags` ベースで維持
|
||||
- 施肥計画の entries 保存時に RESERVE トランザクションを作成
|
||||
|
||||
### USE(散布実績保存時)
|
||||
|
||||
- `SpreadingSessionItem` ごとに USE を1件作成
|
||||
- `material`: `item.fertilizer.material`
|
||||
- `quantity`: `actual_bags`
|
||||
- `occurred_on`: `session.date`
|
||||
- `note`: `散布実績「{session.name or session.date}」`
|
||||
|
||||
### StockTransaction 追加フィールド
|
||||
|
||||
- `spreading_item = FK(SpreadingSessionItem, null=True, blank=True, on_delete=SET_NULL)`
|
||||
|
||||
### 更新・削除
|
||||
|
||||
- 散布実績更新時: その session に紐づく USE を全置換で作り直す
|
||||
- 散布実績削除時: 対応 USE を削除する
|
||||
|
||||
### RESERVE と USE の整合
|
||||
|
||||
- RESERVE は計画値 `bags` ベース
|
||||
- USE は散布実績 `actual_bags` ベース
|
||||
- 計画値と実績値は併存する
|
||||
|
||||
---
|
||||
|
||||
## 集計ルール
|
||||
|
||||
### planned_total(圃場×肥料×年度)
|
||||
|
||||
`FertilizationEntry.bags` の合計
|
||||
|
||||
### delivered_total(圃場×肥料×年度)
|
||||
|
||||
`DeliveryTrip.date != null` の `DeliveryTripItem.bags` 合計
|
||||
|
||||
### spread_total(圃場×肥料×年度)
|
||||
|
||||
`SpreadingSessionItem.actual_bags` の合計
|
||||
|
||||
### actual_bags 再集計ルール
|
||||
|
||||
- `SUM(SpreadingSessionItem.actual_bags)` を同一 year, field, fertilizer で集計
|
||||
- 散布実績の保存・更新・削除時に該当する `FertilizationEntry.actual_bags` を即時再計算
|
||||
- `SUM(...) = 0` の場合は `actual_bags = null`
|
||||
|
||||
### remaining_bags(表示用の残量)
|
||||
|
||||
`delivered_total - spread_total`
|
||||
|
||||
### remaining_plan_bags(計画進捗用の残量)
|
||||
|
||||
`planned_total - spread_total`
|
||||
|
||||
### 差異の扱い
|
||||
|
||||
- `remaining_bags < 0`: 運搬実績不足
|
||||
- `remaining_plan_bags < 0`: 計画超過
|
||||
- 圃場+肥料単位で差異が分かることを優先する
|
||||
|
||||
---
|
||||
|
||||
## WorkRecord 自動生成ルール
|
||||
|
||||
### 運搬(fertilizer_delivery)
|
||||
|
||||
- `DeliveryTrip.date` 保存時に upsert
|
||||
- `title = 肥料運搬: {delivery_plan.name} {n}回目`
|
||||
- 日付削除時は対応 WorkRecord を削除
|
||||
|
||||
### 散布(fertilizer_spreading)
|
||||
|
||||
- `SpreadingSession` 保存時に upsert
|
||||
- `title = 肥料散布: {session.name or session.date}`
|
||||
- 削除時は対応 WorkRecord を削除
|
||||
|
||||
### 実装方針
|
||||
|
||||
自動生成は view に直書きせず、サービス層(`services.py`)で idempotent に実装する。
|
||||
|
||||
---
|
||||
|
||||
## 前年度コピー
|
||||
|
||||
`copy_from_previous_year` で前年度の `FertilizationEntry` をコピーする際のルール:
|
||||
|
||||
- `actual_bags` がある場合: `actual_bags` を新年度の `bags` 初期値として使用
|
||||
- `actual_bags` が `null` の場合: 従来どおり `bags` をコピー
|
||||
|
||||
前年度に実際に散布した量を次年度計画の初期値として再利用できる。
|
||||
|
||||
---
|
||||
|
||||
## 型定義(TypeScript)
|
||||
|
||||
```typescript
|
||||
// frontend/src/types/index.ts
|
||||
// frontend/src/types/index.ts(主要な型のみ抜粋)
|
||||
|
||||
export interface Fertilizer {
|
||||
id: number;
|
||||
@@ -437,6 +658,7 @@ export interface FertilizationEntry {
|
||||
fertilizer: number;
|
||||
fertilizer_name: string;
|
||||
bags: string;
|
||||
actual_bags: string | null; // 散布実績集計値
|
||||
}
|
||||
|
||||
export interface FertilizationPlan {
|
||||
@@ -449,6 +671,10 @@ export interface FertilizationPlan {
|
||||
field_count: number;
|
||||
fertilizer_count: number;
|
||||
entries: FertilizationEntry[];
|
||||
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
||||
planned_total_bags: string;
|
||||
spread_total_bags: string;
|
||||
remaining_total_bags: string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -487,6 +713,25 @@ plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-v
|
||||
PUT 時は entries を全削除→再作成する「全置換」方式。
|
||||
部分更新は非対応(PATCH でも entries がある場合は全置換)。
|
||||
|
||||
### 散布実績の在庫連携
|
||||
|
||||
- 施肥計画保存時: `RESERVE`(計画値 `bags` ベース)
|
||||
- 散布実績保存時: `USE`(実績値 `actual_bags` ベース)
|
||||
- `RESERVE` と `USE` は併存する(計画値と実績値は別管理)
|
||||
- 散布実績更新時は `session` に紐づく `USE` を全置換で作り直す
|
||||
- 散布実績削除時は対応 `USE` を削除する(`StockTransaction.spreading_item` は `SET_NULL`)
|
||||
- `perform_destroy` で明示的に `StockTransaction` を削除してから `session.delete()` を呼ぶ
|
||||
|
||||
### 散布セッション名は必須
|
||||
|
||||
`SpreadingSession.name` は必須フィールド。WorkRecord のタイトル生成や一覧表示に使用するため、
|
||||
空文字での保存は許可しない。
|
||||
|
||||
### useSearchParams と Suspense(Next.js 14)
|
||||
|
||||
散布実績画面(`/fertilizer/spreading`)では `useSearchParams()` を使用するため、
|
||||
`Suspense` boundary でラップする必要がある(本番ビルドで必須)。
|
||||
|
||||
### Next.js ホットリロードが効かない問題(Windows + Docker)
|
||||
|
||||
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
||||
@@ -499,6 +744,8 @@ Windows 環境では Docker ボリュームマウント経由のファイル変
|
||||
|
||||
## 将来の拡張(スコープ外)
|
||||
|
||||
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||
- **相手先別PDF様式**: 客先ごとの提出資料フォーマット(元データは散布実績から取得可能)
|
||||
- **残肥返却・再入庫管理**: 散布後の残りを在庫に戻す処理
|
||||
- **SpreadingAllocation**: 運搬便単位の散布充当追跡(現状は集計ベースで十分)
|
||||
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
||||
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数)
|
||||
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **作成**: 2026-03-02
|
||||
> **最終更新**: 2026-03-16
|
||||
> **対象機能**: 運搬計画(施肥計画の肥料を軽トラで運ぶ単位で計画・記録する)
|
||||
> **実装状況**: 再設計中(旧分配計画から運搬計画へ移行)
|
||||
> **実装状況**: 本番稼働中
|
||||
|
||||
---
|
||||
|
||||
@@ -280,7 +280,10 @@ PUT は groups・trips を全削除→再作成する全置換方式。
|
||||
|---|---|---|
|
||||
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
|
||||
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
|
||||
| 圃場を別の回に移動 | 戻す→再割り当て、または直接ドロップダウン | 回の間で移動 |
|
||||
| 圃場を別の回に移動 | 圃場行の「移動...」ドロップダウン | 回の間で移動 |
|
||||
| グループを回に一括割り当て | 回内の「グループを追加...」ドロップダウン | グループの全圃場を一括割り当て |
|
||||
| グループを別の回に移動 | グループ★行の「移動...」ドロップダウン | グループの全圃場を回の間で一括移動 |
|
||||
| グループを未割り当てに戻す | グループ★行の「移動...」→「未割り当てに戻す」 | グループの全圃場を一括で未割り当てに戻す |
|
||||
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て全圃場を新しい回に追加 |
|
||||
| 回の追加 | 「+回を追加」ボタン | 空の回を追加 |
|
||||
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
|
||||
@@ -393,3 +396,17 @@ PDF生成時のみサーバーサイドで同じ計算を実施。
|
||||
### エラー表示方針
|
||||
|
||||
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
|
||||
|
||||
### 散布実績との連携
|
||||
|
||||
- 運搬計画の `DeliveryTripItem` が散布実績画面(`/fertilizer/spreading`)の候補データソースとなる
|
||||
- `DeliveryTrip.date != null` の明細のみを「運搬済み」とみなし、散布候補に含める
|
||||
- 散布実績画面から運搬計画を指定して遷移する場合(`?delivery_plan_id=N`)、日付フィルタは適用されない(その計画の全明細が候補になる)
|
||||
- 散布実績の保存時に在庫 `USE` が作成される(運搬時点では在庫変動なし)
|
||||
|
||||
### WorkRecord 自動生成
|
||||
|
||||
- `DeliveryTrip` に日付が保存されると、`WorkRecord`(`work_type=fertilizer_delivery`)が自動生成される
|
||||
- 実装: `apps/workrecords/services.py` の `sync_delivery_work_record()`
|
||||
- `DeliveryTrip` の日付が削除されると、対応する `WorkRecord` も削除される
|
||||
- `WorkRecord` は索引として機能し、明細データは `DeliveryTrip` / `DeliveryTripItem` 側が保持する
|
||||
|
||||
557
document/15_マスタードキュメント_畔塗作業編.md
Normal file
557
document/15_マスタードキュメント_畔塗作業編.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# マスタードキュメント:畔塗作業機能
|
||||
|
||||
> **作成**: 2026-04-04
|
||||
> **最終更新**: 2026-04-04
|
||||
> **対象機能**: 畔塗作業記録(日付単位の圃場選択・作業記録索引連携)
|
||||
> **実装状況**: 実装予定(仕様策定版)
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が、水稲作付け圃場に対して実施した「畔塗」作業を日付単位で記録する機能。
|
||||
対象圃場をまとめて選択し、保存時に作業記録一覧へ自動反映する。
|
||||
|
||||
本機能は、施肥計画の散布実績と同様に
|
||||
「作業本体を専用テーブルで持ち、作業記録一覧には索引を自動生成する」
|
||||
という設計方針を採用する。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
| IN(本機能で扱う) | OUT(本機能では扱わない) |
|
||||
|---|---|
|
||||
| 畔塗日単位の記録作成 | 畔塗作業の工程管理 |
|
||||
| 水稲作付け圃場の候補抽出 | 作業者別の工数集計 |
|
||||
| 複数圃場の一括選択・保存 | 機械・資材の在庫管理 |
|
||||
| 作業記録一覧(WorkRecord)への自動反映 | 写真添付 |
|
||||
| 畔塗記録の編集・削除 | GPS軌跡連携 |
|
||||
| 対象圃場一覧の参照画面 | 汎用作業日誌への完全統合 |
|
||||
|
||||
---
|
||||
|
||||
## 背景と目的
|
||||
|
||||
現状システムには、運搬や肥料散布のような作業実績を日付順に参照する仕組みがあるが、
|
||||
春作業の一つである畔塗については記録先が存在しない。
|
||||
|
||||
畔塗は次の特徴を持つ。
|
||||
|
||||
- 1日で複数圃場をまとめて実施することが多い
|
||||
- 対象圃場は当年の作付け計画と密接に関係する
|
||||
- 後から「いつ、どの圃場を畔塗したか」を一覧で見返したい
|
||||
|
||||
そのため、圃場ごとに単発レコードを大量に作るのではなく、
|
||||
`1日 = 1件の畔塗記録` とし、対象圃場を明細としてぶら下げる構成とする。
|
||||
|
||||
---
|
||||
|
||||
## データモデル
|
||||
|
||||
### LeveeWorkSession(畔塗記録本体)
|
||||
|
||||
日付単位の畔塗作業記録。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| year | int | required | 年度フィルタ用。既存機能に合わせて暦年を保持し、原則 `date.year` と一致させる |
|
||||
| date | DateField | required | 畔塗日 |
|
||||
| title | varchar(100) | required, default=`水稲畔塗` | 一覧表示タイトル。未指定時はサーバー側で `水稲畔塗` を補完する |
|
||||
| notes | text | blank | 備考 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `year + date` の一意制約は付けない
|
||||
- 同日に午前・午後や地区別で複数記録を持てるようにする
|
||||
|
||||
### LeveeWorkSessionItem(畔塗対象圃場明細)
|
||||
|
||||
畔塗記録に紐づく対象圃場一覧。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| session | FK(LeveeWorkSession) | CASCADE | 親の畔塗記録 |
|
||||
| field | FK(fields.Field) | PROTECT | 対象圃場 |
|
||||
| plan | FK(plans.Plan) | SET_NULL, nullable | 保存時点の作付け計画参照 |
|
||||
| crop_name_snapshot | varchar(100) | required | 保存時点の作物名 |
|
||||
| variety_name_snapshot | varchar(100) | blank | 保存時点の品種名 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `unique_together = ['session', 'field']`
|
||||
- 圃場名そのものは `Field` を参照して表示する
|
||||
- 作物・品種は履歴保全のためスナップショット保持を推奨する
|
||||
|
||||
### WorkRecord(作業記録索引)
|
||||
|
||||
既存 `apps/workrecords` の `WorkRecord` に畔塗種別を追加して連携する。
|
||||
|
||||
追加内容:
|
||||
|
||||
- `work_type` に `levee_work` を追加
|
||||
- `levee_work_session` への `OneToOne FK('levee_work.LeveeWorkSession')` を追加
|
||||
|
||||
想定制約:
|
||||
|
||||
- `on_delete=models.CASCADE`
|
||||
- `null=True`
|
||||
- `blank=True`
|
||||
- `related_name='work_record'`
|
||||
|
||||
削除方針:
|
||||
|
||||
- 親である `LeveeWorkSession` 削除時に、関連する `WorkRecord` は DB 制約の `CASCADE` で自動削除する
|
||||
- アプリケーション側での「紐づく WorkRecord を削除する」は、この DB 制約により満たされるものとして扱う
|
||||
|
||||
一覧表示時の想定値:
|
||||
|
||||
| 項目 | 値 |
|
||||
|---|---|
|
||||
| 作業日 | 畔塗記録の日付 |
|
||||
| 種別 | 畔塗 |
|
||||
| タイトル | 水稲畔塗 |
|
||||
| 参照先 | 畔塗した圃場一覧画面 |
|
||||
|
||||
---
|
||||
|
||||
## 候補圃場抽出ルール
|
||||
|
||||
畔塗対象候補は、作付け計画 `Plan` から抽出する。
|
||||
|
||||
### 基本条件
|
||||
|
||||
- 指定年度の `Plan` であること
|
||||
- `crop.name = "水稲"` の圃場であること
|
||||
- 圃場が存在すること
|
||||
|
||||
### 補足
|
||||
|
||||
- 判定条件は「品種が水稲」ではなく、原則として「作物が水稲」とする
|
||||
- `variety` は任意項目のため、品種未設定でも `crop=水稲` なら候補に含める
|
||||
- 並び順は `field.display_order`, `field.id`
|
||||
|
||||
### 候補レスポンスで返したい情報
|
||||
|
||||
| 項目 | 説明 |
|
||||
|---|---|
|
||||
| field_id | 圃場ID |
|
||||
| field_name | 圃場名 |
|
||||
| field_area_tan | 面積(反) |
|
||||
| group_name | グループ名 |
|
||||
| plan_id | 対応する作付け計画ID |
|
||||
| crop_name | 作物名 |
|
||||
| variety_name | 品種名 |
|
||||
| selected | 初期選択状態。候補圃場は原則 `true` を返し、全選択をデフォルトとする |
|
||||
|
||||
### 初期選択ルール
|
||||
|
||||
- 候補として返す水稲圃場は、原則すべて `selected=true` とする
|
||||
- 品種未設定の水稲圃場も `selected=true` とする
|
||||
- UI 上のチェック解除は、ユーザーが今回畔塗しない圃場を明示的に外すための操作と位置づける
|
||||
- 先行イメージ図にあった `☐ 山の前` は例示上の表現であり、初期ルールそのものではない
|
||||
|
||||
---
|
||||
|
||||
## 画面仕様
|
||||
|
||||
### 画面の位置づけ
|
||||
|
||||
畔塗機能は、日付を先に決めて対象圃場を選ぶ「日報型UI」とする。
|
||||
圃場ごとの個別登録画面ではなく、1回の保存で複数圃場をまとめて記録する。
|
||||
|
||||
### 主要画面
|
||||
|
||||
#### 1. 畔塗記録一覧画面
|
||||
|
||||
目的:
|
||||
|
||||
- 年度内の畔塗記録を一覧する
|
||||
- 新規作成画面へ遷移する
|
||||
- 既存記録の編集・削除を行う
|
||||
|
||||
表示項目:
|
||||
|
||||
- 畔塗日
|
||||
- タイトル
|
||||
- 対象圃場数
|
||||
- 対象圃場名の要約
|
||||
- 備考
|
||||
|
||||
#### 2. 畔塗記録作成・編集画面
|
||||
|
||||
入力項目:
|
||||
|
||||
- 日付
|
||||
- タイトル
|
||||
- 備考
|
||||
- 対象圃場一覧
|
||||
|
||||
対象圃場一覧の表示項目:
|
||||
|
||||
- 選択チェック
|
||||
- 圃場名
|
||||
- 面積
|
||||
- グループ
|
||||
- 作物
|
||||
- 品種
|
||||
|
||||
操作:
|
||||
|
||||
- 全選択
|
||||
- 全解除
|
||||
- 個別選択
|
||||
- 保存
|
||||
|
||||
初期表示ルール:
|
||||
|
||||
- 初回表示時は候補圃場を全選択状態で表示する
|
||||
- 編集時は保存済み明細に含まれる圃場を選択状態で復元する
|
||||
|
||||
### 推奨UIイメージ
|
||||
|
||||
```text
|
||||
畔塗記録作成
|
||||
|
||||
[日付 2026-04-20]
|
||||
[タイトル 水稲畔塗]
|
||||
[備考 __________________ ]
|
||||
|
||||
対象圃場一覧
|
||||
[全選択] [全解除]
|
||||
|
||||
☑ 田中上 1.2反 上エリア 水稲 コシヒカリ
|
||||
☑ 田中下 0.8反 上エリア 水稲 あきたこまち
|
||||
☐ 山の前 1.5反 南エリア 水稲 (未設定)
|
||||
|
||||
[保存]
|
||||
```
|
||||
|
||||
### 作業記録一覧への見え方
|
||||
|
||||
既存の作業記録一覧には次の形式で表示する。
|
||||
|
||||
| 列 | 表示内容 |
|
||||
|---|---|
|
||||
| 作業日 | 指定した日付 |
|
||||
| 種別 | 畔塗 |
|
||||
| タイトル | 水稲畔塗 |
|
||||
| 参照先 | 畔塗記録 #ID または対象圃場要約 |
|
||||
| 開く | 畔塗記録詳細画面へ遷移 |
|
||||
|
||||
---
|
||||
|
||||
## API エンドポイント
|
||||
|
||||
すべて JWT 認証必須。
|
||||
|
||||
### 畔塗記録
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/levee-work/sessions/?year={year}` | 年度別一覧 |
|
||||
| POST | `/api/levee-work/sessions/` | 新規作成 |
|
||||
| GET | `/api/levee-work/sessions/{id}/` | 詳細取得 |
|
||||
| PUT/PATCH | `/api/levee-work/sessions/{id}/` | 更新 |
|
||||
| DELETE | `/api/levee-work/sessions/{id}/` | 削除 |
|
||||
|
||||
### 候補圃場取得
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/levee-work/candidates/?year={year}` | 水稲作付け圃場候補を返す |
|
||||
|
||||
### レスポンス例(候補圃場)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"field_id": 5,
|
||||
"field_name": "田中上",
|
||||
"field_area_tan": "1.2000",
|
||||
"group_name": "上エリア",
|
||||
"plan_id": 12,
|
||||
"crop_name": "水稲",
|
||||
"variety_name": "コシヒカリ",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### リクエスト例(新規作成)
|
||||
|
||||
```json
|
||||
{
|
||||
"year": 2026,
|
||||
"date": "2026-04-20",
|
||||
"title": "水稲畔塗",
|
||||
"notes": "",
|
||||
"items": [
|
||||
{
|
||||
"field": 5,
|
||||
"plan": 12
|
||||
},
|
||||
{
|
||||
"field": 6,
|
||||
"plan": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
備考:
|
||||
|
||||
- `crop_name_snapshot` / `variety_name_snapshot` はクライアント送信項目ではない
|
||||
- サーバーが `plan` と `field` の整合を検証したうえで、保存時に `Plan` から自動設定する
|
||||
- `plan` が `null` の場合は、保存時点で参照できる `field` に対応する当年 `Plan` から補完を試みる
|
||||
|
||||
### レスポンス例(詳細)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"year": 2026,
|
||||
"date": "2026-04-20",
|
||||
"title": "水稲畔塗",
|
||||
"notes": "",
|
||||
"work_record_id": 15,
|
||||
"item_count": 2,
|
||||
"items": [
|
||||
{
|
||||
"id": 11,
|
||||
"field": 5,
|
||||
"field_name": "田中上",
|
||||
"plan": 12,
|
||||
"crop_name_snapshot": "水稲",
|
||||
"variety_name_snapshot": "コシヒカリ"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"field": 6,
|
||||
"field_name": "田中下",
|
||||
"plan": 13,
|
||||
"crop_name_snapshot": "水稲",
|
||||
"variety_name_snapshot": "あきたこまち"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-04-20T08:00:00Z",
|
||||
"updated_at": "2026-04-20T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 業務フロー
|
||||
|
||||
### 1. 新規作成
|
||||
|
||||
1. ユーザーが年度と日付を選ぶ
|
||||
2. システムが当年の水稲作付け圃場を候補表示する
|
||||
3. ユーザーが対象圃場を選択する
|
||||
4. 保存時に `LeveeWorkSession` を作成する
|
||||
5. 明細として `LeveeWorkSessionItem` を一括作成する
|
||||
6. 各明細の `crop_name_snapshot` / `variety_name_snapshot` をサーバー側で自動設定する
|
||||
7. `WorkRecord` を自動生成または更新する
|
||||
|
||||
### 2. 編集
|
||||
|
||||
1. ユーザーが既存の畔塗記録を開く
|
||||
2. 日付・タイトル・備考・対象圃場を変更する
|
||||
3. 保存時に明細を再構成する
|
||||
4. `WorkRecord` 側の作業日・タイトルも同期更新する
|
||||
5. 明細のスナップショットも保存時点情報で再構成する
|
||||
|
||||
### 3. 削除
|
||||
|
||||
1. ユーザーが畔塗記録を削除する
|
||||
2. 紐づく `LeveeWorkSessionItem` は `CASCADE` で削除される
|
||||
3. 紐づく `WorkRecord` は `levee_work_session` の `on_delete=CASCADE` により削除される
|
||||
|
||||
---
|
||||
|
||||
## 作業記録連携仕様
|
||||
|
||||
畔塗記録保存時に `apps/workrecords` 側へ自動反映する。
|
||||
|
||||
### 追加する種別
|
||||
|
||||
| enum値 | 表示名 |
|
||||
|---|---|
|
||||
| `levee_work` | 畔塗 |
|
||||
|
||||
### 自動生成ルール
|
||||
|
||||
- `work_date` = `session.date`
|
||||
- `work_type` = `levee_work`
|
||||
- `title` = `session.title`
|
||||
- `year` = `session.year`
|
||||
- `auto_created` = `True`
|
||||
- `levee_work_session` = 対応する畔塗記録
|
||||
- `delivery_trip` = `None`
|
||||
- `spreading_session` = `None`
|
||||
|
||||
実装メモ:
|
||||
|
||||
- 既存の `sync_spreading_work_record()` と同様に、`update_or_create()` の `defaults` 内で他系統 FK を明示的に `None` へそろえる
|
||||
- `title` の未入力は `LeveeWorkSession` 保存時にサーバー側で `水稲畔塗` を補完するため、同期処理では補完済みの `session.title` をそのまま使う
|
||||
|
||||
### 同期タイミング
|
||||
|
||||
- 畔塗記録作成時: `update_or_create`
|
||||
- 畔塗記録更新時: `update_or_create`
|
||||
- 畔塗記録削除時: `levee_work_session` の `on_delete=CASCADE` により `WorkRecord` も自動削除される
|
||||
|
||||
---
|
||||
|
||||
## バリデーションルール
|
||||
|
||||
### 必須
|
||||
|
||||
- `year`
|
||||
- `date`
|
||||
- `items`(1件以上)
|
||||
|
||||
### 保存時チェック
|
||||
|
||||
- 選択圃場が0件の保存を禁止する
|
||||
- 同一セッション内で同じ圃場を重複登録しない
|
||||
- 候補外圃場の保存を原則禁止する
|
||||
- `year` は原則 `date.year` と一致しなければならない
|
||||
- `plan` が指定されている場合、その `plan.field` と `field` は一致しなければならない
|
||||
- `plan` が指定されている場合、その `plan.year` は `session.year` と一致しなければならない
|
||||
|
||||
### 業務上の許容
|
||||
|
||||
- 品種未設定の水稲圃場は保存可
|
||||
- 同日に別記録を複数作ることは可
|
||||
- 一度畔塗した圃場を別日に再度記録することは可
|
||||
|
||||
---
|
||||
|
||||
## 実装方針
|
||||
|
||||
### バックエンド
|
||||
|
||||
- 新規アプリ `apps/levee_work` を追加する案を第一候補とする
|
||||
- `Session` / `SessionItem` 構成でモデル化する
|
||||
- Serializer は `read` と `write` を分離する
|
||||
- 候補取得 API は `Plan` を起点に組み立てる
|
||||
- `sync_levee_work_record(session)` を作成して `WorkRecord` と同期する
|
||||
- `WorkRecord` から `LeveeWorkSession` への参照は、アプリ間循環参照を避けるため文字列参照 `OneToOneField('levee_work.LeveeWorkSession', ...)` を使う
|
||||
|
||||
### フロントエンド
|
||||
|
||||
- 画面候補: `frontend/src/app/levee-work/page.tsx`
|
||||
- 1画面完結の一覧 + 作成/編集パネル、または一覧画面 + 詳細画面のどちらでも可
|
||||
- 既存の `fertilizer/spreading` の「一覧 + 編集」導線を参考にする
|
||||
- `workrecords/page.tsx` に遷移先判定を追加する
|
||||
|
||||
### 命名方針
|
||||
|
||||
- ユーザー向け表示は「畔塗」で統一
|
||||
- コード上の英語名は `levee_work` または `levee_coating` が候補
|
||||
- 既存の `WorkRecord.WorkType` に追加する値は、短く意味がぶれない `levee_work` を推奨する
|
||||
|
||||
---
|
||||
|
||||
## 画面遷移案
|
||||
|
||||
```text
|
||||
作業記録一覧
|
||||
└─ 畔塗レコードの「開く」
|
||||
└─ 畔塗記録画面(該当セッションを編集状態で開く)
|
||||
|
||||
畔塗記録画面
|
||||
├─ 新規作成
|
||||
├─ 既存記録の編集
|
||||
└─ 保存後、作業記録一覧に反映
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 将来拡張
|
||||
|
||||
- 作業者名の保持
|
||||
- 使用機械の記録
|
||||
- 実施済み圃場を地図で確認
|
||||
- 写真添付
|
||||
- 代かき、耕起、播種など他作業への横展開
|
||||
- 汎用作業日誌基盤への統合
|
||||
|
||||
---
|
||||
|
||||
## 実装タスク案
|
||||
|
||||
1. `apps/levee_work` アプリ新設
|
||||
2. `LeveeWorkSession` / `LeveeWorkSessionItem` モデル追加
|
||||
3. migration 作成
|
||||
4. serializer / view / url 実装
|
||||
5. 候補圃場 API 実装
|
||||
6. `WorkRecord` に畔塗種別と参照FK追加
|
||||
7. `sync_levee_work_record` サービス実装
|
||||
8. フロントエンド一覧・作成画面実装
|
||||
9. 作業記録一覧の遷移先対応
|
||||
10. テスト追加
|
||||
|
||||
---
|
||||
|
||||
## 注意点と設計判断
|
||||
|
||||
### なぜ「圃場ごと1件」ではなく「日付ごと1件」か
|
||||
|
||||
- 実際の作業単位が日付ベースである
|
||||
- 一覧が見やすい
|
||||
- 既存の散布実績機能と整合する
|
||||
- 作業記録索引との親和性が高い
|
||||
|
||||
### なぜ作付け計画を参照するか
|
||||
|
||||
- 水稲圃場だけを自然に抽出できる
|
||||
- 年度との整合が取りやすい
|
||||
- 将来「未畔塗候補」や「前年比較」に発展させやすい
|
||||
|
||||
### スナップショットを持つ理由
|
||||
|
||||
- 後から作付け計画が変更されても、記録時点の情報を追える
|
||||
- 作業記録としての監査性を保ちやすい
|
||||
|
||||
### なぜ snapshot をクライアント入力にしないか
|
||||
|
||||
- `plan` と `field` からサーバーが一意に導出できる情報だから
|
||||
- クライアント送信にすると改ざんや不整合の余地が増えるから
|
||||
- API 入力を最小限に保った方が UI 実装が単純になるから
|
||||
|
||||
---
|
||||
|
||||
## ソースファイル追加想定
|
||||
|
||||
### バックエンド
|
||||
|
||||
- `backend/apps/levee_work/models.py`
|
||||
- `backend/apps/levee_work/serializers.py`
|
||||
- `backend/apps/levee_work/views.py`
|
||||
- `backend/apps/levee_work/urls.py`
|
||||
- `backend/apps/levee_work/admin.py`
|
||||
- `backend/apps/levee_work/migrations/0001_initial.py`
|
||||
- `backend/apps/workrecords/models.py`
|
||||
- `backend/apps/workrecords/services.py`
|
||||
- `backend/apps/workrecords/serializers.py`
|
||||
- `backend/apps/workrecords/views.py`
|
||||
- `backend/keinasystem/urls.py`
|
||||
|
||||
### フロントエンド
|
||||
|
||||
- `frontend/src/app/levee-work/page.tsx`
|
||||
- `frontend/src/types/index.ts`
|
||||
- `frontend/src/app/workrecords/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## まとめ
|
||||
|
||||
畔塗作業機能は、
|
||||
「当年の水稲作付け圃場を候補として出し、日付単位で複数圃場をまとめて記録し、作業記録一覧へ自動反映する」
|
||||
というシンプルな構成を基本とする。
|
||||
|
||||
この構成により、既存の作付け計画・作業記録の設計を壊さずに、
|
||||
春作業の記録を自然に追加できる。
|
||||
316
document/16_マスタードキュメント_田植え計画編.md
Normal file
316
document/16_マスタードキュメント_田植え計画編.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# マスタードキュメント:田植え計画機能
|
||||
|
||||
> **作成**: 2026-04-04
|
||||
> **最終更新**: 2026-04-05
|
||||
> **対象機能**: 田植え計画(年度・種子資材を軸に複数回作成できる苗箱・種もみ使用量計画)
|
||||
> **実装状況**: MVP実装完了
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が「年度 × 種子資材」を軸に、田植え前の播種・育苗準備量を見積もる機能。
|
||||
各圃場について「実際に使う苗箱数」を記録し、計画全体で必要な種もみ量を自動集計する。
|
||||
|
||||
圃場候補は既存の作付け計画から自動取得し、選択した種子資材に紐づく品種を内部的に参照して候補圃場を決める。種もみ在庫は種子資材単位、反当苗箱枚数の初期値は紐づく品種単位で管理する。
|
||||
同じ年度・同じ種子資材でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
| IN(実装済み) | OUT(対象外) |
|
||||
|---|---|
|
||||
| 田植え計画の作成・編集・削除 | 育苗日程のカレンダー管理 |
|
||||
| 作付け計画からの候補圃場自動取得 | 実播種実績の記録 |
|
||||
| 圃場ごとの苗箱数の個別調整 | 種もみロット管理 |
|
||||
| 列単位のデフォルト反映・四捨五入 | 在庫の自動引当 |
|
||||
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
|
||||
| 種子資材ごとの種もみ在庫参照 | 品種ごとの播種日管理 |
|
||||
| 品種ごとの反当苗箱枚数デフォルト管理 | |
|
||||
|
||||
---
|
||||
|
||||
## 業務ルール
|
||||
|
||||
1. 田植え計画は `年度 × 種子資材` を軸に作成する
|
||||
2. 対象圃場は、選択した種子資材に紐づく品種の作付け計画が登録されている圃場から取得する
|
||||
3. 種もみ在庫は種子資材単位で管理する
|
||||
4. 反当苗箱枚数の初期値は、種子資材に紐づく品種単位で管理する
|
||||
5. 計画ヘッダ側に `反当苗箱枚数` を持ち、施肥計画の `反当袋数` と同じ役割で使う
|
||||
6. 画面上では `反当苗箱枚数 × 面積(反)` を各圃場のデフォルト苗箱数として表示する
|
||||
7. 実際に保存するのは圃場ごとの `苗箱数` であり、手動調整後の値をそのまま保持する
|
||||
8. `種もみg/箱` は計画全体の共通パラメータとして扱い、圃場ごとには持たない
|
||||
9. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
|
||||
10. 同じ年度・同じ種子資材で複数の計画を作成してよい
|
||||
11. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
|
||||
|
||||
---
|
||||
|
||||
## 計算式
|
||||
|
||||
### 圃場ごとのデフォルト苗箱数
|
||||
|
||||
`デフォルト苗箱数 = 圃場面積(反) × 反当苗箱枚数`
|
||||
|
||||
### 圃場ごとの種もみ使用量
|
||||
|
||||
`種もみkg = 苗箱数合計 × 苗箱1枚あたり種もみ(g) ÷ 1000`
|
||||
|
||||
### 計画全体の残在庫見込み
|
||||
|
||||
`残在庫見込み = 種子資材在庫(kg) - 計画全体の種もみkg合計`
|
||||
|
||||
---
|
||||
|
||||
## データモデル
|
||||
|
||||
### Variety(品種マスタ)
|
||||
|
||||
既存 `plans.Variety` に以下を追加・参照する。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| default_seedling_boxes_per_tan | decimal(6,2) | default=0 | 反当苗箱枚数の初期値 |
|
||||
| seed_material | FK(materials.Material) 相当 | nullable | その品種に対応する種子在庫 |
|
||||
|
||||
### RiceTransplantPlan(田植え計画)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| name | varchar(200) | required | 計画名 |
|
||||
| year | int | required | 年度 |
|
||||
| variety | FK(plans.Variety) | PROTECT | 内部参照用の品種 |
|
||||
| seedling_boxes_per_tan | decimal(6,2) | default=0 | 計画で使う反当苗箱枚数 |
|
||||
| default_seed_grams_per_box | decimal(8,2) | default=0 | 苗箱1枚あたり種もみ(g)の初期値 |
|
||||
| notes | text | blank | 備考 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- ユーザー操作上の主選択は `種子資材`
|
||||
- 保存時には、選択した種子資材に紐づく `Variety` を内部的に参照して保持する
|
||||
- `year + variety` の一意制約は持たない
|
||||
- 同一年度・同一種子資材で複数レコード作成可能
|
||||
|
||||
#### 表示用計算項目(APIレスポンスに含まれる)
|
||||
|
||||
| 項目 | 型 | 説明 |
|
||||
|---|---|---|
|
||||
| field_count | int | 対象圃場数 |
|
||||
| total_seedling_boxes | decimal | 苗箱数合計 |
|
||||
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
|
||||
| variety_seed_inventory_kg | decimal | 種子資材在庫(kg) |
|
||||
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
|
||||
|
||||
### RiceTransplantEntry(田植え計画エントリ)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| plan | FK(RiceTransplantPlan) | CASCADE | |
|
||||
| field | FK(fields.Field) | CASCADE | |
|
||||
| installed_seedling_boxes | decimal(8,2) | required | その圃場の苗箱数 |
|
||||
|
||||
- `unique_together = ['plan', 'field']`
|
||||
- 順序: `field__display_order, field__id`
|
||||
|
||||
#### 表示用計算項目(entryレスポンスに含まれる)
|
||||
|
||||
| 項目 | 型 | 説明 |
|
||||
|---|---|---|
|
||||
| field_name | string | 圃場名 |
|
||||
| field_area_tan | decimal | 圃場面積(反) |
|
||||
| default_seedling_boxes | decimal | `反当苗箱枚数 × 面積(反)` で求めたデフォルト候補 |
|
||||
| planned_boxes | decimal | 圃場ごとの苗箱数 |
|
||||
|
||||
---
|
||||
|
||||
## API エンドポイント
|
||||
|
||||
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
|
||||
|
||||
### 田植え計画
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/plans/rice-transplant-plans/?year={year}` | 年度別一覧 |
|
||||
| POST | `/api/plans/rice-transplant-plans/` | 新規作成 |
|
||||
| GET | `/api/plans/rice-transplant-plans/{id}/` | 詳細取得 |
|
||||
| PUT/PATCH | `/api/plans/rice-transplant-plans/{id}/` | 更新 |
|
||||
| DELETE | `/api/plans/rice-transplant-plans/{id}/` | 削除 |
|
||||
| GET | `/api/plans/rice-transplant-plans/candidate_fields/?year={year}&variety_id={id}` | 内部参照した品種IDで候補圃場取得 |
|
||||
|
||||
一覧レスポンス例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "2026年度 にこまる種もみ 田植え計画",
|
||||
"year": 2026,
|
||||
"variety": 3,
|
||||
"variety_name": "にこまる",
|
||||
"crop_name": "水稲",
|
||||
"seedling_boxes_per_tan": "12.00",
|
||||
"default_seed_grams_per_box": "200.00",
|
||||
"seed_material_name": "にこまる 種もみ",
|
||||
"notes": "",
|
||||
"field_count": 8,
|
||||
"total_seedling_boxes": "98.40",
|
||||
"total_seed_kg": "19.680",
|
||||
"variety_seed_inventory_kg": "25.000",
|
||||
"remaining_seed_kg": "5.320",
|
||||
"entries": [
|
||||
{
|
||||
"id": 10,
|
||||
"field": 5,
|
||||
"field_name": "田中上",
|
||||
"field_area_tan": "1.2000",
|
||||
"installed_seedling_boxes": "14.40",
|
||||
"default_seedling_boxes": "14.40",
|
||||
"planned_boxes": "14.40"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
POST/PUT リクエスト例:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "2026年度 にこまる種もみ 田植え計画",
|
||||
"year": 2026,
|
||||
"variety": 3,
|
||||
"seedling_boxes_per_tan": "12.00",
|
||||
"default_seed_grams_per_box": "200.00",
|
||||
"notes": "",
|
||||
"entries": [
|
||||
{
|
||||
"field_id": 5,
|
||||
"installed_seedling_boxes": "14.40"
|
||||
},
|
||||
{
|
||||
"field_id": 6,
|
||||
"installed_seedling_boxes": "13.80"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
更新時は `entries` を全置換する。
|
||||
|
||||
### 品種マスタ更新 / 在庫管理
|
||||
|
||||
田植え計画に必要な既定値は既存 API で更新する。
|
||||
|
||||
| メソッド | URL | 更新項目 |
|
||||
|---|---|---|
|
||||
| PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` |
|
||||
| PATCH | `/api/plans/varieties/{id}/` | `seed_material` または同等の種子在庫参照 |
|
||||
| CRUD | `/api/materials/materials/?material_type=seed` | 種子在庫マスタ |
|
||||
|
||||
---
|
||||
|
||||
## 画面仕様
|
||||
|
||||
### 1. 田植え計画一覧 `/rice-transplant`
|
||||
|
||||
- 年度切替
|
||||
- 田植え計画の一覧表示
|
||||
- 同一年度・同一種子資材の計画が複数並ぶことを想定する
|
||||
- 表示列:
|
||||
- 計画名
|
||||
- 種子資材
|
||||
- 圃場数
|
||||
- 苗箱合計
|
||||
- 種もみ計画kg
|
||||
- 残在庫見込みkg
|
||||
- 行アクション:
|
||||
- 編集
|
||||
- 削除
|
||||
|
||||
### 2. 田植え計画編集 `/rice-transplant/new`, `/rice-transplant/{id}/edit`
|
||||
|
||||
- 基本情報:
|
||||
- 計画名
|
||||
- 同一年度・同一種子資材の複数計画を区別できる名称を付ける
|
||||
- 例: `2026年度 にこまる種もみ 第1回`, `2026年度 にこまる種もみ 4/15播種分`
|
||||
- 年度
|
||||
- 種子資材
|
||||
- 苗箱1枚あたり種もみ(g) デフォルト
|
||||
- 備考
|
||||
- 対象圃場:
|
||||
- 種子資材選択後に、その資材に紐づく品種の作付け計画から候補圃場を自動取得
|
||||
- 新規作成時は候補圃場を初期選択
|
||||
- 圃場の追加・除外が可能
|
||||
- 初期値:
|
||||
- `反当苗箱枚数` は紐づく品種マスタの `default_seedling_boxes_per_tan` を初期値として表示
|
||||
- `反当苗箱枚数 × 面積(反)` で各圃場のデフォルト苗箱数を求める
|
||||
- `種もみg/箱` は計画全体の共通値
|
||||
- 圃場テーブル:
|
||||
- 圃場
|
||||
- 面積(反)
|
||||
- 小数は 2 桁表示を基本とする
|
||||
- 苗箱数入力欄
|
||||
- 左側にデフォルト苗箱数ラベルを表示
|
||||
- 小数は 1 桁表示を基本とする
|
||||
- 列操作:
|
||||
- `反当苗箱枚数` の入力欄
|
||||
- デフォルトを列単位で一括反映するボタン
|
||||
- 列単位の四捨五入ボタン
|
||||
- 施肥計画の四捨五入ボタンと同じ配置・2ステート動作
|
||||
- サマリー:
|
||||
- 対象圃場数
|
||||
- 苗箱合計
|
||||
- 種もみ計画kg
|
||||
- 種子資材在庫kg
|
||||
- 残在庫見込みkg
|
||||
|
||||
### 3. 品種管理モーダル `/allocation`
|
||||
|
||||
既存の作付け計画画面内の品種管理モーダルを拡張。
|
||||
|
||||
- 品種単位:
|
||||
- 反当苗箱枚数デフォルトを更新可能
|
||||
|
||||
### 4. 資材マスタ `/materials/masters`
|
||||
|
||||
- 種子タブ:
|
||||
- 種子資材を登録・編集できる
|
||||
- 各種子資材に対応する品種を 1 件選んで紐付ける
|
||||
|
||||
---
|
||||
|
||||
## バリデーション・運用ルール
|
||||
|
||||
1. 計画名は必須
|
||||
2. 種子資材は必須
|
||||
3. 圃場は1件以上必要
|
||||
4. `installed_seedling_boxes` と `seedling_boxes_per_tan` は 0 以上の数値を想定
|
||||
5. 在庫不足でも保存は許可し、UIで不足を可視化する
|
||||
6. 候補圃場の抽出元は既存 `Plan`(作付け計画)であるため、先に作付け計画が必要
|
||||
|
||||
---
|
||||
|
||||
## 既知の制約
|
||||
|
||||
1. 田植え計画の PDF 出力は未実装
|
||||
2. 実播種や田植え実績との連携は未実装
|
||||
|
||||
---
|
||||
|
||||
## 関連ファイル
|
||||
|
||||
| 種別 | パス |
|
||||
|---|---|
|
||||
| モデル | `backend/apps/plans/models.py` |
|
||||
| モデル | `backend/apps/materials/models.py` |
|
||||
| シリアライザ | `backend/apps/plans/serializers.py` |
|
||||
| シリアライザ | `backend/apps/materials/serializers.py` |
|
||||
| ViewSet | `backend/apps/plans/views.py` |
|
||||
| URL | `backend/apps/plans/urls.py` |
|
||||
| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py`, `backend/apps/plans/migrations/0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes.py`, `backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py`, `backend/apps/plans/migrations/0008_variety_seed_material.py`, `backend/apps/plans/migrations/0009_alter_ricetransplantentry_installed_seedling_boxes.py`, `backend/apps/materials/migrations/0005_material_seed_type.py` |
|
||||
| 一覧画面 | `frontend/src/app/rice-transplant/page.tsx` |
|
||||
| 編集画面 | `frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx` |
|
||||
| ナビゲーション | `frontend/src/components/Navbar.tsx` |
|
||||
| 品種管理モーダル | `frontend/src/app/allocation/page.tsx` |
|
||||
| 在庫画面 | `frontend/src/app/materials/page.tsx` |
|
||||
| 資材マスタ | `frontend/src/app/materials/masters/page.tsx` |
|
||||
298
document/17_マスタードキュメント_ナビゲーション再編編.md
Normal file
298
document/17_マスタードキュメント_ナビゲーション再編編.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# マスタードキュメント:ナビゲーション再編
|
||||
|
||||
> **作成**: 2026-04-07
|
||||
> **最終更新**: 2026-04-07
|
||||
> **対象機能**: グローバルナビゲーション再編(トップメニュー整理・カテゴリ再編・PC/スマホ共通情報設計)
|
||||
> **実装状況**: 仕様策定完了・未実装
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
機能追加に伴って共通ナビゲーションのトップレベル項目が増え、画面名ベースで並ぶ構造になってきたため、業務カテゴリ単位で再整理する。
|
||||
|
||||
今回の再編では、トップナビを `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類に絞り、個別画面はドロップダウン配下に集約する。
|
||||
これにより、利用者が「どの画面名か」ではなく「何をしたいか」で画面を探せる状態を目指す。
|
||||
|
||||
また、URL 構造とメニュー構成は意図的に分離して扱う。既存 URL は安定性を優先して原則維持し、アクティブ判定はナビ定義側で吸収する。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
| IN(今回対象) | OUT(今回対象外) |
|
||||
|---|---|
|
||||
| PC ヘッダーのトップメニュー再編 | 各業務画面自体のUI改修 |
|
||||
| スマホ用ハンバーガーメニュー再編 | 権限別メニュー出し分け |
|
||||
| メニュー分類、並び順、開閉仕様 | お気に入り、ピン留め |
|
||||
| アクティブ判定ルール整理 | ダッシュボード内容の刷新 |
|
||||
| `NavGroup` / `NavItem` ベースのメニュー定義整理 | URL の全面変更 |
|
||||
| `作物` `品種` を将来のマスター画面として位置づけ | 矢印キー移動を含む高度なメニューアクセシビリティ |
|
||||
|
||||
---
|
||||
|
||||
## 背景と判断理由
|
||||
|
||||
### 現状の課題
|
||||
|
||||
- 横並びのトップメニュー数が多く、目的の画面を探しにくい
|
||||
- `計画` `実績` `設定` `補助機能` が同じ粒度で並んでいる
|
||||
- 画面名ベースで項目が増えており、業務単位でまとまっていない
|
||||
- 今後も機能追加が続くと、視認性と拡張性の両方が悪化する
|
||||
|
||||
### 採用した考え方
|
||||
|
||||
1. トップレベルは日常的に使う業務カテゴリだけに絞る
|
||||
2. 個別機能名ではなく、業務単位で束ねる
|
||||
3. URL はリソース識別子として安定性を優先し、メニュー構成とは分離する
|
||||
4. 例外的な URL 衝突のみナビ定義側のルールで吸収する
|
||||
|
||||
### 関連議論
|
||||
|
||||
- 判断理由、論点の切り分け、URL とメニューの関係整理は Gitea Issue `#13` に残す
|
||||
- 実装向けの決定事項は `改善案/ナビゲーション再編仕様書.md` に集約する
|
||||
- 本ドキュメントは、その内容を長期参照用に固定化したものとして扱う
|
||||
|
||||
---
|
||||
|
||||
## 情報設計
|
||||
|
||||
### トップレベル構成
|
||||
|
||||
1. ホーム
|
||||
2. 計画
|
||||
3. 実績
|
||||
4. マスター
|
||||
5. 帳票・連携
|
||||
|
||||
右上ユーザー操作:
|
||||
|
||||
- パスワード変更
|
||||
- ログアウト
|
||||
|
||||
### カテゴリ構成
|
||||
|
||||
#### ホーム
|
||||
|
||||
- ダッシュボード
|
||||
|
||||
#### 計画
|
||||
|
||||
- 作付け計画
|
||||
- 施肥計画
|
||||
- 田植え計画
|
||||
- 運搬計画
|
||||
|
||||
#### 実績
|
||||
|
||||
- 散布実績
|
||||
- 畔塗記録
|
||||
- 作業記録
|
||||
|
||||
#### マスター
|
||||
|
||||
- 圃場管理
|
||||
- 作物
|
||||
- 品種
|
||||
- 資材マスタ
|
||||
- 肥料マスタ
|
||||
|
||||
#### 帳票・連携
|
||||
|
||||
- 在庫管理
|
||||
- 帳票出力
|
||||
- データ取込
|
||||
- 気象
|
||||
- メール
|
||||
|
||||
### この分類にした理由
|
||||
|
||||
#### マスター
|
||||
|
||||
- `圃場管理` は圃場マスタとして独立性が高い
|
||||
- `作物` `品種` も本来マスター管理である
|
||||
- `資材マスタ` `肥料マスタ` はすでに独立画面が存在する
|
||||
|
||||
そのため、基礎データ管理を `マスター` に集約する。
|
||||
|
||||
#### 帳票・連携
|
||||
|
||||
- `在庫管理` `帳票出力` `データ取込` `気象` `メール` は完全に同質ではない
|
||||
- ただし、いずれも主作業そのものではなく、補助・参照・出力・連携の性質が強い
|
||||
|
||||
そのため、トップ階層を増やしすぎないための受け皿として `帳票・連携` にまとめる。
|
||||
|
||||
補足:
|
||||
|
||||
- `データ取込` は日常操作ではなく、年度切替時や初期設定時の補助導線とみなす
|
||||
- `メール` は個別トップにしない
|
||||
- `設定` は現状パスワード変更のみなので、右上ユーザー操作に残す
|
||||
|
||||
---
|
||||
|
||||
## 画面と所属カテゴリ
|
||||
|
||||
| カテゴリ | ラベル | パス |
|
||||
|---|---|---|
|
||||
| ホーム | ダッシュボード | `/dashboard` |
|
||||
| 計画 | 作付け計画 | `/allocation` |
|
||||
| 計画 | 施肥計画 | `/fertilizer` |
|
||||
| 計画 | 田植え計画 | `/rice-transplant` |
|
||||
| 計画 | 運搬計画 | `/distribution` |
|
||||
| 実績 | 散布実績 | `/fertilizer/spreading` |
|
||||
| 実績 | 畔塗記録 | `/levee-work` |
|
||||
| 実績 | 作業記録 | `/workrecords` |
|
||||
| マスター | 圃場管理 | `/fields` |
|
||||
| マスター | 作物 | 未実装(allocation 内管理を独立予定) |
|
||||
| マスター | 品種 | 未実装(allocation 内管理を独立予定) |
|
||||
| マスター | 資材マスタ | `/materials/masters` |
|
||||
| マスター | 肥料マスタ | `/fertilizer/masters` |
|
||||
| 帳票・連携 | 在庫管理 | `/materials` |
|
||||
| 帳票・連携 | 帳票出力 | `/reports` |
|
||||
| 帳票・連携 | データ取込 | `/import` |
|
||||
| 帳票・連携 | 気象 | `/weather` |
|
||||
| 帳票・連携 > メール | メール履歴 | `/mail/history` |
|
||||
| 帳票・連携 > メール | メールルール | `/mail/rules` |
|
||||
| 右上ユーザー操作 | パスワード変更 | `/settings/password` |
|
||||
|
||||
---
|
||||
|
||||
## URL とナビゲーションの関係
|
||||
|
||||
### 基本原則
|
||||
|
||||
1. URL はリソース・機能識別子として安定性を優先する
|
||||
2. メニュー構成とは意図的に分離して扱う
|
||||
3. メニュー再編のたびに URL を変更しない
|
||||
4. アクティブ判定はナビ定義側のルールで吸収する
|
||||
|
||||
### 採用理由
|
||||
|
||||
- URL をメニュー階層に合わせて変更すると、既存リンク、ブックマーク、テストへの影響が大きい
|
||||
- メニュー構成は将来も変わりうるため、URL にメニュー階層を埋め込むと変更コストが増える
|
||||
|
||||
### 衝突する既存パス
|
||||
|
||||
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|
||||
|---|---|---|
|
||||
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
|
||||
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
|
||||
|
||||
通常判定:
|
||||
|
||||
- `/fertilizer` `/fertilizer/new` `/fertilizer/[id]/edit` は `施肥計画`
|
||||
- `/materials` `/materials?tab=...` は `在庫管理`
|
||||
|
||||
---
|
||||
|
||||
## 表示仕様
|
||||
|
||||
### PC
|
||||
|
||||
- 左: ブランド名 `KeinaSystem`
|
||||
- 中央: トップメニュー 5 項目
|
||||
- 右: パスワード変更、ログアウト
|
||||
|
||||
表示ルール:
|
||||
|
||||
- `ホーム` は単独リンク
|
||||
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン
|
||||
- 開いているメニューがある状態で別メニューを開く場合は、前のメニューを閉じる
|
||||
- メニュー外クリック、`Esc` キーで閉じる
|
||||
- 項目選択後は遷移して閉じる
|
||||
|
||||
### スマホ
|
||||
|
||||
- ハンバーガーメニューを採用する
|
||||
- `ホーム` は単独リンクで `/dashboard` へ遷移する
|
||||
- それ以外のカテゴリはアコーディオン形式で開閉する
|
||||
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
|
||||
- 項目タップ後はメニューを閉じて画面遷移する
|
||||
|
||||
---
|
||||
|
||||
## アクセシビリティ方針
|
||||
|
||||
- トップメニューへキーボードでフォーカス移動できること
|
||||
- `Enter` または `Space` でドロップダウンを開閉できること
|
||||
- ドロップダウン展開後、各項目へ `Tab` で到達できること
|
||||
- `Esc` で閉じられること
|
||||
- 現在位置が視覚的に分かること
|
||||
|
||||
### 初期実装でやらないこと
|
||||
|
||||
- 矢印キーによるドロップダウン項目間移動
|
||||
|
||||
これは Phase 1 の必須要件には含めず、将来のアクセシビリティ強化項目として扱う。
|
||||
|
||||
---
|
||||
|
||||
## 実装方針
|
||||
|
||||
### メニュー定義
|
||||
|
||||
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
|
||||
|
||||
```ts
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
match?: (pathname: string) => boolean;
|
||||
};
|
||||
|
||||
type NavGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'link' | 'group';
|
||||
href?: string;
|
||||
items?: NavItem[];
|
||||
};
|
||||
```
|
||||
|
||||
方針:
|
||||
|
||||
- グループ構成そのものが定義から読み取れることを優先する
|
||||
- 通常ケースは `href` ベースで扱う
|
||||
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
|
||||
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
|
||||
|
||||
### Next.js App Router との関係
|
||||
|
||||
- Route Groups は、URL を変えずにコード構造を整理する手段として有効
|
||||
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
|
||||
|
||||
---
|
||||
|
||||
## 段階導入
|
||||
|
||||
### Phase 1
|
||||
|
||||
- トップナビを 5 分類へ再編する
|
||||
- `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみ
|
||||
- `作物` `品種` はマスター体系には含めるが、独立画面がまだないため Phase 1 ではメニューに表示しない
|
||||
- PC / スマホともに同じ情報設計にそろえる
|
||||
|
||||
### Phase 2
|
||||
|
||||
- `作物管理` `品種管理` を独立画面として追加
|
||||
- `帳票・連携` 内の `メール` を必要に応じてサブグループ化
|
||||
|
||||
### Phase 3
|
||||
|
||||
- 将来マルチユーザー化した場合のみ再検討
|
||||
- 単独利用前提の間は実施対象外
|
||||
|
||||
---
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
|
||||
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
|
||||
- 各画面でアクティブ状態が期待通りに表示されること
|
||||
- PC とスマホで同じカテゴリ構成になっていること
|
||||
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
|
||||
|
||||
---
|
||||
|
||||
## 参照
|
||||
|
||||
- 議論の背景・判断理由: Gitea Issue `#13 メニューがごちゃごちゃしてきたので、整理する`
|
||||
- 実装向け詳細仕様: `改善案/ナビゲーション再編仕様書.md`
|
||||
639
document/18_マスタードキュメント_農薬散布管理編.md
Normal file
639
document/18_マスタードキュメント_農薬散布管理編.md
Normal file
@@ -0,0 +1,639 @@
|
||||
# マスタードキュメント:農薬散布管理機能
|
||||
|
||||
> **作成**: 2026-04-09
|
||||
> **最終更新**: 2026-04-09
|
||||
> **対象機能**: 農薬散布管理(農薬マスタ・散布記録・使用回数チェック・特別栽培向け成分数集計)
|
||||
> **実装状況**: 未着手(仕様確定済み)
|
||||
> **Gitea Issue**: akira/keinasystem#18
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が散布した農薬を記録・管理し、農薬取締法に基づく使用基準(製品ごと・有効成分ごとの使用回数制限)への適合確認と、特別栽培認証用の成分数集計を行う機能。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
| IN(実装対象) | OUT(対象外) |
|
||||
|---|---|
|
||||
| 農薬マスタ管理(CRUD) | 農薬の在庫管理・購入管理 |
|
||||
| 農林水産省サイトからの農薬情報自動取得 | 農薬費用の管理 |
|
||||
| 散布イベント記録(圃場/グループ/作物/品種対象) | 希釈液の量管理 |
|
||||
| 製品ごとの使用回数チェック(年度×作物) | 農薬の廃棄記録 |
|
||||
| 有効成分ごとの総使用回数チェック(年度×作物) | 農薬散布マップ(GIS) |
|
||||
| 特別栽培用:節減対象農薬の使用成分数集計 | 農薬の処方箋・防除暦の自動作成 |
|
||||
| 回数超過アラート表示 | |
|
||||
|
||||
---
|
||||
|
||||
## 使用回数カウントのルール
|
||||
|
||||
農薬の使用回数は **製品単位** と **有効成分単位** の2軸で管理する。
|
||||
|
||||
### ルール1:製品ごとの使用回数
|
||||
|
||||
農薬製品(例: 住化スミチオン乳剤)を1シーズンに使用した回数 ≤ 登録情報の「本剤の使用回数」上限。
|
||||
|
||||
### ルール2:有効成分ごとの総使用回数
|
||||
|
||||
同一有効成分を含む複数製品を使用した場合、その有効成分の総使用回数として合算カウントする。
|
||||
|
||||
```
|
||||
例)「MEP乳剤A(上限3回)」と「MEP乳剤B(上限3回)」、MEP成分の総上限3回
|
||||
→ A剤2回 + B剤1回 = 合計3回 → OK
|
||||
→ A剤2回 + B剤2回 = 合計4回 → 超過!
|
||||
```
|
||||
|
||||
### ルール3:使用時期別カウント
|
||||
|
||||
育苗期・本圃期など時期別に別カウントになる場合がある(登録情報のテキストとして記録)。
|
||||
システムでは現フェーズで時期別の自動判定は行わず、登録情報テキストを参照情報として表示する。
|
||||
|
||||
### カウント対象外農薬(節減対象外)
|
||||
|
||||
以下の農薬は使用回数・成分数のカウントから除外する(`is_non_target` フラグで管理):
|
||||
|
||||
- 展着剤(`is_spreader` フラグでも管理)
|
||||
- 有機JAS別表2に掲げる農薬(除虫菊乳剤・硫黄剤・天敵生物農薬・性フェロモン剤等)
|
||||
- 化学合成でないと認められた農薬(カスガマイシン剤・ポリオキシン剤・バリダマイシン剤等)
|
||||
|
||||
### 特別栽培向け成分数集計
|
||||
|
||||
「節減対象農薬(`is_non_target=False`)の有効成分(`is_active=True`)が何種類使われたか」を年度×作物単位でカウントする。
|
||||
上限はなく、報告用の集計値として表示する。
|
||||
|
||||
---
|
||||
|
||||
## データモデル
|
||||
|
||||
### Pesticide(農薬マスタ)
|
||||
|
||||
**アプリ**: `apps/pesticide`
|
||||
**テーブル名**: `pesticide_pesticide`
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| name | CharField(200) | required | 農薬名(例: 住化スミチオン乳剤) |
|
||||
| pesticide_type | CharField(100) | blank | 農薬の種類(例: MEP乳剤) |
|
||||
| registration_number | CharField(20) | blank | 農薬登録番号(公式登録番号) |
|
||||
| system_id | CharField(20) | blank | 農水省サイトの内部ID(詳細URLに使用) |
|
||||
| purpose | CharField(100) | blank | 用途(例: 殺虫剤) |
|
||||
| formulation | CharField(100) | blank | 剤型(例: 乳剤) |
|
||||
| toxicity | CharField(20) | blank | 製剤毒性(普/毒/劇等) |
|
||||
| is_spreader | BooleanField | default=False | 展着剤フラグ |
|
||||
| is_non_target | BooleanField | default=False | 節減対象外フラグ(カウント除外) |
|
||||
| notes | TextField | blank | 備考 |
|
||||
| fetched_at | DateTimeField | null=True | 農水省サイトからの最終取得日時 |
|
||||
| created_at | DateTimeField | auto | |
|
||||
| updated_at | DateTimeField | auto | |
|
||||
|
||||
- `name` は unique 制約なし(同名で複数登録番号が存在しうる)
|
||||
- `is_spreader=True` の場合、`is_non_target` も自動的に `True` 扱いとする
|
||||
|
||||
### PesticideIngredient(有効成分)
|
||||
|
||||
**テーブル名**: `pesticide_pesticideingredient`
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| pesticide | FK(Pesticide) | CASCADE | |
|
||||
| name | CharField(200) | required | 成分名称(例: MEP) |
|
||||
| concentration | CharField(100) | blank | 含有濃度(例: 50.0%) |
|
||||
| is_active | BooleanField | default=True | 有効成分かどうか(False = その他成分) |
|
||||
|
||||
- `unique_together = ['pesticide', 'name']`
|
||||
|
||||
### PesticideIngredientLimit(有効成分の総使用回数上限:作物別)
|
||||
|
||||
**テーブル名**: `pesticide_pesticideingredientlimit`
|
||||
|
||||
農水省の「○○を含む農薬の総使用回数」は作物ごとに異なりうるため、有効成分本体とは分離して作物別に保持する。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| pesticide | FK(Pesticide) | CASCADE | 取得元農薬 |
|
||||
| ingredient_name | CharField(200) | required | 成分名称(例: MEP) |
|
||||
| crop_name | CharField(200) | required | 作物名(農水省登録情報の表記、例: 稲) |
|
||||
| max_total_uses | IntegerField | null=True | この成分を含む農薬の総使用回数上限 |
|
||||
| use_timing_note | TextField | blank | 使用時期別制限のテキスト(例: 種もみへの処理は1回以内、…) |
|
||||
|
||||
- `unique_together = ['pesticide', 'ingredient_name', 'crop_name']`
|
||||
- 同一成分・同一作物であれば製品が異なっても上限値は同一(農水省登録情報の仕様)
|
||||
- 保存時バリデーション: 同一 `ingredient_name + crop_name` の既存レコードと異なる `max_total_uses` を保存しようとした場合はエラーにする
|
||||
- 使用回数チェック API の `ingredient_usage.max_total_uses` は、同一 `ingredient_name + crop_name` の値が一意であることを前提に単一値を返す
|
||||
|
||||
### PesticideProductLimit(製品の使用回数上限:作物別)
|
||||
|
||||
**テーブル名**: `pesticide_pesticideproductlimit`
|
||||
|
||||
農水省の適用表は作物ごとに上限が異なるため、作物名をキーとして保存する。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| pesticide | FK(Pesticide) | CASCADE | |
|
||||
| crop_name | CharField(200) | required | 作物名(農水省登録情報の表記、例: 稲) |
|
||||
| max_uses | IntegerField | required | 本剤の使用回数上限 |
|
||||
| use_timing_note | TextField | blank | 使用時期・条件の補足テキスト |
|
||||
|
||||
- `unique_together = ['pesticide', 'crop_name']`
|
||||
|
||||
### PesticideCropAlias(農水省作物名と内部作物の対応)
|
||||
|
||||
**テーブル名**: `pesticide_pesticidecropalias`
|
||||
|
||||
農水省の適用表上の作物名と、内部 `plans.Crop` の作物を対応付けるための正規化テーブル。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| crop | FK(plans.Crop) | PROTECT | 内部作物 |
|
||||
| alias_name | CharField(200) | required, unique | 農水省登録情報の作物名(例: 稲, 水稲) |
|
||||
| is_primary | BooleanField | default=False | 代表表記かどうか |
|
||||
|
||||
- 使用回数チェック時は `crop_id` から本テーブルを逆引きし、`PesticideProductLimit.crop_name` / `PesticideIngredientLimit.crop_name` と照合する
|
||||
- 初期データ例: `Crop=水稲` に対し `alias_name=稲`, `alias_name=水稲` を登録
|
||||
|
||||
### SprayEvent(散布イベント)
|
||||
|
||||
**テーブル名**: `pesticide_sprayevent`
|
||||
|
||||
1回の散布作業を1件として記録する。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| year | IntegerField | required | 年度(集計フィルタ用) |
|
||||
| date | DateField | required | 散布日 |
|
||||
| target_type | CharField(20) | required | 対象種別: `field` / `group` / `crop` / `variety` |
|
||||
| target_field | FK(fields.Field) | null=True, PROTECT | 対象が圃場の場合 |
|
||||
| target_group | CharField(50) | blank | 対象が圃場グループの場合(group_name) |
|
||||
| target_crop | FK(plans.Crop) | null=True, PROTECT | 対象が作物の場合 |
|
||||
| target_variety | FK(plans.Variety) | null=True, PROTECT | 対象が品種の場合 |
|
||||
| notes | TextField | blank | 備考 |
|
||||
| created_at | DateTimeField | auto | |
|
||||
| updated_at | DateTimeField | auto | |
|
||||
|
||||
#### target_type 別のバリデーション
|
||||
|
||||
| target_type | 必須フィールド | 意味 |
|
||||
|---|---|---|
|
||||
| `field` | target_field | 特定の圃場1筆に散布 |
|
||||
| `group` | target_group | 同一 group_name の全圃場に散布 |
|
||||
| `crop` | target_crop | 特定の作物に対して散布(作付け計画と照合) |
|
||||
| `variety` | target_variety | 特定の品種に対して散布(作付け計画と照合) |
|
||||
|
||||
- 保存時に全対象圃場を `SprayEventResolvedField` として確定保存し、後日の作付け変更やグループ名変更があっても過去実績の集計結果が変わらないようにする
|
||||
|
||||
### SprayEventResolvedField(散布イベント対象圃場スナップショット)
|
||||
|
||||
**テーブル名**: `pesticide_sprayeventresolvedfield`
|
||||
|
||||
`target_type=group` / `crop` / `variety` のように複数圃場へ展開される散布について、保存時点で対象圃場を確定保存する。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| event | FK(SprayEvent) | CASCADE | |
|
||||
| field | FK(fields.Field) | PROTECT | 対象圃場 |
|
||||
| field_name_snapshot | CharField(100) | required | 保存時点の圃場名 |
|
||||
| group_name_snapshot | CharField(50) | blank | 保存時点のグループ名 |
|
||||
| crop_name_snapshot | CharField(100) | required | 保存時点の作物名 |
|
||||
| variety_name_snapshot | CharField(100) | blank | 保存時点の品種名 |
|
||||
|
||||
- `unique_together = ['event', 'field']`
|
||||
- `target_type=field` の場合も 1 行作成しておくと、集計ロジックを統一しやすい
|
||||
|
||||
### SprayEventPesticide(散布農薬明細)
|
||||
|
||||
**テーブル名**: `pesticide_sprayeventpesticide`
|
||||
|
||||
1つの散布イベントに複数農薬を紐づける。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | |
|
||||
| event | FK(SprayEvent) | CASCADE | |
|
||||
| pesticide | FK(Pesticide) | PROTECT | 使用農薬 |
|
||||
| dilution_ratio | CharField(50) | blank | 希釈倍率(例: 1000倍) |
|
||||
| amount_used | CharField(50) | blank | 使用量(例: 500mL、単位込みで自由記述) |
|
||||
| notes | TextField | blank | 備考 |
|
||||
|
||||
- `pesticide` は PROTECT(使用済み農薬は削除不可)
|
||||
- `unique_together = ['event', 'pesticide']`(同一イベント内で同じ農薬を2回登録不可)
|
||||
|
||||
---
|
||||
|
||||
## 使用回数集計の仕組み
|
||||
|
||||
### 集計単位
|
||||
|
||||
**年度 × 作物** を基本単位とする(農薬取締法上、使用回数は作物単位で管理する義務がある)。
|
||||
|
||||
- 集計対象作物は `SprayEventResolvedField.crop_name_snapshot` を正とする(圃場ごとに記録)
|
||||
- `target_type=field`/`group`/`crop`/`variety` の違いにかかわらず、保存時に全対象圃場の `SprayEventResolvedField` を作成し、各圃場の作物をスナップショットとして保持する
|
||||
- **グループ内に複数作物が混在する場合**、同一の散布イベント・散布農薬でも作物ごとに使用回数がカウントされる。例:グループ内に「水稲」3筆・「大豆」1筆が含まれる場合、そのイベントの農薬は水稲の回数にも大豆の回数にも +1 される
|
||||
- 使用回数上限の照合は、`SprayEventResolvedField.crop_name_snapshot` → `PesticideCropAlias` → `PesticideProductLimit` / `PesticideIngredientLimit` の順に行う
|
||||
|
||||
### 製品使用回数の集計
|
||||
|
||||
1イベント = 1散布作業 = 1回。`unique_together=['event', 'pesticide']` により同一イベント内で同一農薬は1行しか存在しないため、イベント単位でカウントして正確。
|
||||
|
||||
```
|
||||
製品使用回数(年度Y・作物C・農薬P)=
|
||||
COUNT(DISTINCT SprayEvent.id)
|
||||
where SprayEvent に SprayEventPesticide(pesticide=P) が紐づく
|
||||
かつ SprayEvent に SprayEventResolvedField(crop_name_snapshot=C) が紐づく
|
||||
かつ SprayEvent.year = Y
|
||||
```
|
||||
|
||||
※ 1イベントで複数圃場に散布しても「1回」とカウントする(1イベント=1散布作業)
|
||||
|
||||
### 有効成分総使用回数の集計
|
||||
|
||||
1回の散布作業(イベント)= 有効成分の使用回数1回。同一成分を含む複数製品を同一イベントで施用することは実務上なく、仮に混合散布しても農薬取締法上「1回の散布 = 1回の使用」と解釈される。
|
||||
|
||||
```
|
||||
有効成分総使用回数(年度Y・作物C・成分名I)=
|
||||
COUNT(DISTINCT SprayEvent.id)
|
||||
where SprayEvent に SprayEventPesticide が紐づく
|
||||
かつ SprayEventPesticide.pesticide の PesticideIngredient に
|
||||
name=I かつ is_active=True のものが存在する
|
||||
かつ SprayEventPesticide.pesticide.is_non_target=False
|
||||
かつ SprayEvent に SprayEventResolvedField(crop_name_snapshot=C) が紐づく
|
||||
かつ SprayEvent.year = Y
|
||||
```
|
||||
|
||||
※ `SprayEventResolvedField` は圃場ごとに複数行あるため、結合で行が増えても `DISTINCT SprayEvent.id` で 1散布作業を1回だけ数える
|
||||
|
||||
### 特別栽培・使用成分数の集計
|
||||
|
||||
```
|
||||
使用成分数(年度Y・作物C)=
|
||||
COUNT(DISTINCT PesticideIngredient.name)
|
||||
where 上記条件(年度Y・作物C)の散布イベントで使用された農薬に含まれる
|
||||
かつ PesticideIngredient.is_active=True
|
||||
かつ SprayEventPesticide.pesticide.is_non_target=False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API エンドポイント
|
||||
|
||||
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
|
||||
|
||||
### 農薬マスタ
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/pesticide/pesticides/` | 一覧取得 |
|
||||
| POST | `/api/pesticide/pesticides/` | 新規作成 |
|
||||
| GET | `/api/pesticide/pesticides/{id}/` | 詳細取得 |
|
||||
| PUT/PATCH | `/api/pesticide/pesticides/{id}/` | 更新 |
|
||||
| DELETE | `/api/pesticide/pesticides/{id}/` | 削除(使用中は 400) |
|
||||
| POST | `/api/pesticide/pesticides/fetch/` | 農水省サイトから情報取得 |
|
||||
|
||||
農薬マスタ レスポンス例:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "住化スミチオン乳剤",
|
||||
"pesticide_type": "MEP乳剤",
|
||||
"registration_number": "4962",
|
||||
"system_id": "4962",
|
||||
"purpose": "殺虫剤",
|
||||
"formulation": "乳剤",
|
||||
"toxicity": "普",
|
||||
"is_spreader": false,
|
||||
"is_non_target": false,
|
||||
"notes": "",
|
||||
"fetched_at": "2026-04-09T10:00:00Z",
|
||||
"ingredients": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "MEP",
|
||||
"concentration": "50.0%",
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"product_limits": [
|
||||
{
|
||||
"id": 1,
|
||||
"crop_name": "稲",
|
||||
"max_uses": 2,
|
||||
"use_timing_note": "収穫21日前まで"
|
||||
}
|
||||
],
|
||||
"ingredient_limits": [
|
||||
{
|
||||
"id": 1,
|
||||
"ingredient_name": "MEP",
|
||||
"crop_name": "稲",
|
||||
"max_total_uses": 3,
|
||||
"use_timing_note": "種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内"
|
||||
}
|
||||
],
|
||||
"crop_aliases": [
|
||||
{
|
||||
"crop": 1,
|
||||
"crop_name": "水稲",
|
||||
"alias_name": "稲",
|
||||
"is_primary": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/pesticide/pesticides/fetch/`
|
||||
|
||||
農水省農薬登録情報提供システムから農薬情報を取得してマスタに保存する。
|
||||
取得に失敗した場合は `fetch_error` を返し、手動入力に切り替える。
|
||||
|
||||
リクエスト:
|
||||
```json
|
||||
{
|
||||
"name": "スミチオン"
|
||||
}
|
||||
```
|
||||
|
||||
レスポンス(成功):
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"candidates": [
|
||||
{
|
||||
"system_id": "4962",
|
||||
"name": "住化スミチオン乳剤",
|
||||
"pesticide_type": "MEP乳剤",
|
||||
"registration_number": "4962"
|
||||
},
|
||||
{
|
||||
"system_id": "4991",
|
||||
"name": "ホクコースミチオン乳剤",
|
||||
"pesticide_type": "MEP乳剤",
|
||||
"registration_number": "4991"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
候補が複数ある場合はフロントで選択させ、選択後に詳細取得リクエストを投げる:
|
||||
```json
|
||||
{ "system_id": "4962" }
|
||||
```
|
||||
|
||||
レスポンス(失敗):
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "農林水産省サイトへの接続に失敗しました。手動で入力してください。"
|
||||
}
|
||||
```
|
||||
|
||||
### 散布イベント
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/pesticide/events/?year={year}` | 年度別一覧 |
|
||||
| POST | `/api/pesticide/events/` | 新規作成 |
|
||||
| GET | `/api/pesticide/events/{id}/` | 詳細取得 |
|
||||
| PUT/PATCH | `/api/pesticide/events/{id}/` | 更新 |
|
||||
| DELETE | `/api/pesticide/events/{id}/` | 削除 |
|
||||
|
||||
散布イベント POST リクエスト例(圃場グループを対象に複数農薬散布):
|
||||
```json
|
||||
{
|
||||
"year": 2026,
|
||||
"date": "2026-05-10",
|
||||
"target_type": "group",
|
||||
"target_group": "田中エリア",
|
||||
"notes": "曇り、風弱し",
|
||||
"pesticides": [
|
||||
{
|
||||
"pesticide": 1,
|
||||
"dilution_ratio": "1000倍",
|
||||
"amount_used": "500mL"
|
||||
},
|
||||
{
|
||||
"pesticide": 3,
|
||||
"dilution_ratio": "2000倍",
|
||||
"amount_used": "200mL"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
散布イベント レスポンス例:
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"year": 2026,
|
||||
"date": "2026-05-10",
|
||||
"target_type": "group",
|
||||
"target_group": "田中エリア",
|
||||
"target_display": "田中エリア(グループ)",
|
||||
"resolved_fields": [
|
||||
{
|
||||
"field": 5,
|
||||
"field_name_snapshot": "田中上",
|
||||
"group_name_snapshot": "田中エリア",
|
||||
"crop_name_snapshot": "水稲",
|
||||
"variety_name_snapshot": "コシヒカリ"
|
||||
}
|
||||
],
|
||||
"notes": "曇り、風弱し",
|
||||
"pesticides": [
|
||||
{
|
||||
"id": 15,
|
||||
"pesticide": 1,
|
||||
"pesticide_name": "住化スミチオン乳剤",
|
||||
"dilution_ratio": "1000倍",
|
||||
"amount_used": "500mL"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 使用回数チェック
|
||||
|
||||
#### `GET /api/pesticide/usage-summary/?year={year}&crop_id={crop_id}`
|
||||
|
||||
年度×作物単位で使用回数の集計・チェック結果を返す。
|
||||
|
||||
レスポンス例:
|
||||
```json
|
||||
{
|
||||
"year": 2026,
|
||||
"crop_id": 1,
|
||||
"crop_name": "水稲",
|
||||
"crop_aliases": ["稲", "水稲"],
|
||||
"product_usage": [
|
||||
{
|
||||
"pesticide_id": 1,
|
||||
"pesticide_name": "住化スミチオン乳剤",
|
||||
"used_count": 2,
|
||||
"max_uses": 2,
|
||||
"remaining": 0,
|
||||
"is_over": false
|
||||
}
|
||||
],
|
||||
"ingredient_usage": [
|
||||
{
|
||||
"ingredient_name": "MEP",
|
||||
"used_count": 2,
|
||||
"max_total_uses": 3,
|
||||
"remaining": 1,
|
||||
"is_over": false,
|
||||
"products_used": ["住化スミチオン乳剤"]
|
||||
}
|
||||
],
|
||||
"component_count": 2,
|
||||
"has_violation": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 農水省サイトスクレイピング仕様
|
||||
|
||||
### 対象サイト
|
||||
|
||||
農林水産省 農薬登録情報提供システム
|
||||
URL: `https://pesticide.maff.go.jp/`
|
||||
|
||||
### アクセスフロー
|
||||
|
||||
```
|
||||
1. GET /agricultural-chemicals/name-search/
|
||||
→ JSESSIONID クッキー + CSRF トークン(フォーム埋め込み)取得
|
||||
|
||||
2. POST /agricultural-chemicals/name-search
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: _csrf=<token>&agriculturalChemicalsName=<農薬名>&agriculturalChemicalsType=
|
||||
→ 302 リダイレクト先: /agricultural-chemicals/list
|
||||
|
||||
3. GET /agricultural-chemicals/list
|
||||
→ 検索結果一覧 HTML
|
||||
→ <a href="/agricultural-chemicals/details/{system_id}"> からリンク抽出
|
||||
|
||||
4. GET /agricultural-chemicals/details/{system_id}
|
||||
→ 詳細ページ HTML → 下記データをパース
|
||||
```
|
||||
|
||||
### 詳細ページ パース項目
|
||||
|
||||
**基本情報テーブル(`th[scope=col]` + `td` ペア):**
|
||||
|
||||
| th テキスト | 取得項目 | 保存先 |
|
||||
|---|---|---|
|
||||
| 登録番号 | 登録番号 | `registration_number` |
|
||||
| 農薬の種類 | 種類名 | `pesticide_type` |
|
||||
| 農薬の名称 | 農薬名 | `name` |
|
||||
| 用途 | 用途 | `purpose` |
|
||||
| 剤型 | 剤型 | `formulation` |
|
||||
| 製剤毒性 | 毒性区分 | `toxicity` |
|
||||
|
||||
**有効成分テーブル:**
|
||||
|
||||
- 「有効成分」行: `is_active=True`、成分名・含有濃度を取得
|
||||
- 「その他成分」行: `is_active=False`
|
||||
|
||||
**適用表(作物×病害虫ごとの行):**
|
||||
|
||||
各行のカラム(`data-label` 属性でカラム識別):
|
||||
|
||||
| data-label | 取得項目 | 保存先 |
|
||||
|---|---|---|
|
||||
| 作物名 | 作物名 | `PesticideProductLimit.crop_name` |
|
||||
| 本剤の使用回数 | 「N回以内」から N を抽出 | `PesticideProductLimit.max_uses` |
|
||||
| 使用時期 | テキストそのまま | `PesticideProductLimit.use_timing_note` |
|
||||
| `{成分名}を含む農薬の総使用回数` | 「N回以内(...)」から N と補足を抽出 | `PesticideIngredientLimit.max_total_uses` / `use_timing_note` |
|
||||
|
||||
**「総使用回数」テキストのパース規則:**
|
||||
|
||||
```
|
||||
入力例: "3回以内(種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内)"
|
||||
→ max_total_uses = 3
|
||||
→ use_timing_note = "種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内"
|
||||
|
||||
正規表現: r'(\d+)回以内(?:\((.+)\))?'
|
||||
```
|
||||
|
||||
**整合性チェック:**
|
||||
|
||||
- 同一 `ingredient_name + crop_name` に対して既存の `PesticideIngredientLimit.max_total_uses` と異なる値が取得された場合、その農薬の自動取込はエラーとし、手動確認を促す
|
||||
- `use_timing_note` の差異は許容し、より詳細なテキストで上書きしてよい
|
||||
|
||||
### 実装場所
|
||||
|
||||
`apps/pesticide/management/commands/fetch_pesticide.py`
|
||||
Django management command として実装。APIエンドポイントから呼び出す。
|
||||
|
||||
### 注意事項
|
||||
|
||||
- セッション(`requests.Session`)を使用し、クッキーとCSRFを維持する
|
||||
- アクセスは農薬マスタ登録時の1件ずつに限定(バルク取得は行わない)
|
||||
- 農水省サイトの内部ID(`system_id`)と農薬の公式登録番号は別物
|
||||
- タイムアウト: 10秒
|
||||
- 適用表の作物名は `PesticideCropAlias` で内部 `Crop` と対応付ける前提で保存する
|
||||
|
||||
---
|
||||
|
||||
## 画面仕様
|
||||
|
||||
### 農薬マスタ画面(`/pesticide/`)
|
||||
|
||||
- 登録済み農薬の一覧表示
|
||||
- 農薬名で検索 → 農水省サイトから候補を取得 → 選択して詳細取得 → 保存
|
||||
- 取得失敗時は手動入力フォームに切り替え
|
||||
- 展着剤フラグ・節減対象外フラグの編集
|
||||
|
||||
### 散布記録入力画面(`/pesticide/events/new`)
|
||||
|
||||
- 散布日・年度入力
|
||||
- 対象種別(圃場/グループ/作物/品種)選択 → 対象を選択
|
||||
- 農薬を追加(複数可): 農薬マスタから選択 + 希釈倍率 + 使用量
|
||||
- 保存時に使用回数チェックを実行し、超過がある場合は警告を表示(保存はブロックしない)
|
||||
|
||||
### 使用回数チェック画面(`/pesticide/usage`)
|
||||
|
||||
- 年度・作物でフィルタ
|
||||
- **製品使用回数テーブル**: 農薬名 / 使用回数 / 上限 / 残回数(超過時は赤表示)
|
||||
- **有効成分総使用回数テーブル**: 成分名 / 使用回数 / 上限 / 残回数 / 使用製品一覧(超過時は赤表示)
|
||||
- **特別栽培欄**: 節減対象農薬の使用成分数(報告用)
|
||||
|
||||
---
|
||||
|
||||
## 設計判断と制約
|
||||
|
||||
1. **散布対象の特定**: `target_type` + 対象FK/文字列で柔軟に対応。保存時に `SprayEventResolvedField` で対象圃場と作物を確定保存する。作付け計画(Plan)はあくまで保存時の解決に使うだけで、集計の正源ではない。
|
||||
2. **使用回数上限は作物別に保持**: 同一農薬でも作物ごとに上限が異なるため `PesticideProductLimit` と `PesticideIngredientLimit` を作物別に複数行保持する。
|
||||
3. **作物名の照合は別名テーブルで吸収**: 農水省表記の「稲」と内部の「水稲」のような差異を吸収するため、`PesticideCropAlias` を必須とする。
|
||||
4. **散布対象は保存時に確定保存する**: 後日のグループ名変更や作付け変更で過去実績の集計結果が変わらないよう、`SprayEventResolvedField` に圃場・作物をスナップショット保存する。`SprayEvent` 自体には作物情報を持たない。
|
||||
5. **有効成分総使用回数も「1イベント=1回」**: 同一成分を含む複数製品を同一イベントで施用することは実務上なく、仮に混合散布しても農薬取締法上「1回の散布=1回の使用」。製品使用回数と同様に `COUNT(DISTINCT SprayEvent.id)` で集計する。`SprayEventResolvedField` との結合で行が増えても `DISTINCT` で正確にカウントできる。
|
||||
6. **総使用回数はテキストパース**: 農水省サイトの「○○を含む農薬の総使用回数」カラムから正規表現で数値を抽出する。
|
||||
7. **有効成分上限の整合性は保存時に保証する**: 同一 `ingredient_name + crop_name` の `max_total_uses` は製品をまたいで一致している前提とし、異なる値を保存しようとした場合はエラーにする。
|
||||
8. **保存はブロックしない**: 使用回数超過は警告表示のみ。農薬散布の記録は法的義務があるため、超過でも保存できるようにする。
|
||||
9. **`SprayEventPesticide.pesticide` は PROTECT**: 散布記録に使用中の農薬は削除不可。
|
||||
10. **成分集計は `is_active=True` のみ対象**: 「その他成分」は総使用回数・特別栽培の成分数集計に含めない。
|
||||
11. **`is_spreader=True` は `is_non_target` 扱い**: 展着剤はカウント除外のため、展着剤フラグをセットすれば節減対象外フラグも自動的に True 扱い(DB保存は別フィールド)。
|
||||
|
||||
---
|
||||
|
||||
## ソースファイル索引(実装後に更新)
|
||||
|
||||
| ファイル | 説明 |
|
||||
|---|---|
|
||||
| `backend/apps/pesticide/models.py` | Pesticide, PesticideIngredient, PesticideIngredientLimit, PesticideProductLimit, PesticideCropAlias, SprayEvent, SprayEventResolvedField, SprayEventPesticide |
|
||||
| `backend/apps/pesticide/serializers.py` | DRF シリアライザ |
|
||||
| `backend/apps/pesticide/views.py` | ViewSet |
|
||||
| `backend/apps/pesticide/urls.py` | URL ルーティング |
|
||||
| `backend/apps/pesticide/management/commands/fetch_pesticide.py` | 農水省スクレイパー |
|
||||
| `frontend/src/app/pesticide/page.tsx` | 農薬マスタ一覧・散布記録 |
|
||||
| `frontend/src/app/pesticide/usage/page.tsx` | 使用回数チェック画面 |
|
||||
| `frontend/src/lib/types.ts` | 型定義(Pesticide, SprayEvent 等) |
|
||||
94
document/20_ローカルテスト環境.md
Normal file
94
document/20_ローカルテスト環境.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# ローカルテスト環境(Ubuntu PC)
|
||||
|
||||
本番同等の環境をローカルで起動し、サーバーのデータで動作確認するための手順。
|
||||
|
||||
---
|
||||
|
||||
## 構成
|
||||
|
||||
| ファイル | 用途 |
|
||||
|---------|------|
|
||||
| `docker-compose.local.yml` | 本番用Dockerfileを使用、Traefikなし、ポート直接公開 |
|
||||
| `deploy_local.sh` | ローカル環境のビルド・起動 |
|
||||
| `sync_db.sh` | サーバーのDBダンプをローカルに取り込む |
|
||||
| `.env` | 本番と同じ環境変数(git管理外) |
|
||||
|
||||
アクセス先:
|
||||
- フロントエンド: http://localhost:3000
|
||||
- バックエンドAPI: http://localhost:8000/api/
|
||||
|
||||
---
|
||||
|
||||
## 初回セットアップ
|
||||
|
||||
### 1. .env を作成
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env
|
||||
# .env に本番と同じ値を設定する
|
||||
```
|
||||
|
||||
### 2. ローカル環境を起動
|
||||
|
||||
```bash
|
||||
bash deploy_local.sh
|
||||
```
|
||||
|
||||
ビルド(初回は10〜15分)→ 起動 → マイグレーションが自動実行される。
|
||||
|
||||
### 3. サーバーのDBを同期
|
||||
|
||||
**サーバー側で実行**(keinasystemユーザーで):
|
||||
```bash
|
||||
docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
|
||||
```
|
||||
|
||||
**ローカル側で実行**:
|
||||
```bash
|
||||
bash sync_db.sh
|
||||
```
|
||||
|
||||
> `sync_db.sh` はリストア後に自動でマイグレーションを実行する。サーバーより新しいマイグレーションがローカルにある場合でも正しく動作する。
|
||||
|
||||
---
|
||||
|
||||
## 2回目以降の起動
|
||||
|
||||
```bash
|
||||
# 停止中の場合は起動
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# 停止
|
||||
docker compose -f docker-compose.local.yml down
|
||||
```
|
||||
|
||||
コードを変更した場合は再ビルドが必要:
|
||||
```bash
|
||||
bash deploy_local.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DBの再同期
|
||||
|
||||
サーバーのデータをローカルに反映したい時。
|
||||
|
||||
**サーバー側**(keinasystemユーザーで):
|
||||
```bash
|
||||
docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
|
||||
```
|
||||
|
||||
**ローカル側**:
|
||||
```bash
|
||||
bash sync_db.sh
|
||||
```
|
||||
|
||||
> **注意**: ローカルのDBデータは上書きされる。ローカルで加えた変更は失われる。
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- `.env` は gitignore 対象(コミットしない)
|
||||
- ローカルDBは `postgres_data_local` ボリュームに保存(本番の `postgres_data` とは別)
|
||||
- `sync_db.sh` は SSH設定 `keinafarm`(`~/.ssh/config`)を使用
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Field, Crop, Plan } from '@/types';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
|
||||
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search, History } from 'lucide-react';
|
||||
|
||||
interface SummaryItem {
|
||||
cropId: number;
|
||||
@@ -48,6 +48,13 @@ export default function AllocationPage() {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = window.setTimeout(() => setToast(null), 4000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('allocationYear', String(year));
|
||||
@@ -233,17 +240,46 @@ export default function AllocationPage() {
|
||||
const existingPlan = getPlanForField(fieldId);
|
||||
|
||||
if (!existingPlan || !existingPlan.crop) return;
|
||||
if ((existingPlan.variety || null) === variety) return;
|
||||
|
||||
const nextVarietyName =
|
||||
variety === null
|
||||
? '(品種未選択)'
|
||||
: getVarietiesForCrop(existingPlan.crop).find((item) => item.id === variety)?.name || '不明';
|
||||
const currentVarietyName = existingPlan.variety_name || '(品種未選択)';
|
||||
|
||||
const shouldProceed = confirm(
|
||||
[
|
||||
`品種を「${currentVarietyName}」から「${nextVarietyName}」へ変更します。`,
|
||||
'施肥計画・田植え計画の関連エントリが自動で移動する場合があります。',
|
||||
'実行しますか?',
|
||||
].join('\n')
|
||||
);
|
||||
if (!shouldProceed) return;
|
||||
|
||||
setSaving(fieldId);
|
||||
|
||||
try {
|
||||
await api.patch(`/plans/${existingPlan.id}/`, {
|
||||
const res = await api.patch(`/plans/${existingPlan.id}/`, {
|
||||
variety,
|
||||
notes: existingPlan.notes,
|
||||
});
|
||||
const updatedPlan: Plan = res.data;
|
||||
const movedCount = updatedPlan.latest_variety_change?.fertilizer_moved_entry_count ?? 0;
|
||||
setToast({
|
||||
type: 'success',
|
||||
message:
|
||||
movedCount > 0
|
||||
? `品種を変更し、施肥計画 ${movedCount} 件を移動しました。`
|
||||
: '品種を変更しました。関連する施肥計画の移動はありませんでした。',
|
||||
});
|
||||
await fetchData(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to save variety:', error);
|
||||
setToast({
|
||||
type: 'error',
|
||||
message: '品種変更に失敗しました。',
|
||||
});
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
@@ -367,6 +403,20 @@ export default function AllocationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => {
|
||||
try {
|
||||
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
|
||||
if (!variety) return;
|
||||
await api.patch(`/plans/varieties/${varietyId}/`, {
|
||||
default_seedling_boxes_per_tan: defaultBoxes,
|
||||
});
|
||||
await fetchData(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to update variety default boxes:', error);
|
||||
alert('品種デフォルトの更新に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFieldSelection = (fieldId: number) => {
|
||||
setSelectedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -549,6 +599,17 @@ export default function AllocationPage() {
|
||||
{/* メインコンテンツ */}
|
||||
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{toast && (
|
||||
<div
|
||||
className={`mb-4 rounded-md border px-4 py-3 text-sm ${
|
||||
toast.type === 'success'
|
||||
? 'border-green-300 bg-green-50 text-green-800'
|
||||
: 'border-red-300 bg-red-50 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
作付け計画 <span className="text-green-700">{year}年度</span>
|
||||
@@ -873,27 +934,43 @@ export default function AllocationPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedVarietyId || ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__add__') {
|
||||
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
||||
setNewVarietyName('');
|
||||
} else {
|
||||
handleVarietyChange(field.id, e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={saving === field.id || !selectedCropId}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">選択してください</option>
|
||||
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
||||
<option key={variety.id} value={variety.id}>
|
||||
{variety.name}
|
||||
</option>
|
||||
))}
|
||||
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedVarietyId || ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__add__') {
|
||||
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
||||
setNewVarietyName('');
|
||||
} else {
|
||||
handleVarietyChange(field.id, e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={saving === field.id || !selectedCropId}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">選択してください</option>
|
||||
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
||||
<option key={variety.id} value={variety.id}>
|
||||
{variety.name}
|
||||
</option>
|
||||
))}
|
||||
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
||||
</select>
|
||||
{plan?.latest_variety_change && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
|
||||
title={[
|
||||
`変更日時: ${new Date(plan.latest_variety_change.changed_at).toLocaleString('ja-JP')}`,
|
||||
`変更前: ${plan.latest_variety_change.old_variety_name || '未設定'}`,
|
||||
`変更後: ${plan.latest_variety_change.new_variety_name || '未設定'}`,
|
||||
`施肥移動件数: ${plan.latest_variety_change.fertilizer_moved_entry_count}`,
|
||||
].join('\n')}
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
変更履歴あり
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -1032,15 +1109,22 @@ export default function AllocationPage() {
|
||||
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{getVarietiesForCrop(managerCropId).map((v) => (
|
||||
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
|
||||
<span className="text-sm text-gray-900">{v.name}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteVariety(v.id, v.name)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="削除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<li key={v.id} className="rounded border border-gray-200 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{v.name}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteVariety(v.id, v.name)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="削除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<VarietyDefaultBoxesForm
|
||||
varietyId={v.id}
|
||||
initialValue={v.default_seedling_boxes_per_tan}
|
||||
onSave={handleUpdateVarietyDefaultBoxes}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1105,3 +1189,47 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VarietyDefaultBoxesForm({
|
||||
varietyId,
|
||||
initialValue,
|
||||
onSave,
|
||||
}: {
|
||||
varietyId: number;
|
||||
initialValue: string;
|
||||
onSave: (varietyId: number, defaultBoxes: string) => Promise<void>;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await onSave(varietyId, value);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-gray-600">反当苗箱枚数デフォルト</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft } from 'lucide-react';
|
||||
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft, Sprout } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { DeliveryPlan, DeliveryAllEntry } from '@/types';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -239,7 +239,7 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
// 未割り当て圃場(グループに入っていない + 選択肥料のエントリがある)
|
||||
const unassignedFields = useMemo(() => {
|
||||
const result: FieldInfo[] = [];
|
||||
for (const fId of relevantFieldIds) {
|
||||
for (const fId of Array.from(relevantFieldIds)) {
|
||||
if (!allGroupFieldIds.has(fId)) {
|
||||
result.push(getFieldInfo(fId));
|
||||
}
|
||||
@@ -433,6 +433,32 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
]);
|
||||
};
|
||||
|
||||
// グループの全圃場をtripに一括割り当て
|
||||
const assignGroupToTrip = (groupTempId: string, tripTempId: string) => {
|
||||
const group = groups.find(g => g.tempId === groupTempId);
|
||||
if (!group) return;
|
||||
setTrips(prev => prev.map(t => {
|
||||
if (t.tempId !== tripTempId) return t;
|
||||
const newItems = [...t.items];
|
||||
for (const fieldId of group.fieldIds) {
|
||||
for (const fert of selectedFertilizers) {
|
||||
const remaining = getUnassignedBags(fieldId, fert.id);
|
||||
if (remaining > 0) {
|
||||
const existing = newItems.find(
|
||||
item => item.fieldId === fieldId && item.fertilizerId === fert.id
|
||||
);
|
||||
if (existing) {
|
||||
existing.bags += remaining;
|
||||
} else {
|
||||
newItems.push({ fieldId, fertilizerId: fert.id, bags: remaining });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...t, items: newItems };
|
||||
}));
|
||||
};
|
||||
|
||||
// tripの中の圃場IDを取得(表示用・重複なし)
|
||||
const getTripFieldIds = useCallback((trip: LocalTrip): number[] => {
|
||||
const seen = new Set<number>();
|
||||
@@ -521,6 +547,47 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
removeFieldFromTrip(fieldId, tripTempId);
|
||||
};
|
||||
|
||||
// グループの全圃場を別のtripに移動
|
||||
const moveGroupToTrip = (groupFieldIds: number[], fromTripTempId: string, toTripTempId: string) => {
|
||||
setTrips(prev => {
|
||||
const fromTrip = prev.find(t => t.tempId === fromTripTempId);
|
||||
if (!fromTrip) return prev;
|
||||
const fieldIdSet = new Set(groupFieldIds);
|
||||
const movingItems = fromTrip.items.filter(item => fieldIdSet.has(item.fieldId));
|
||||
if (movingItems.length === 0) return prev;
|
||||
|
||||
return prev.map(t => {
|
||||
if (t.tempId === fromTripTempId) {
|
||||
return { ...t, items: t.items.filter(item => !fieldIdSet.has(item.fieldId)) };
|
||||
}
|
||||
if (t.tempId === toTripTempId) {
|
||||
const newItems = [...t.items];
|
||||
for (const moving of movingItems) {
|
||||
const existing = newItems.find(
|
||||
item => item.fieldId === moving.fieldId && item.fertilizerId === moving.fertilizerId
|
||||
);
|
||||
if (existing) {
|
||||
existing.bags += moving.bags;
|
||||
} else {
|
||||
newItems.push({ ...moving });
|
||||
}
|
||||
}
|
||||
return { ...t, items: newItems };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// グループの全圃場をtripから未割り当てに戻す
|
||||
const returnGroupToUnassigned = (groupFieldIds: number[], tripTempId: string) => {
|
||||
const fieldIdSet = new Set(groupFieldIds);
|
||||
setTrips(prev => prev.map(t => {
|
||||
if (t.tempId !== tripTempId) return t;
|
||||
return { ...t, items: t.items.filter(item => !fieldIdSet.has(item.fieldId)) };
|
||||
}));
|
||||
};
|
||||
|
||||
// tripのグループ小計
|
||||
const getTripGroupFertTotal = useCallback((trip: LocalTrip, fieldIds: number[], fertilizerId: number): number => {
|
||||
return trip.items
|
||||
@@ -609,9 +676,20 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
運搬計画一覧
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
|
||||
</h1>
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
{isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
|
||||
</h1>
|
||||
{isEdit && planId && (
|
||||
<button
|
||||
onClick={() => router.push(`/fertilizer/spreading?year=${year}&delivery_plan=${planId}`)}
|
||||
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
<Sprout className="h-4 w-4" />
|
||||
この運搬計画から散布実績へ進む
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
|
||||
@@ -932,11 +1010,34 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
{/* グループ小計行 */}
|
||||
<div className="flex items-center gap-2 bg-green-50 px-2 py-1 rounded text-sm font-medium text-green-800">
|
||||
<span>★ {groupName}</span>
|
||||
<span className="text-xs text-green-600 ml-auto">
|
||||
<span className="text-xs text-green-600 flex-1 text-right">
|
||||
{groupFertTotals.map((f, i) => (
|
||||
<span key={f.id}>{i > 0 ? ' ' : ''}{f.name}: {f.total.toFixed(2)}</span>
|
||||
))}
|
||||
</span>
|
||||
{fieldIds.length > 1 && (
|
||||
<select
|
||||
value=""
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
if (val === '__unassigned__') {
|
||||
returnGroupToUnassigned(fieldIds, trip.tempId);
|
||||
} else if (val) {
|
||||
moveGroupToTrip(fieldIds, trip.tempId, val);
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-1.5 py-0.5 text-xs font-normal text-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-shrink-0"
|
||||
>
|
||||
<option value="">移動...</option>
|
||||
{trips.filter(t => t.tempId !== trip.tempId).map(t => {
|
||||
const displayIdx = trips.indexOf(t) + 1;
|
||||
return (
|
||||
<option key={t.tempId} value={t.tempId}>→ {displayIdx}回目{t.name ? ` (${t.name})` : ''}</option>
|
||||
);
|
||||
})}
|
||||
<option value="__unassigned__">← 戻す</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{/* 圃場行 */}
|
||||
{fieldIds.map(fId => {
|
||||
@@ -989,9 +1090,31 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* このtripに圃場を追加 */}
|
||||
{/* このtripに追加 */}
|
||||
{hasUnassignedBags && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 flex gap-2 flex-wrap">
|
||||
{/* グループ一括追加 */}
|
||||
<select
|
||||
value=""
|
||||
onChange={e => {
|
||||
if (e.target.value) {
|
||||
assignGroupToTrip(e.target.value, trip.tempId);
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
>
|
||||
<option value="">+ グループを追加...</option>
|
||||
{groups.map(g => {
|
||||
const hasRemaining = g.fieldIds.some(fId =>
|
||||
selectedFertilizers.some(f => getUnassignedBags(fId, f.id) > 0)
|
||||
);
|
||||
if (!hasRemaining) return null;
|
||||
return (
|
||||
<option key={g.tempId} value={g.tempId}>★ {g.name}({g.fieldIds.length}圃場)</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* 圃場単体追加 */}
|
||||
<select
|
||||
value=""
|
||||
onChange={e => {
|
||||
@@ -1002,7 +1125,6 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
>
|
||||
<option value="">+ 圃場を追加...</option>
|
||||
{/* グループごとにまとめて表示 */}
|
||||
{groups.map(g => {
|
||||
const fieldsWithRemaining = g.fieldIds.filter(fId =>
|
||||
selectedFertilizers.some(f => getUnassignedBags(fId, f.id) > 0)
|
||||
@@ -1016,7 +1138,6 @@ export default function DeliveryEditPage({ planId }: Props) {
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
{/* 未グループ */}
|
||||
{unassignedFields.filter(fi =>
|
||||
selectedFertilizers.some(f => getUnassignedBags(fi.id, f.id) > 0)
|
||||
).length > 0 && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Truck, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { Truck, Plus, FileDown, Pencil, Trash2, X, Sprout } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { DeliveryPlanListItem } from '@/types';
|
||||
@@ -75,13 +75,22 @@ export default function DeliveryListPage() {
|
||||
<Truck className="h-7 w-7 text-green-700" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">運搬計画</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/distribution/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer/spreading')}
|
||||
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors"
|
||||
>
|
||||
<Sprout className="h-4 w-4" />
|
||||
散布実績
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/distribution/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 年度セレクタ */}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
import { FertilizationPlan } from '@/types';
|
||||
|
||||
interface ConfirmSpreadingModalProps {
|
||||
plan: FertilizationPlan | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmed: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
type ActualMap = Record<string, string>;
|
||||
|
||||
const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`;
|
||||
type EntryMatrix = Record<number, Record<number, string>>;
|
||||
|
||||
export default function ConfirmSpreadingModal({
|
||||
plan,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: ConfirmSpreadingModalProps) {
|
||||
const [actuals, setActuals] = useState<ActualMap>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextActuals: ActualMap = {};
|
||||
plan.entries.forEach((entry) => {
|
||||
nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags);
|
||||
});
|
||||
setActuals(nextActuals);
|
||||
setError(null);
|
||||
}, [isOpen, plan]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!plan) {
|
||||
return {
|
||||
fields: [] as { id: number; name: string; areaTan: string | undefined }[],
|
||||
fertilizers: [] as { id: number; name: string }[],
|
||||
planned: {} as EntryMatrix,
|
||||
};
|
||||
}
|
||||
|
||||
const fieldMap = new Map<number, { id: number; name: string; areaTan: string | undefined }>();
|
||||
const fertilizerMap = new Map<number, { id: number; name: string }>();
|
||||
const planned: EntryMatrix = {};
|
||||
|
||||
plan.entries.forEach((entry) => {
|
||||
if (!fieldMap.has(entry.field)) {
|
||||
fieldMap.set(entry.field, {
|
||||
id: entry.field,
|
||||
name: entry.field_name ?? `圃場ID:${entry.field}`,
|
||||
areaTan: entry.field_area_tan,
|
||||
});
|
||||
}
|
||||
if (!fertilizerMap.has(entry.fertilizer)) {
|
||||
fertilizerMap.set(entry.fertilizer, {
|
||||
id: entry.fertilizer,
|
||||
name: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
|
||||
});
|
||||
}
|
||||
if (!planned[entry.field]) {
|
||||
planned[entry.field] = {};
|
||||
}
|
||||
planned[entry.field][entry.fertilizer] = String(entry.bags);
|
||||
});
|
||||
|
||||
return {
|
||||
fields: Array.from(fieldMap.values()),
|
||||
fertilizers: Array.from(fertilizerMap.values()),
|
||||
planned,
|
||||
};
|
||||
}, [plan]);
|
||||
|
||||
if (!isOpen || !plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, {
|
||||
entries: plan.entries.map((entry) => ({
|
||||
field_id: entry.field,
|
||||
fertilizer_id: entry.fertilizer,
|
||||
actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0),
|
||||
})),
|
||||
});
|
||||
await onConfirmed();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const detail =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'response' in e &&
|
||||
typeof e.response === 'object' &&
|
||||
e.response !== null &&
|
||||
'data' in e.response
|
||||
? JSON.stringify(e.response.data)
|
||||
: '散布確定に失敗しました。';
|
||||
setError(detail);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const numericValue = (value: string | undefined) => {
|
||||
const parsed = parseFloat(value ?? '0');
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
const actualTotalByField = (fieldId: number) =>
|
||||
layout.fertilizers.reduce(
|
||||
(sum, fertilizer) => sum + numericValue(actuals[entryKey(fieldId, fertilizer.id)]),
|
||||
0
|
||||
);
|
||||
|
||||
const actualTotalByFertilizer = (fertilizerId: number) =>
|
||||
layout.fields.reduce(
|
||||
(sum, field) => sum + numericValue(actuals[entryKey(field.id, fertilizerId)]),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
|
||||
<div className="max-h-[92vh] w-full max-w-[95vw] overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
散布確定: 「{plan.name}」
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
施肥計画編集と同じ並びで、各セルの計画値を確認しながら実績数量を入力します。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(92vh-144px)] overflow-y-auto bg-gray-50 px-6 py-5">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 rounded-lg bg-white p-4 shadow">
|
||||
<div className="grid gap-3 text-sm text-gray-700 sm:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">年度</div>
|
||||
<div className="font-medium">{plan.year}年度</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">作物 / 品種</div>
|
||||
<div className="font-medium">
|
||||
{plan.crop_name} / {plan.variety_name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">対象圃場</div>
|
||||
<div className="font-medium">{plan.field_count}筆</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">肥料数</div>
|
||||
<div className="font-medium">{plan.fertilizer_count}種</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-xs text-sky-800">
|
||||
各セルの灰色表示が計画値、入力欄が散布実績です。「0」を入力したセルは未散布として引当解除されます。
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg bg-white shadow">
|
||||
<table className="min-w-full text-sm border-collapse">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-4 py-3 text-left font-medium text-gray-700 whitespace-nowrap">
|
||||
圃場名
|
||||
</th>
|
||||
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
|
||||
面積(反)
|
||||
</th>
|
||||
{layout.fertilizers.map((fertilizer) => (
|
||||
<th
|
||||
key={fertilizer.id}
|
||||
className="border border-gray-200 px-3 py-2 text-center font-medium text-gray-700 whitespace-nowrap"
|
||||
>
|
||||
<div>{fertilizer.name}</div>
|
||||
<div className="mt-0.5 text-[11px] font-normal text-gray-400">
|
||||
計画 / 実績
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
|
||||
実績合計
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.fields.map((field) => (
|
||||
<tr key={field.id} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-200 px-4 py-2 whitespace-nowrap text-gray-800">
|
||||
{field.name}
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-600 whitespace-nowrap">
|
||||
{field.areaTan ?? '-'}
|
||||
</td>
|
||||
{layout.fertilizers.map((fertilizer) => {
|
||||
const key = entryKey(field.id, fertilizer.id);
|
||||
const planned = layout.planned[field.id]?.[fertilizer.id];
|
||||
const hasEntry = planned !== undefined;
|
||||
return (
|
||||
<td key={key} className="border border-gray-200 px-2 py-2">
|
||||
{hasEntry ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-[11px] text-gray-400">
|
||||
計画 {planned}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={actuals[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setActuals((prev) => ({
|
||||
...prev,
|
||||
[key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-300">-</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-700">
|
||||
{actualTotalByField(field.id).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-4 py-2">合計</td>
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
|
||||
{layout.fields
|
||||
.reduce((sum, field) => sum + (parseFloat(field.areaTan ?? '0') || 0), 0)
|
||||
.toFixed(2)}
|
||||
</td>
|
||||
{layout.fertilizers.map((fertilizer) => (
|
||||
<td
|
||||
key={fertilizer.id}
|
||||
className="border border-gray-200 px-3 py-2 text-right text-gray-700"
|
||||
>
|
||||
{actualTotalByFertilizer(fertilizer.id).toFixed(2)}
|
||||
</td>
|
||||
))}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-green-700">
|
||||
{layout.fields
|
||||
.reduce((sum, field) => sum + actualTotalByField(field.id), 0)
|
||||
.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
一括確定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
|
||||
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Sprout } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
|
||||
@@ -62,11 +62,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
// roundedColumns: 四捨五入済みの肥料列ID(↩ トグル用)
|
||||
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
||||
const [adjusted, setAdjusted] = useState<Matrix>({});
|
||||
const [actualMatrix, setActualMatrix] = useState<Matrix>({});
|
||||
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
||||
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
|
||||
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -102,9 +101,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
setName(plan.name);
|
||||
setYear(plan.year);
|
||||
setVarietyId(plan.variety);
|
||||
setIsConfirmed(plan.is_confirmed);
|
||||
setConfirmedAt(plan.confirmed_at);
|
||||
|
||||
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
||||
setPlanFertilizers(ferts);
|
||||
@@ -122,11 +118,17 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// 保存済みの値は adjusted に復元
|
||||
const newAdjusted: Matrix = {};
|
||||
const newActualMatrix: Matrix = {};
|
||||
plan.entries.forEach((e) => {
|
||||
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
|
||||
newAdjusted[e.field][e.fertilizer] = String(e.bags);
|
||||
if (e.actual_bags !== null && e.actual_bags !== undefined) {
|
||||
if (!newActualMatrix[e.field]) newActualMatrix[e.field] = {};
|
||||
newActualMatrix[e.field][e.fertilizer] = String(e.actual_bags);
|
||||
}
|
||||
});
|
||||
setAdjusted(newAdjusted);
|
||||
setActualMatrix(newActualMatrix);
|
||||
setInitialPlanTotals(
|
||||
plan.entries.reduce((acc: Record<number, number>, entry) => {
|
||||
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
|
||||
@@ -195,7 +197,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 肥料追加・削除
|
||||
const addFertilizer = (fert: Fertilizer) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||
setPlanFertilizers((prev) => [...prev, fert]);
|
||||
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
||||
@@ -203,7 +205,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
};
|
||||
|
||||
const removeFertilizer = (id: number) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||
const dropCol = (m: Matrix): Matrix => {
|
||||
@@ -222,14 +224,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 圃場追加・削除
|
||||
const addField = (field: Field) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||
setSelectedFields((prev) => [...prev, field]);
|
||||
setShowFieldPicker(false);
|
||||
};
|
||||
|
||||
const removeField = (id: number) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
||||
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||
@@ -239,7 +241,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 自動計算
|
||||
const runCalc = async (setting: CalcSetting) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
if (!setting.param) {
|
||||
setSaveError('パラメータを入力してください');
|
||||
return;
|
||||
@@ -298,7 +300,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── セル更新(adjusted を更新)
|
||||
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
setAdjusted((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!next[fieldId]) next[fieldId] = {};
|
||||
@@ -309,7 +311,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
||||
const roundColumn = (fertId: number) => {
|
||||
if (isConfirmed) return;
|
||||
|
||||
if (roundedColumns.has(fertId)) {
|
||||
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
||||
setAdjusted((prev) => {
|
||||
@@ -383,10 +385,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
// ─── 保存(adjusted 優先、なければ calc 値を使用)
|
||||
const handleSave = async () => {
|
||||
setSaveError(null);
|
||||
if (isConfirmed) {
|
||||
setSaveError('確定済みの施肥計画は編集できません。');
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
||||
if (!varietyId) { setSaveError('品種を選択してください'); return; }
|
||||
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
|
||||
@@ -427,31 +425,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 確定取消
|
||||
const handleUnconfirm = async () => {
|
||||
if (!planId) return;
|
||||
setSaveError(null);
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${planId}/unconfirm/`);
|
||||
setIsConfirmed(false);
|
||||
setConfirmedAt(null);
|
||||
// 引当が再作成されるので在庫情報を再取得
|
||||
const stockRes = await api.get('/materials/fertilizer-stock/');
|
||||
setStockByMaterialId(
|
||||
stockRes.data.reduce(
|
||||
(acc: Record<number, StockSummary>, summary: StockSummary) => {
|
||||
acc[summary.material_id] = summary;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setSaveError('確定取消に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PDF出力
|
||||
const handlePdf = async () => {
|
||||
if (!planId) return;
|
||||
@@ -498,13 +471,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isNew && isConfirmed && (
|
||||
{!isNew && planId && (
|
||||
<button
|
||||
onClick={handleUnconfirm}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
|
||||
onClick={() => router.push(`/fertilizer/spreading?year=${year}&plan=${planId}`)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-emerald-300 rounded-lg text-sm text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
確定取消
|
||||
<Sprout className="h-4 w-4" />
|
||||
この施肥計画から散布実績へ進む
|
||||
</button>
|
||||
)}
|
||||
{!isNew && (
|
||||
@@ -518,11 +491,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || isConfirmed}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,16 +509,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConfirmed && (
|
||||
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
|
||||
<span className="font-bold shrink-0">i</span>
|
||||
<span>
|
||||
この施肥計画は散布確定済みです。
|
||||
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本情報 */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-48">
|
||||
@@ -555,7 +518,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||
disabled={isConfirmed}
|
||||
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -564,7 +527,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
disabled={isConfirmed}
|
||||
|
||||
>
|
||||
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||
</select>
|
||||
@@ -575,7 +538,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={varietyId}
|
||||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||||
disabled={isConfirmed}
|
||||
|
||||
>
|
||||
<option value="">品種を選択</option>
|
||||
{crops.map((crop) => (
|
||||
@@ -601,7 +564,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowFieldPicker(true)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3 w-3" />圃場を追加
|
||||
@@ -621,7 +584,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
{f.name}({f.area_tan}反)
|
||||
<button
|
||||
onClick={() => removeField(f.id)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -641,14 +604,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
type="checkbox"
|
||||
checked={calcNewOnly}
|
||||
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
未入力圃場のみ
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowFertPicker(true)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3 w-3" />肥料を追加
|
||||
@@ -671,7 +634,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
value={setting.method}
|
||||
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
>
|
||||
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
@@ -684,19 +647,19 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
value={setting.param}
|
||||
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||
placeholder="値"
|
||||
disabled={isConfirmed}
|
||||
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||||
<button
|
||||
onClick={() => runCalc(setting)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Calculator className="h-3 w-3" />計算
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFertilizer(fert.id)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -750,7 +713,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
(袋)
|
||||
<button
|
||||
onClick={() => roundColumn(f.id)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
||||
isRounded
|
||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||
@@ -775,25 +738,33 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
{planFertilizers.map((fert) => {
|
||||
const calcVal = calcMatrix[field.id]?.[fert.id];
|
||||
const adjVal = adjusted[field.id]?.[fert.id];
|
||||
const actualVal = actualMatrix[field.id]?.[fert.id];
|
||||
// 計算結果があればラベルを表示(adjusted が上書きされた場合は参照値として)
|
||||
const showRef = calcVal !== undefined;
|
||||
// 入力欄: adjusted → calc値 → 空
|
||||
const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
|
||||
return (
|
||||
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{showRef && (
|
||||
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{showRef && (
|
||||
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
|
||||
value={inputValue}
|
||||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||
placeholder="-"
|
||||
|
||||
/>
|
||||
</div>
|
||||
{actualVal !== undefined && (
|
||||
<div className="text-right text-[11px] text-sky-700">
|
||||
実績 {actualVal}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
|
||||
value={inputValue}
|
||||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||
placeholder="-"
|
||||
disabled={isConfirmed}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
@@ -841,7 +812,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addField(f)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{f.name}</span>
|
||||
@@ -856,7 +827,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addField(f)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{f.name}</span>
|
||||
@@ -884,7 +855,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addFertilizer(f)}
|
||||
disabled={isConfirmed}
|
||||
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="font-medium">{f.name}</span>
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
|
||||
import { FileDown, GitMerge, NotebookText, Pencil, Plus, Sprout, Trash2, Truck, X } from 'lucide-react';
|
||||
|
||||
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { FertilizationPlan } from '@/types';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const STATUS_LABELS: Record<FertilizationPlan['spread_status'], string> = {
|
||||
unspread: '未散布',
|
||||
partial: '一部散布',
|
||||
completed: '散布済み',
|
||||
over_applied: '超過散布',
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<FertilizationPlan['spread_status'], string> = {
|
||||
unspread: 'bg-gray-100 text-gray-700',
|
||||
partial: 'bg-amber-100 text-amber-800',
|
||||
completed: 'bg-emerald-100 text-emerald-800',
|
||||
over_applied: 'bg-rose-100 text-rose-800',
|
||||
};
|
||||
|
||||
export default function FertilizerPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('fertilizerYear');
|
||||
if (saved) return parseInt(saved);
|
||||
if (saved) return parseInt(saved, 10);
|
||||
}
|
||||
return currentYear;
|
||||
});
|
||||
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mergeSourcePlan, setMergeSourcePlan] = useState<FertilizationPlan | null>(null);
|
||||
const [mergeTargets, setMergeTargets] = useState<
|
||||
{ id: number; name: string; field_count: number; planned_total_bags: string; is_confirmed: boolean }[]
|
||||
>([]);
|
||||
const [mergeTargetId, setMergeTargetId] = useState<number | ''>('');
|
||||
const [mergeLoading, setMergeLoading] = useState(false);
|
||||
const [mergeSubmitting, setMergeSubmitting] = useState(false);
|
||||
const [mergeError, setMergeError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('fertilizerYear', String(year));
|
||||
@@ -33,41 +52,31 @@ export default function FertilizerPage() {
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/?year=${year}`);
|
||||
setPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('施肥計画の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
setDeleteError(null);
|
||||
setActionError(null);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/fertilizer/plans/${id}/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setDeleteError(`「${name}」の削除に失敗しました`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnconfirm = async (id: number, name: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setActionError(`「${name}」の確定取消に失敗しました`);
|
||||
setError(`「${name}」の削除に失敗しました。`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (id: number, name: string) => {
|
||||
setActionError(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||
@@ -78,7 +87,69 @@ export default function FertilizerPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setActionError('PDF出力に失敗しました');
|
||||
setError('PDF出力に失敗しました。');
|
||||
}
|
||||
};
|
||||
|
||||
const openMergeDialog = async (plan: FertilizationPlan) => {
|
||||
setMergeSourcePlan(plan);
|
||||
setMergeTargets([]);
|
||||
setMergeTargetId('');
|
||||
setMergeError(null);
|
||||
setMergeLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${plan.id}/merge_targets/`);
|
||||
setMergeTargets(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setMergeError('マージ先候補の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setMergeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeMergeDialog = () => {
|
||||
if (mergeSubmitting) return;
|
||||
setMergeSourcePlan(null);
|
||||
setMergeTargets([]);
|
||||
setMergeTargetId('');
|
||||
setMergeError(null);
|
||||
setMergeLoading(false);
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!mergeSourcePlan || !mergeTargetId) {
|
||||
setMergeError('マージ先の施肥計画を選択してください。');
|
||||
return;
|
||||
}
|
||||
setMergeSubmitting(true);
|
||||
setMergeError(null);
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${mergeSourcePlan.id}/merge_into/`, {
|
||||
target_plan_id: mergeTargetId,
|
||||
});
|
||||
closeMergeDialog();
|
||||
await fetchPlans();
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
conflicts?: { field_name: string; fertilizer_name: string }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (conflicts.length > 0) {
|
||||
const details = conflicts
|
||||
.map((conflict) => `${conflict.field_name} × ${conflict.fertilizer_name}`)
|
||||
.join('、');
|
||||
setMergeError(`${err.response?.data?.error ?? '競合があるためマージできません。'} ${details}`);
|
||||
} else {
|
||||
setMergeError(err.response?.data?.error ?? 'マージに失敗しました。');
|
||||
}
|
||||
} finally {
|
||||
setMergeSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,22 +158,36 @@ export default function FertilizerPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sprout className="h-6 w-6 text-green-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-800">施肥計画</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/workrecords')}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<NotebookText className="h-4 w-4" />
|
||||
作業記録
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer/spreading')}
|
||||
className="flex items-center gap-2 rounded-lg border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
<Truck className="h-4 w-4" />
|
||||
散布実績
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer/masters')}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
肥料マスタ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
@@ -110,113 +195,76 @@ export default function FertilizerPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 年度セレクタ */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>{y}年度</option>
|
||||
<option key={y} value={y}>
|
||||
{y}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
<span className="font-bold shrink-0">⚠</span>
|
||||
<span>{deleteError}</span>
|
||||
<button onClick={() => setDeleteError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
<span className="font-bold shrink-0">⚠</span>
|
||||
<span>{actionError}</span>
|
||||
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
|
||||
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
|
||||
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p>{year}年度の施肥計画はありません</p>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer/new')}
|
||||
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
||||
className="mt-4 rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-700"
|
||||
>
|
||||
最初の計画を作成する
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<thead className="border-b bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">計画名</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">作物 / 品種</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">状態</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-700">圃場数</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-700">肥料種数</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">計画名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">作物 / 品種</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">散布状況</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">計画</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">実績</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">残</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">圃場</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{plans.map((plan) => (
|
||||
<tr
|
||||
key={plan.id}
|
||||
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{plan.name}</span>
|
||||
{plan.is_confirmed && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
確定済み
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{plan.crop_name} / {plan.variety_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{plan.is_confirmed
|
||||
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
|
||||
: '未確定'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{!plan.is_confirmed ? (
|
||||
<button
|
||||
onClick={() => setConfirmTarget(plan)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
|
||||
title="散布確定"
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
散布確定
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleUnconfirm(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
|
||||
title="確定取消"
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
確定取消
|
||||
</button>
|
||||
)}
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_CLASSES[plan.spread_status]}`}>
|
||||
{STATUS_LABELS[plan.spread_status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.planned_total_bags}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.spread_total_bags}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.remaining_total_bags}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handlePdf(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
|
||||
className="flex items-center gap-1 rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||
title="PDF出力"
|
||||
>
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
@@ -224,15 +272,25 @@ export default function FertilizerPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-blue-300 rounded hover:bg-blue-50 text-blue-700"
|
||||
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||
title="編集"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
編集
|
||||
</button>
|
||||
{plan.is_variety_change_plan && (
|
||||
<button
|
||||
onClick={() => openMergeDialog(plan)}
|
||||
className="flex items-center gap-1 rounded border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 hover:bg-emerald-50"
|
||||
title="既存計画へマージ"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
マージ
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600"
|
||||
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||
title="削除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -248,12 +306,84 @@ export default function FertilizerPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmSpreadingModal
|
||||
isOpen={confirmTarget !== null}
|
||||
plan={confirmTarget}
|
||||
onClose={() => setConfirmTarget(null)}
|
||||
onConfirmed={fetchPlans}
|
||||
/>
|
||||
{mergeSourcePlan && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-xl rounded-lg bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">既存計画へマージ</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">{mergeSourcePlan.name}</p>
|
||||
</div>
|
||||
<button onClick={closeMergeDialog} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
同年度・同品種の既存施肥計画へ統合します。競合する圃場 × 肥料がある場合はマージせず停止します。
|
||||
</p>
|
||||
|
||||
{mergeError && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{mergeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mergeLoading ? (
|
||||
<p className="text-sm text-gray-500">候補を読み込み中...</p>
|
||||
) : mergeTargets.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">マージ可能な施肥計画がありません。</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mergeTargets.map((target) => (
|
||||
<label
|
||||
key={target.id}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 ${
|
||||
target.is_confirmed ? 'border-gray-200 bg-gray-50 text-gray-400' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="merge-target"
|
||||
value={target.id}
|
||||
checked={mergeTargetId === target.id}
|
||||
onChange={() => setMergeTargetId(target.id)}
|
||||
disabled={target.is_confirmed}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-gray-800">{target.name}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{target.field_count}筆 / 計画 {target.planned_total_bags}袋
|
||||
{target.is_confirmed ? ' / 散布確定済みのため選択不可' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t px-5 py-4">
|
||||
<button
|
||||
onClick={closeMergeDialog}
|
||||
disabled={mergeSubmitting}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
disabled={mergeSubmitting || mergeLoading || !mergeTargetId}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{mergeSubmitting ? 'マージ中...' : 'マージ実行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
759
frontend/src/app/fertilizer/spreading/page.tsx
Normal file
759
frontend/src/app/fertilizer/spreading/page.tsx
Normal file
@@ -0,0 +1,759 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
DeliveryPlan,
|
||||
FertilizationPlan,
|
||||
SpreadingCandidate,
|
||||
SpreadingSession,
|
||||
} from '@/types';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_KEY = 'spreadingYear';
|
||||
|
||||
type SourceType = 'delivery' | 'plan' | 'year';
|
||||
|
||||
type FormState = {
|
||||
date: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
itemValues: Record<string, string>;
|
||||
};
|
||||
|
||||
type MatrixField = {
|
||||
id: number;
|
||||
name: string;
|
||||
area_tan: string;
|
||||
};
|
||||
|
||||
type MatrixFertilizer = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const candidateKey = (fieldId: number, fertilizerId: number) => `${fieldId}:${fertilizerId}`;
|
||||
|
||||
const toNumber = (value: string | number | null | undefined) => {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const formatDisplay = (value: string | number | null | undefined) => {
|
||||
const num = toNumber(value);
|
||||
if (Number.isInteger(num)) {
|
||||
return String(num);
|
||||
}
|
||||
return num.toFixed(4).replace(/\.?0+$/, '');
|
||||
};
|
||||
|
||||
const formatInputValue = (value: number) => {
|
||||
if (value <= 0) return '0';
|
||||
return value.toFixed(2).replace(/\.?0+$/, '');
|
||||
};
|
||||
|
||||
const getDefaultDate = (year: number) => {
|
||||
const today = new Date();
|
||||
if (today.getFullYear() !== year) {
|
||||
return `${year}-01-01`;
|
||||
}
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const getSourceType = (deliveryPlanId: number | null, fertilizationPlanId: number | null): SourceType => {
|
||||
if (deliveryPlanId) return 'delivery';
|
||||
if (fertilizationPlanId) return 'plan';
|
||||
return 'year';
|
||||
};
|
||||
|
||||
const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: SourceType) => {
|
||||
const values: Record<string, string> = {};
|
||||
rows.forEach((candidate) => {
|
||||
let base = 0;
|
||||
if (sourceType === 'delivery') {
|
||||
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
|
||||
} else if (sourceType === 'plan') {
|
||||
base = toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other);
|
||||
} else {
|
||||
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
|
||||
}
|
||||
values[candidateKey(candidate.field, candidate.fertilizer)] = formatInputValue(Math.max(base, 0));
|
||||
});
|
||||
return values;
|
||||
};
|
||||
|
||||
export default function SpreadingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-gray-50"><Navbar /><div className="max-w-7xl mx-auto px-4 py-8 text-gray-500">読み込み中...</div></div>}>
|
||||
<SpreadingPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SpreadingPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryYear = Number(searchParams.get('year') || '0') || null;
|
||||
const deliveryPlanId = Number(searchParams.get('delivery_plan') || '0') || null;
|
||||
const fertilizationPlanId = Number(searchParams.get('plan') || '0') || null;
|
||||
const sourceType = getSourceType(deliveryPlanId, fertilizationPlanId);
|
||||
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||
}
|
||||
return CURRENT_YEAR;
|
||||
});
|
||||
const [sessions, setSessions] = useState<SpreadingSession[]>([]);
|
||||
const [candidates, setCandidates] = useState<SpreadingCandidate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [openedFromQuery, setOpenedFromQuery] = useState(false);
|
||||
const [openedFromSource, setOpenedFromSource] = useState(false);
|
||||
const [sourceName, setSourceName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryYear && queryYear !== year) {
|
||||
setYear(queryYear);
|
||||
}
|
||||
}, [queryYear, year]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(YEAR_KEY, String(year));
|
||||
void fetchSessions();
|
||||
setForm(null);
|
||||
setEditingSessionId(null);
|
||||
setOpenedFromQuery(false);
|
||||
setOpenedFromSource(false);
|
||||
}, [year]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSource = async () => {
|
||||
if (deliveryPlanId) {
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/delivery/${deliveryPlanId}/`);
|
||||
const plan: DeliveryPlan = res.data;
|
||||
setSourceName(plan.name);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setSourceName(`運搬計画 #${deliveryPlanId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (fertilizationPlanId) {
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
|
||||
const plan: FertilizationPlan = res.data;
|
||||
setSourceName(plan.name);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setSourceName(`施肥計画 #${fertilizationPlanId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSourceName(null);
|
||||
};
|
||||
void loadSource();
|
||||
}, [deliveryPlanId, fertilizationPlanId]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionParam = searchParams.get('session');
|
||||
if (!sessionParam || openedFromQuery || sessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const targetId = Number(sessionParam);
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
const target = sessions.find((session) => session.id === targetId);
|
||||
if (target) {
|
||||
void openEditor(target);
|
||||
setOpenedFromQuery(true);
|
||||
}
|
||||
}, [openedFromQuery, searchParams, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionParam = searchParams.get('session');
|
||||
if (sessionParam || sourceType === 'year' || openedFromSource || form || formLoading) {
|
||||
return;
|
||||
}
|
||||
void startCreate();
|
||||
setOpenedFromSource(true);
|
||||
}, [form, formLoading, openedFromSource, searchParams, sourceType]);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/spreading/?year=${year}`);
|
||||
setSessions(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('散布実績の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCandidates = async (sessionId?: number) => {
|
||||
const params = new URLSearchParams({ year: String(year) });
|
||||
if (sessionId) {
|
||||
params.set('session_id', String(sessionId));
|
||||
}
|
||||
if (deliveryPlanId) {
|
||||
params.set('delivery_plan_id', String(deliveryPlanId));
|
||||
}
|
||||
if (fertilizationPlanId) {
|
||||
params.set('plan_id', String(fertilizationPlanId));
|
||||
}
|
||||
const res = await api.get(`/fertilizer/spreading/candidates/?${params.toString()}`);
|
||||
setCandidates(res.data);
|
||||
return res.data as SpreadingCandidate[];
|
||||
};
|
||||
|
||||
const startCreate = async () => {
|
||||
setFormLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loaded = await loadCandidates();
|
||||
setEditingSessionId(null);
|
||||
setForm({
|
||||
date: getDefaultDate(year),
|
||||
name: '',
|
||||
notes: '',
|
||||
itemValues: buildCreateInitialValues(loaded, sourceType),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('散布候補の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = async (session: SpreadingSession) => {
|
||||
setFormLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loadCandidates(session.id);
|
||||
const itemValues = session.items.reduce<Record<string, string>>((acc, item) => {
|
||||
acc[candidateKey(item.field, item.fertilizer)] = String(item.actual_bags);
|
||||
return acc;
|
||||
}, {});
|
||||
setEditingSessionId(session.id);
|
||||
setForm({
|
||||
date: session.date,
|
||||
name: session.name,
|
||||
notes: session.notes,
|
||||
itemValues,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('散布候補の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditingSessionId(null);
|
||||
setForm(null);
|
||||
setCandidates([]);
|
||||
};
|
||||
|
||||
const candidateMap = useMemo(() => {
|
||||
const map = new Map<string, SpreadingCandidate>();
|
||||
candidates.forEach((candidate) => {
|
||||
map.set(candidateKey(candidate.field, candidate.fertilizer), candidate);
|
||||
});
|
||||
return map;
|
||||
}, [candidates]);
|
||||
|
||||
const matrixFields = useMemo<MatrixField[]>(() => {
|
||||
const map = new Map<number, MatrixField>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (!map.has(candidate.field)) {
|
||||
map.set(candidate.field, {
|
||||
id: candidate.field,
|
||||
name: candidate.field_name,
|
||||
area_tan: candidate.field_area_tan,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
|
||||
}, [candidates]);
|
||||
|
||||
const matrixFertilizers = useMemo<MatrixFertilizer[]>(() => {
|
||||
const map = new Map<number, MatrixFertilizer>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (!map.has(candidate.fertilizer)) {
|
||||
map.set(candidate.fertilizer, {
|
||||
id: candidate.fertilizer,
|
||||
name: candidate.fertilizer_name,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
|
||||
}, [candidates]);
|
||||
|
||||
const handleItemChange = (fieldId: number, fertilizerId: number, value: string) => {
|
||||
if (!form) return;
|
||||
const key = candidateKey(fieldId, fertilizerId);
|
||||
setForm({
|
||||
...form,
|
||||
itemValues: {
|
||||
...form.itemValues,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getCellValue = (fieldId: number, fertilizerId: number) => {
|
||||
if (!form) return '';
|
||||
return form.itemValues[candidateKey(fieldId, fertilizerId)] ?? '0';
|
||||
};
|
||||
|
||||
const selectedRows = useMemo(() => {
|
||||
if (!form) return [];
|
||||
return candidates.filter((candidate) => {
|
||||
const value = toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
|
||||
return value > 0;
|
||||
});
|
||||
}, [candidates, form]);
|
||||
|
||||
const getRowTotal = (fieldId: number) => {
|
||||
if (!form) return 0;
|
||||
return matrixFertilizers.reduce((sum, fertilizer) => {
|
||||
const candidate = candidateMap.get(candidateKey(fieldId, fertilizer.id));
|
||||
if (!candidate) return sum;
|
||||
return sum + toNumber(getCellValue(fieldId, fertilizer.id));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getColumnTotal = (fertilizerId: number) => {
|
||||
if (!form) return 0;
|
||||
return matrixFields.reduce((sum, field) => {
|
||||
const candidate = candidateMap.get(candidateKey(field.id, fertilizerId));
|
||||
if (!candidate) return sum;
|
||||
return sum + toNumber(getCellValue(field.id, fertilizerId));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const totalInputBags = selectedRows.reduce((sum, candidate) => {
|
||||
return sum + toNumber(form?.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
|
||||
}, 0);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
setError(null);
|
||||
if (!form.name.trim()) {
|
||||
setError('名称を入力してください。');
|
||||
return;
|
||||
}
|
||||
if (!form.date) {
|
||||
setError('散布日を入力してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
const items = selectedRows.map((candidate) => ({
|
||||
field_id: candidate.field,
|
||||
fertilizer_id: candidate.fertilizer,
|
||||
actual_bags: toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'),
|
||||
planned_bags_snapshot: toNumber(candidate.planned_bags),
|
||||
delivered_bags_snapshot: toNumber(candidate.delivered_bags),
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
setError('散布実績を1件以上入力してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
year,
|
||||
date: form.date,
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
items,
|
||||
};
|
||||
if (editingSessionId) {
|
||||
await api.put(`/fertilizer/spreading/${editingSessionId}/`, payload);
|
||||
} else {
|
||||
await api.post('/fertilizer/spreading/', payload);
|
||||
}
|
||||
await fetchSessions();
|
||||
closeEditor();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('散布実績の保存に失敗しました。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (sessionId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/fertilizer/spreading/${sessionId}/`);
|
||||
await fetchSessions();
|
||||
if (editingSessionId === sessionId) {
|
||||
closeEditor();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('散布実績の削除に失敗しました。');
|
||||
}
|
||||
};
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||
|
||||
const sourceSummary =
|
||||
sourceType === 'delivery'
|
||||
? '初期値は運搬計画値から散布済を引いた値です。'
|
||||
: sourceType === 'plan'
|
||||
? '初期値は施肥計画値から散布済を引いた値です。'
|
||||
: '初期値は運搬済みから散布済を引いた値です。';
|
||||
|
||||
const sourceLabel =
|
||||
sourceType === 'delivery'
|
||||
? '運搬計画を選択した状態です'
|
||||
: sourceType === 'plan'
|
||||
? '施肥計画を選択した状態です'
|
||||
: null;
|
||||
|
||||
const clearFilterHref = `/fertilizer/spreading?year=${year}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<Sprout className="h-6 w-6 text-green-700" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">散布実績</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void startCreate()}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規記録
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{sourceLabel && (
|
||||
<div className="mb-6 flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-emerald-900">{sourceLabel}</div>
|
||||
<div className="mt-1 text-sm text-emerald-700">
|
||||
{sourceName ?? (sourceType === 'delivery' ? `運搬計画 #${deliveryPlanId}` : `施肥計画 #${fertilizationPlanId}`)}
|
||||
{' '}を起点に散布候補を絞り込んでいます。
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-700">{sourceSummary}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push(clearFilterHref)}
|
||||
className="flex items-center gap-1 rounded border border-emerald-300 px-3 py-1.5 text-xs text-emerald-700 hover:bg-emerald-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
絞り込み解除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(form || formLoading) && (
|
||||
<section className="mb-8 rounded-lg border border-emerald-200 bg-white shadow-sm">
|
||||
<div className="border-b border-emerald-100 px-5 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingSessionId ? '散布実績を編集' : '散布実績を登録'}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
施肥計画と同じ感覚で、圃場 × 肥料のマトリックスで実績を入力します。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{sourceSummary}</p>
|
||||
</div>
|
||||
|
||||
{formLoading || !form ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-500">候補を読み込み中...</div>
|
||||
) : (
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">散布日</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">名称</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例: 3/17 元肥散布"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">備考</label>
|
||||
<input
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="任意"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full border-collapse text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="w-48 border border-gray-200 px-4 py-3 text-left font-medium text-gray-700">
|
||||
圃場
|
||||
</th>
|
||||
{matrixFertilizers.map((fertilizer) => (
|
||||
<th
|
||||
key={fertilizer.id}
|
||||
className="min-w-[220px] border border-gray-200 px-3 py-3 text-center font-medium text-gray-700"
|
||||
>
|
||||
<div>{fertilizer.name}</div>
|
||||
<div className="mt-1 text-[11px] font-normal text-gray-400">
|
||||
入力計 {formatDisplay(getColumnTotal(fertilizer.id))}袋
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="w-28 border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
|
||||
行合計
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrixFields.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={matrixFertilizers.length + 2}
|
||||
className="border border-gray-200 px-4 py-8 text-center text-gray-400"
|
||||
>
|
||||
散布対象の候補がありません。
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
matrixFields.map((field) => (
|
||||
<tr key={field.id} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-200 px-4 py-3 align-top">
|
||||
<div className="font-medium text-gray-900">{field.name}</div>
|
||||
<div className="text-xs text-gray-400">{field.area_tan}反</div>
|
||||
</td>
|
||||
{matrixFertilizers.map((fertilizer) => {
|
||||
const candidate = candidateMap.get(candidateKey(field.id, fertilizer.id));
|
||||
if (!candidate) {
|
||||
return (
|
||||
<td
|
||||
key={fertilizer.id}
|
||||
className="border border-gray-200 bg-gray-50 px-3 py-3 text-center text-xs text-gray-300"
|
||||
>
|
||||
-
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={fertilizer.id} className="border border-gray-200 px-3 py-3 align-top">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="grid flex-1 grid-cols-2 gap-x-3 gap-y-1 text-[11px] leading-5 text-gray-500">
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="mr-1 text-gray-400">計画</span>
|
||||
<span>{formatDisplay(candidate.planned_bags)}</span>
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="mr-1 text-gray-400">
|
||||
{sourceType === 'plan' ? '計画残' : '未散布'}
|
||||
</span>
|
||||
<span>
|
||||
{formatDisplay(
|
||||
sourceType === 'plan'
|
||||
? Math.max(toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other), 0)
|
||||
: Math.max(toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other), 0)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="mr-1 text-gray-400">運搬</span>
|
||||
<span>{formatDisplay(candidate.delivered_bags)}</span>
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="mr-1 text-gray-400">散布済</span>
|
||||
<span>{formatDisplay(candidate.spread_bags_other)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={getCellValue(field.id, fertilizer.id)}
|
||||
onChange={(e) => handleItemChange(field.id, fertilizer.id, e.target.value)}
|
||||
className="w-20 shrink-0 rounded border border-gray-300 px-2 py-1.5 text-right text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
|
||||
{formatDisplay(getRowTotal(field.id))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
{matrixFields.length > 0 && (
|
||||
<tfoot className="bg-gray-50">
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-4 py-3 font-medium text-gray-700">合計</td>
|
||||
{matrixFertilizers.map((fertilizer) => (
|
||||
<td
|
||||
key={fertilizer.id}
|
||||
className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700"
|
||||
>
|
||||
{formatDisplay(getColumnTotal(fertilizer.id))}
|
||||
</td>
|
||||
))}
|
||||
<td className="border border-gray-200 px-3 py-3 text-right font-bold text-green-700">
|
||||
{formatDisplay(totalInputBags)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
入力中 {selectedRows.length}件 / 合計 {formatDisplay(totalInputBags)}袋
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={closeEditor}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg bg-white shadow-sm">
|
||||
<div className="border-b px-5 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">登録済み散布実績</h2>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-500">読み込み中...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-400">この年度の散布実績はまだありません。</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">散布日</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">名称</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">明細数</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">合計袋数</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">作業記録</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sessions.map((session) => {
|
||||
const totalBags = session.items.reduce((sum, item) => sum + toNumber(item.actual_bags), 0);
|
||||
return (
|
||||
<tr key={session.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-700">{session.date}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{session.name || '名称なし'}</div>
|
||||
{session.notes && <div className="text-xs text-gray-400">{session.notes}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{session.items.length}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{formatDisplay(totalBags)}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">
|
||||
{session.work_record_id ? `#${session.work_record_id}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => void openEditor(session)}
|
||||
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(session.id)}
|
||||
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
526
frontend/src/app/levee-work/page.tsx
Normal file
526
frontend/src/app/levee-work/page.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, PencilLine, Plus, Save, Trash2 } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { LeveeWorkCandidate, LeveeWorkSession } from '@/types';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_KEY = 'leveeWorkYear';
|
||||
|
||||
type FormState = {
|
||||
date: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
selectedFieldIds: Set<number>;
|
||||
};
|
||||
|
||||
type SortKey = 'field_name' | 'field_area_tan' | 'group_name' | 'variety_name';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const extractErrorMessage = (error: any) => {
|
||||
const data = error?.response?.data;
|
||||
if (!data) return '保存に失敗しました。';
|
||||
if (typeof data.detail === 'string') return data.detail;
|
||||
if (Array.isArray(data.year) && data.year[0]) return data.year[0];
|
||||
if (Array.isArray(data.items) && data.items[0]) return data.items[0];
|
||||
if (typeof data.items === 'string') return data.items;
|
||||
return '保存に失敗しました。';
|
||||
};
|
||||
|
||||
const getDefaultDate = (year: number) => {
|
||||
const today = new Date();
|
||||
if (today.getFullYear() !== year) {
|
||||
return `${year}-01-01`;
|
||||
}
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export default function LeveeWorkPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-gray-50"><Navbar /><div className="mx-auto max-w-7xl px-4 py-8 text-gray-500">読み込み中...</div></div>}>
|
||||
<LeveeWorkPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LeveeWorkPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||
}
|
||||
return CURRENT_YEAR;
|
||||
});
|
||||
const [sessions, setSessions] = useState<LeveeWorkSession[]>([]);
|
||||
const [candidates, setCandidates] = useState<LeveeWorkCandidate[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openedFromQuery, setOpenedFromQuery] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<SortKey>('field_name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(YEAR_KEY, String(year));
|
||||
void fetchSessions();
|
||||
setForm(null);
|
||||
setEditingSessionId(null);
|
||||
setOpenedFromQuery(false);
|
||||
}, [year]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionParam = Number(searchParams.get('session') || '0') || null;
|
||||
if (!sessionParam || openedFromQuery || sessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const target = sessions.find((session) => session.id === sessionParam);
|
||||
if (target) {
|
||||
void openEditor(target);
|
||||
setOpenedFromQuery(true);
|
||||
}
|
||||
}, [openedFromQuery, searchParams, sessions]);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/levee-work/sessions/?year=${year}`);
|
||||
setSessions(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('畔塗記録の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCandidates = async () => {
|
||||
const res = await api.get(`/levee-work/candidates/?year=${year}`);
|
||||
setCandidates(res.data);
|
||||
return res.data as LeveeWorkCandidate[];
|
||||
};
|
||||
|
||||
const startCreate = async () => {
|
||||
setFormLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loaded = await loadCandidates();
|
||||
setEditingSessionId(null);
|
||||
setForm({
|
||||
date: getDefaultDate(year),
|
||||
title: '水稲畔塗',
|
||||
notes: '',
|
||||
selectedFieldIds: new Set(loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id)),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('候補圃場の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = async (session: LeveeWorkSession) => {
|
||||
setFormLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loaded = await loadCandidates();
|
||||
const selectedIds = new Set(session.items.map((item) => item.field));
|
||||
const fallbackSelected = loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id);
|
||||
setEditingSessionId(session.id);
|
||||
setForm({
|
||||
date: session.date,
|
||||
title: session.title,
|
||||
notes: session.notes,
|
||||
selectedFieldIds: selectedIds.size > 0 ? selectedIds : new Set(fallbackSelected),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('編集用データの読み込みに失敗しました。');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleField = (fieldId: number) => {
|
||||
if (!form) return;
|
||||
const next = new Set(form.selectedFieldIds);
|
||||
if (next.has(fieldId)) {
|
||||
next.delete(fieldId);
|
||||
} else {
|
||||
next.add(fieldId);
|
||||
}
|
||||
setForm({ ...form, selectedFieldIds: next });
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (!form) return;
|
||||
setForm({
|
||||
...form,
|
||||
selectedFieldIds: new Set(candidates.map((candidate) => candidate.field_id)),
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, selectedFieldIds: new Set() });
|
||||
};
|
||||
|
||||
const selectedCount = form?.selectedFieldIds.size ?? 0;
|
||||
|
||||
const sortedCandidates = useMemo(() => {
|
||||
const rows = [...candidates];
|
||||
rows.sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
if (sortKey === 'field_area_tan') {
|
||||
result = Number(a.field_area_tan) - Number(b.field_area_tan);
|
||||
} else {
|
||||
result = (a[sortKey] || '').toString().localeCompare((b[sortKey] || '').toString(), 'ja');
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = a.field_name.localeCompare(b.field_name, 'ja');
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
return rows;
|
||||
}, [candidates, sortDirection, sortKey]);
|
||||
|
||||
const selectedCandidates = useMemo(() => {
|
||||
if (!form) return [];
|
||||
return sortedCandidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
|
||||
}, [form, sortedCandidates]);
|
||||
|
||||
const selectedAreaTan = useMemo(() => {
|
||||
return selectedCandidates.reduce((sum, candidate) => sum + Number(candidate.field_area_tan || '0'), 0);
|
||||
}, [selectedCandidates]);
|
||||
|
||||
const handleSort = (nextKey: SortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const renderSortIcon = (key: SortKey) => {
|
||||
if (sortKey !== key) return null;
|
||||
return sortDirection === 'asc' ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
if (selectedCount === 0) {
|
||||
setError('対象圃場を1件以上選択してください。');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
year,
|
||||
date: form.date,
|
||||
title: form.title,
|
||||
notes: form.notes,
|
||||
items: selectedCandidates.map((candidate) => ({
|
||||
field: candidate.field_id,
|
||||
plan: candidate.plan_id,
|
||||
})),
|
||||
};
|
||||
if (editingSessionId) {
|
||||
await api.put(`/levee-work/sessions/${editingSessionId}/`, payload);
|
||||
} else {
|
||||
await api.post('/levee-work/sessions/', payload);
|
||||
}
|
||||
await fetchSessions();
|
||||
await startCreate();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(extractErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editingSessionId) return;
|
||||
if (!window.confirm('この畔塗記録を削除しますか?')) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/levee-work/sessions/${editingSessionId}/`);
|
||||
await fetchSessions();
|
||||
setEditingSessionId(null);
|
||||
setForm(null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('削除に失敗しました。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.push('/workrecords')} className="text-gray-500 hover:text-gray-700">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<PencilLine className="h-6 w-6 text-amber-700" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">畔塗記録</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => void startCreate()}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<section className="overflow-hidden rounded-lg bg-white shadow-sm">
|
||||
<div className="border-b bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700">記録一覧</div>
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-sm text-gray-500">読み込み中...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-gray-400">この年度の畔塗記録はまだありません。</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => void openEditor(session)}
|
||||
className={`block w-full px-4 py-4 text-left hover:bg-amber-50 ${
|
||||
editingSessionId === session.id ? 'bg-amber-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">{session.title}</div>
|
||||
<div className="mt-1 text-sm text-gray-600">{session.date}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{session.item_count}圃場 / {Number(session.total_area_tan).toFixed(2)}反
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg bg-white shadow-sm">
|
||||
<div className="border-b bg-gray-50 px-5 py-3 text-sm font-medium text-gray-700">
|
||||
{editingSessionId ? '畔塗記録を編集' : '畔塗記録を作成'}
|
||||
</div>
|
||||
|
||||
{!form ? (
|
||||
<div className="px-5 py-10 text-sm text-gray-500">
|
||||
{formLoading ? 'フォームを準備中...' : '「新規作成」または既存記録の選択で編集を始められます。'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 px-5 py-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">日付</div>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">タイトル</div>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">備考</div>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-900">対象圃場一覧</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedCount} / {candidates.length} 圃場を選択中 / 合計 {selectedAreaTan.toFixed(2)}反
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
全選択
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
全解除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formLoading ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
|
||||
候補圃場を読み込み中...
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-400">
|
||||
この年度の水稲作付け圃場がありません。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">選択</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('field_name')}
|
||||
className="inline-flex items-center gap-1 hover:text-gray-900"
|
||||
>
|
||||
圃場
|
||||
{renderSortIcon('field_name')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('field_area_tan')}
|
||||
className="inline-flex items-center gap-1 hover:text-gray-900"
|
||||
>
|
||||
面積
|
||||
{renderSortIcon('field_area_tan')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('group_name')}
|
||||
className="inline-flex items-center gap-1 hover:text-gray-900"
|
||||
>
|
||||
グループ
|
||||
{renderSortIcon('group_name')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('variety_name')}
|
||||
className="inline-flex items-center gap-1 hover:text-gray-900"
|
||||
>
|
||||
品種
|
||||
{renderSortIcon('variety_name')}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sortedCandidates.map((candidate) => {
|
||||
const checked = form.selectedFieldIds.has(candidate.field_id);
|
||||
return (
|
||||
<tr key={candidate.field_id} className={checked ? 'bg-amber-50/40' : ''}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleToggleField(candidate.field_id)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{candidate.field_name}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{candidate.field_area_tan}反</td>
|
||||
<td className="px-4 py-3 text-gray-700">{candidate.group_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{candidate.variety_name || '(未設定)'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || formLoading}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
保存
|
||||
</button>
|
||||
{editingSessionId && (
|
||||
<button
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { Check, X } from 'lucide-react';
|
||||
|
||||
import { Material } from '@/types';
|
||||
|
||||
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
|
||||
export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc';
|
||||
|
||||
export interface MaterialFormState {
|
||||
name: string;
|
||||
material_type: Material['material_type'];
|
||||
seed_variety_id: string;
|
||||
maker: string;
|
||||
stock_unit: Material['stock_unit'];
|
||||
is_active: boolean;
|
||||
@@ -33,6 +34,7 @@ interface MaterialFormProps {
|
||||
tab: MaterialTab;
|
||||
form: MaterialFormState;
|
||||
saving: boolean;
|
||||
seedVarietyOptions?: { id: number; label: string }[];
|
||||
onBaseFieldChange: (
|
||||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||||
value: string | boolean
|
||||
@@ -56,6 +58,7 @@ export default function MaterialForm({
|
||||
tab,
|
||||
form,
|
||||
saving,
|
||||
seedVarietyOptions = [],
|
||||
onBaseFieldChange,
|
||||
onFertilizerFieldChange,
|
||||
onPesticideFieldChange,
|
||||
@@ -244,14 +247,29 @@ export default function MaterialForm({
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={form.material_type}
|
||||
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
|
||||
>
|
||||
<option value="other">その他</option>
|
||||
<option value="seedling">種苗</option>
|
||||
</select>
|
||||
{tab === 'seed' ? (
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={form.seed_variety_id}
|
||||
onChange={(e) => onBaseFieldChange('seed_variety_id', e.target.value)}
|
||||
>
|
||||
<option value="">品種未設定</option>
|
||||
{seedVarietyOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={form.material_type}
|
||||
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
|
||||
>
|
||||
<option value="other">その他</option>
|
||||
<option value="seedling">種苗</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Clock3, Download, Upload } from 'lucide-react';
|
||||
import { Clock3, Download, Pencil, Trash2, Upload } from 'lucide-react';
|
||||
|
||||
import { StockSummary, StockTransaction } from '@/types';
|
||||
|
||||
@@ -15,6 +15,8 @@ interface StockOverviewProps {
|
||||
materialId: number,
|
||||
transactionType: StockTransaction['transaction_type']
|
||||
) => void;
|
||||
onEditTransaction: (transaction: StockTransaction) => void;
|
||||
onDeleteTransaction: (transaction: StockTransaction) => void;
|
||||
onToggleHistory: (materialId: number) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +27,8 @@ export default function StockOverview({
|
||||
historyLoadingId,
|
||||
histories,
|
||||
onOpenTransaction,
|
||||
onEditTransaction,
|
||||
onDeleteTransaction,
|
||||
onToggleHistory,
|
||||
}: StockOverviewProps) {
|
||||
if (loading) {
|
||||
@@ -149,6 +153,24 @@ export default function StockOverview({
|
||||
<span className="text-gray-500">
|
||||
{transaction.note || '備考なし'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onEditTransaction(transaction)}
|
||||
disabled={transaction.is_locked}
|
||||
className="inline-flex items-center gap-1 rounded border border-blue-300 px-2 py-1 text-xs text-blue-700 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteTransaction(transaction)}
|
||||
disabled={transaction.is_locked}
|
||||
className="inline-flex items-center gap-1 rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface StockTransactionFormProps {
|
||||
materials: Material[];
|
||||
presetMaterialId?: number | null;
|
||||
presetTransactionType?: TransactionType | null;
|
||||
editingTransaction?: StockTransaction | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<void> | void;
|
||||
}
|
||||
@@ -32,6 +33,7 @@ export default function StockTransactionForm({
|
||||
materials,
|
||||
presetMaterialId = null,
|
||||
presetTransactionType = null,
|
||||
editingTransaction = null,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: StockTransactionFormProps) {
|
||||
@@ -47,13 +49,21 @@ export default function StockTransactionForm({
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
|
||||
setTransactionType(presetTransactionType ?? 'purchase');
|
||||
setQuantity('');
|
||||
setOccurredOn(today());
|
||||
setNote('');
|
||||
if (editingTransaction) {
|
||||
setMaterialId(String(editingTransaction.material));
|
||||
setTransactionType(editingTransaction.transaction_type);
|
||||
setQuantity(editingTransaction.quantity);
|
||||
setOccurredOn(editingTransaction.occurred_on);
|
||||
setNote(editingTransaction.note || '');
|
||||
} else {
|
||||
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
|
||||
setTransactionType(presetTransactionType ?? 'purchase');
|
||||
setQuantity('');
|
||||
setOccurredOn(today());
|
||||
setNote('');
|
||||
}
|
||||
setError(null);
|
||||
}, [isOpen, presetMaterialId, presetTransactionType]);
|
||||
}, [isOpen, presetMaterialId, presetTransactionType, editingTransaction]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -73,13 +83,18 @@ export default function StockTransactionForm({
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/materials/stock-transactions/', {
|
||||
const payload = {
|
||||
material: Number(materialId),
|
||||
transaction_type: transactionType,
|
||||
quantity,
|
||||
occurred_on: occurredOn,
|
||||
note,
|
||||
});
|
||||
};
|
||||
if (editingTransaction) {
|
||||
await api.put(`/materials/stock-transactions/${editingTransaction.id}/`, payload);
|
||||
} else {
|
||||
await api.post('/materials/stock-transactions/', payload);
|
||||
}
|
||||
await onSaved();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
@@ -104,7 +119,9 @@ export default function StockTransactionForm({
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">入出庫登録</h2>
|
||||
<p className="text-sm text-gray-500">在庫の増減を記録します。</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{editingTransaction ? '入出庫履歴を修正します。' : '在庫の増減を記録します。'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -10,20 +10,35 @@ import MaterialForm, {
|
||||
} from '../_components/MaterialForm';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Material } from '@/types';
|
||||
import { Crop, Material } from '@/types';
|
||||
|
||||
const tabs: { key: MaterialTab; label: string }[] = [
|
||||
{ key: 'fertilizer', label: '肥料' },
|
||||
{ key: 'pesticide', label: '農薬' },
|
||||
{ key: 'seed', label: '種子' },
|
||||
{ key: 'misc', label: 'その他' },
|
||||
];
|
||||
|
||||
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
||||
name: '',
|
||||
material_type:
|
||||
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
|
||||
tab === 'fertilizer'
|
||||
? 'fertilizer'
|
||||
: tab === 'pesticide'
|
||||
? 'pesticide'
|
||||
: tab === 'seed'
|
||||
? 'seed'
|
||||
: 'other',
|
||||
seed_variety_id: '',
|
||||
maker: '',
|
||||
stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece',
|
||||
stock_unit:
|
||||
tab === 'fertilizer'
|
||||
? 'bag'
|
||||
: tab === 'pesticide'
|
||||
? 'bottle'
|
||||
: tab === 'seed'
|
||||
? 'kg'
|
||||
: 'piece',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
fertilizer_profile: {
|
||||
@@ -42,10 +57,13 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
||||
},
|
||||
});
|
||||
|
||||
type VarietyOption = { id: number; label: string };
|
||||
|
||||
export default function MaterialMastersPage() {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<MaterialTab>('fertilizer');
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||||
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
|
||||
@@ -53,7 +71,7 @@ export default function MaterialMastersPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,11 +80,15 @@ export default function MaterialMastersPage() {
|
||||
}
|
||||
}, [tab, editingId]);
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/materials/materials/');
|
||||
setMaterials(res.data);
|
||||
const [materialsRes, cropsRes] = await Promise.all([
|
||||
api.get('/materials/materials/'),
|
||||
api.get('/plans/crops/'),
|
||||
]);
|
||||
setMaterials(materialsRes.data);
|
||||
setCrops(cropsRes.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('資材マスタの取得に失敗しました。');
|
||||
@@ -75,6 +97,26 @@ export default function MaterialMastersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const allVarieties = crops.flatMap((crop) =>
|
||||
crop.varieties.map((variety) => ({
|
||||
...variety,
|
||||
crop_name: crop.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const seedVarietyOptions: VarietyOption[] = allVarieties.map((variety) => ({
|
||||
id: variety.id,
|
||||
label: `${variety.crop_name} / ${variety.name}`,
|
||||
}));
|
||||
|
||||
const getLinkedVariety = (materialId: number) =>
|
||||
allVarieties.find((variety) => variety.seed_material === materialId) ?? null;
|
||||
|
||||
const getLinkedVarietyLabel = (materialId: number) => {
|
||||
const variety = getLinkedVariety(materialId);
|
||||
return variety ? `${variety.crop_name} / ${variety.name}` : '-';
|
||||
};
|
||||
|
||||
const visibleMaterials = materials.filter((material) => {
|
||||
if (tab === 'misc') {
|
||||
return material.material_type === 'other' || material.material_type === 'seedling';
|
||||
@@ -90,9 +132,11 @@ export default function MaterialMastersPage() {
|
||||
|
||||
const startEdit = (material: Material) => {
|
||||
setError(null);
|
||||
const linkedVariety = getLinkedVariety(material.id);
|
||||
setForm({
|
||||
name: material.name,
|
||||
material_type: material.material_type,
|
||||
seed_variety_id: linkedVariety ? String(linkedVariety.id) : '',
|
||||
maker: material.maker,
|
||||
stock_unit: material.stock_unit,
|
||||
is_active: material.is_active,
|
||||
@@ -120,6 +164,23 @@ export default function MaterialMastersPage() {
|
||||
setForm(emptyForm(tab));
|
||||
};
|
||||
|
||||
const syncSeedVariety = async (materialId: number, seedVarietyId: string) => {
|
||||
const currentlyLinked = getLinkedVariety(materialId);
|
||||
const selectedVarietyId = seedVarietyId ? parseInt(seedVarietyId, 10) : null;
|
||||
|
||||
if (currentlyLinked && currentlyLinked.id !== selectedVarietyId) {
|
||||
await api.patch(`/plans/varieties/${currentlyLinked.id}/`, {
|
||||
seed_material: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedVarietyId) {
|
||||
await api.patch(`/plans/varieties/${selectedVarietyId}/`, {
|
||||
seed_material: materialId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
|
||||
@@ -159,13 +220,27 @@ export default function MaterialMastersPage() {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
let savedMaterial: Material;
|
||||
if (editingId === 'new') {
|
||||
await api.post('/materials/materials/', payload);
|
||||
const res = await api.post('/materials/materials/', payload);
|
||||
savedMaterial = res.data;
|
||||
} else {
|
||||
await api.put(`/materials/materials/${editingId}/`, payload);
|
||||
const res = await api.put(`/materials/materials/${editingId}/`, payload);
|
||||
savedMaterial = res.data;
|
||||
}
|
||||
|
||||
await fetchMaterials();
|
||||
if (form.material_type === 'seed') {
|
||||
await syncSeedVariety(savedMaterial.id, form.seed_variety_id);
|
||||
} else {
|
||||
const linkedVariety = getLinkedVariety(savedMaterial.id);
|
||||
if (linkedVariety) {
|
||||
await api.patch(`/plans/varieties/${linkedVariety.id}/`, {
|
||||
seed_material: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await fetchData();
|
||||
setEditingId(null);
|
||||
setForm(emptyForm(tab));
|
||||
} catch (e: unknown) {
|
||||
@@ -189,7 +264,7 @@ export default function MaterialMastersPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/materials/materials/${material.id}/`);
|
||||
await fetchMaterials();
|
||||
await fetchData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const detail =
|
||||
@@ -241,6 +316,22 @@ export default function MaterialMastersPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const tableProps = {
|
||||
materials: visibleMaterials,
|
||||
editingId,
|
||||
form,
|
||||
saving,
|
||||
seedVarietyOptions,
|
||||
getLinkedVarietyLabel,
|
||||
onEdit: startEdit,
|
||||
onDelete: handleDelete,
|
||||
onBaseFieldChange: handleBaseFieldChange,
|
||||
onFertilizerFieldChange: handleFertilizerFieldChange,
|
||||
onPesticideFieldChange: handlePesticideFieldChange,
|
||||
onSave: handleSave,
|
||||
onCancel: cancelEdit,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
@@ -304,51 +395,10 @@ export default function MaterialMastersPage() {
|
||||
<p className="text-sm text-gray-500">読み込み中...</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
{tab === 'fertilizer' && (
|
||||
<FertilizerTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'pesticide' && (
|
||||
<PesticideTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'misc' && (
|
||||
<MiscTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'fertilizer' && <FertilizerTable {...tableProps} />}
|
||||
{tab === 'pesticide' && <PesticideTable {...tableProps} />}
|
||||
{tab === 'seed' && <SeedTable {...tableProps} />}
|
||||
{tab === 'misc' && <MiscTable {...tableProps} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -361,6 +411,8 @@ interface TableProps {
|
||||
editingId: number | 'new' | null;
|
||||
form: MaterialFormState;
|
||||
saving: boolean;
|
||||
seedVarietyOptions: VarietyOption[];
|
||||
getLinkedVarietyLabel: (materialId: number) => string;
|
||||
onEdit: (material: Material) => void;
|
||||
onDelete: (material: Material) => void;
|
||||
onBaseFieldChange: (
|
||||
@@ -509,6 +561,59 @@ function PesticideTable(props: TableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SeedTable(props: TableProps) {
|
||||
return (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">資材名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">品種</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">メーカー</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">使用中</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{props.editingId === 'new' && <MaterialForm tab="seed" {...props} />}
|
||||
{props.materials.map((material) =>
|
||||
props.editingId === material.id ? (
|
||||
<MaterialForm key={material.id} tab="seed" {...props} />
|
||||
) : (
|
||||
<tr key={material.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{props.getLinkedVarietyLabel(material.id)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-600">
|
||||
{material.is_active ? '○' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RowActions
|
||||
disabled={props.editingId !== null}
|
||||
onEdit={() => props.onEdit(material)}
|
||||
onDelete={() => props.onDelete(material)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{props.materials.length === 0 && props.editingId === null && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
該当する資材が登録されていません
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function MiscTable(props: TableProps) {
|
||||
return (
|
||||
<table className="min-w-full text-sm">
|
||||
|
||||
@@ -10,12 +10,13 @@ import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Material, StockSummary, StockTransaction } from '@/types';
|
||||
|
||||
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc';
|
||||
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'seed' | 'misc';
|
||||
|
||||
const tabs: { key: FilterTab; label: string }[] = [
|
||||
{ key: 'all', label: '全て' },
|
||||
{ key: 'fertilizer', label: '肥料' },
|
||||
{ key: 'pesticide', label: '農薬' },
|
||||
{ key: 'seed', label: '種子' },
|
||||
{ key: 'misc', label: 'その他' },
|
||||
];
|
||||
|
||||
@@ -33,6 +34,7 @@ export default function MaterialsPage() {
|
||||
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
|
||||
const [presetTransactionType, setPresetTransactionType] =
|
||||
useState<StockTransaction['transaction_type'] | null>(null);
|
||||
const [editingTransaction, setEditingTransaction] = useState<StockTransaction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
@@ -93,11 +95,41 @@ export default function MaterialsPage() {
|
||||
materialId: number | null,
|
||||
transactionType: StockTransaction['transaction_type'] | null
|
||||
) => {
|
||||
setEditingTransaction(null);
|
||||
setPresetMaterialId(materialId);
|
||||
setPresetTransactionType(transactionType);
|
||||
setIsTransactionOpen(true);
|
||||
};
|
||||
|
||||
const handleEditTransaction = (transaction: StockTransaction) => {
|
||||
setPresetMaterialId(null);
|
||||
setPresetTransactionType(null);
|
||||
setEditingTransaction(transaction);
|
||||
setIsTransactionOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteTransaction = async (transaction: StockTransaction) => {
|
||||
if (!confirm(`この入出庫履歴を削除しますか?\n${transaction.transaction_type_display} ${transaction.quantity}${transaction.stock_unit_display}`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/materials/stock-transactions/${transaction.id}/`);
|
||||
await handleSavedTransaction();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const detail =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'response' in e &&
|
||||
typeof e.response === 'object' &&
|
||||
e.response !== null &&
|
||||
'data' in e.response
|
||||
? JSON.stringify(e.response.data)
|
||||
: '入出庫履歴の削除に失敗しました。';
|
||||
setError(detail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavedTransaction = async () => {
|
||||
await fetchSummaryOnly();
|
||||
|
||||
@@ -191,6 +223,8 @@ export default function MaterialsPage() {
|
||||
historyLoadingId={historyLoadingId}
|
||||
histories={histories}
|
||||
onOpenTransaction={handleOpenTransaction}
|
||||
onEditTransaction={handleEditTransaction}
|
||||
onDeleteTransaction={handleDeleteTransaction}
|
||||
onToggleHistory={handleToggleHistory}
|
||||
/>
|
||||
</div>
|
||||
@@ -200,7 +234,11 @@ export default function MaterialsPage() {
|
||||
materials={materials}
|
||||
presetMaterialId={presetMaterialId}
|
||||
presetTransactionType={presetTransactionType}
|
||||
onClose={() => setIsTransactionOpen(false)}
|
||||
editingTransaction={editingTransaction}
|
||||
onClose={() => {
|
||||
setIsTransactionOpen(false);
|
||||
setEditingTransaction(null);
|
||||
}}
|
||||
onSaved={handleSavedTransaction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
5
frontend/src/app/rice-transplant/[id]/edit/page.tsx
Normal file
5
frontend/src/app/rice-transplant/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RiceTransplantEditPage from '../../_components/RiceTransplantEditPage';
|
||||
|
||||
export default function EditRiceTransplantPage({ params }: { params: { id: string } }) {
|
||||
return <RiceTransplantEditPage planId={parseInt(params.id, 10)} />;
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, Save } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Crop, Field, RiceTransplantPlan, StockSummary, Variety } from '@/types';
|
||||
|
||||
type BoxMap = Record<number, string>;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default function RiceTransplantEditPage({ planId }: { planId?: number }) {
|
||||
const router = useRouter();
|
||||
const isNew = !planId;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [year, setYear] = useState(currentYear);
|
||||
const [seedMaterialId, setSeedMaterialId] = useState<number | ''>('');
|
||||
const [seedlingBoxesPerTan, setSeedlingBoxesPerTan] = useState('');
|
||||
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||
const [seedStocks, setSeedStocks] = useState<StockSummary[]>([]);
|
||||
|
||||
const [calcBoxes, setCalcBoxes] = useState<BoxMap>({});
|
||||
const [adjustedBoxes, setAdjustedBoxes] = useState<BoxMap>({});
|
||||
const [boxesRounded, setBoxesRounded] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||
|
||||
const allVarieties = crops.flatMap((crop: Crop) => crop.varieties);
|
||||
const getVarietyBySeedMaterial = (id: number) =>
|
||||
allVarieties.find((variety: Variety) => variety.seed_material === id) ?? null;
|
||||
|
||||
const calculateDefaultBoxes = (field: Field, perTan: string) => {
|
||||
const areaTan = parseFloat(field.area_tan || '0');
|
||||
const boxesPerTan = parseFloat(perTan || '0');
|
||||
return Number.isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [cropsRes, fieldsRes, seedStockRes] = await Promise.all([
|
||||
api.get('/plans/crops/'),
|
||||
api.get('/fields/?ordering=display_order,id'),
|
||||
api.get('/materials/stock-summary/?material_type=seed'),
|
||||
]);
|
||||
setCrops(cropsRes.data);
|
||||
setAllFields(fieldsRes.data);
|
||||
setSeedStocks(seedStockRes.data);
|
||||
|
||||
if (!isNew && planId) {
|
||||
const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`);
|
||||
const plan: RiceTransplantPlan = planRes.data;
|
||||
const fetchedVarieties = cropsRes.data.flatMap((crop: Crop) => crop.varieties);
|
||||
const linkedVariety =
|
||||
fetchedVarieties.find((variety: Variety) => variety.id === plan.variety) ?? null;
|
||||
setName(plan.name);
|
||||
setYear(plan.year);
|
||||
setSeedMaterialId(linkedVariety?.seed_material ?? '');
|
||||
setSeedlingBoxesPerTan(plan.seedling_boxes_per_tan);
|
||||
setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box);
|
||||
setNotes(plan.notes);
|
||||
|
||||
const fieldIds = new Set(plan.entries.map((entry) => entry.field));
|
||||
const planFields = fieldsRes.data.filter((field: Field) => fieldIds.has(field.id));
|
||||
setSelectedFields(planFields);
|
||||
setCandidateFields(planFields);
|
||||
|
||||
const nextAdjusted: BoxMap = {};
|
||||
const nextCalc: BoxMap = {};
|
||||
plan.entries.forEach((entry) => {
|
||||
nextAdjusted[entry.field] = Number(entry.installed_seedling_boxes).toFixed(1);
|
||||
nextCalc[entry.field] = Number(entry.default_seedling_boxes).toFixed(1);
|
||||
});
|
||||
setAdjustedBoxes(nextAdjusted);
|
||||
setCalcBoxes(nextCalc);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('データの読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [isNew, planId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCandidates = async () => {
|
||||
const selectedVariety = seedMaterialId ? getVarietyBySeedMaterial(seedMaterialId) : null;
|
||||
if (!selectedVariety || !year || (!isNew && loading)) return;
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${selectedVariety.id}`
|
||||
);
|
||||
const nextCandidates: Field[] = res.data;
|
||||
setCandidateFields(nextCandidates);
|
||||
if (isNew) {
|
||||
setSelectedFields(nextCandidates);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('候補圃場の取得に失敗しました。');
|
||||
}
|
||||
};
|
||||
fetchCandidates();
|
||||
}, [seedMaterialId, year, isNew, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!seedMaterialId) return;
|
||||
const variety = getVarietyBySeedMaterial(seedMaterialId);
|
||||
if (!variety) return;
|
||||
if (seedlingBoxesPerTan === '') {
|
||||
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
|
||||
}
|
||||
}, [seedMaterialId, crops, seedlingBoxesPerTan]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextCalc: BoxMap = {};
|
||||
selectedFields.forEach((field) => {
|
||||
nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan);
|
||||
});
|
||||
setCalcBoxes(nextCalc);
|
||||
setBoxesRounded(false);
|
||||
}, [selectedFields, seedlingBoxesPerTan]);
|
||||
|
||||
const addField = (field: Field) => {
|
||||
if (selectedFields.some((selected) => selected.id === field.id)) return;
|
||||
setSelectedFields((prev) => [...prev, field]);
|
||||
};
|
||||
|
||||
const removeField = (fieldId: number) => {
|
||||
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
|
||||
setCalcBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[fieldId];
|
||||
return next;
|
||||
});
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[fieldId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoxCount = (fieldId: number, value: string) => {
|
||||
setAdjustedBoxes((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const applyColumnDefaults = () => {
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
next[field.id] = calcBoxes[field.id] ?? '';
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(false);
|
||||
};
|
||||
|
||||
const toggleRoundColumn = () => {
|
||||
if (boxesRounded) {
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
delete next[field.id];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
const raw = calcBoxes[field.id] ?? prev[field.id];
|
||||
if (!raw) return;
|
||||
const value = parseFloat(raw);
|
||||
if (Number.isNaN(value)) return;
|
||||
next[field.id] = String(Math.round(value));
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(true);
|
||||
};
|
||||
|
||||
const effectiveBoxes = (fieldId: number) => {
|
||||
const raw =
|
||||
adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== ''
|
||||
? adjustedBoxes[fieldId]
|
||||
: calcBoxes[fieldId];
|
||||
const value = parseFloat(raw ?? '0');
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
};
|
||||
|
||||
const selectedSeedStock = seedMaterialId
|
||||
? seedStocks.find((item) => item.material_id === seedMaterialId) ?? null
|
||||
: null;
|
||||
const selectedVariety = seedMaterialId ? getVarietyBySeedMaterial(seedMaterialId) : null;
|
||||
|
||||
const totalBoxes = selectedFields.reduce((sum, field) => sum + effectiveBoxes(field.id), 0);
|
||||
const seedGrams = parseFloat(defaultSeedGramsPerBox || '0');
|
||||
const totalSeedKg = seedGrams > 0 ? (totalBoxes * seedGrams) / 1000 : 0;
|
||||
const seedInventoryKg = parseFloat(selectedSeedStock?.current_stock ?? '0');
|
||||
const remainingSeedKg = seedInventoryKg - totalSeedKg;
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError('計画名を入力してください。');
|
||||
return;
|
||||
}
|
||||
if (!seedMaterialId) {
|
||||
setError('種子資材を選択してください。');
|
||||
return;
|
||||
}
|
||||
if (!selectedVariety) {
|
||||
setError('選択した種子資材に対応する品種が未設定です。資材マスタで紐付けてください。');
|
||||
return;
|
||||
}
|
||||
if (selectedFields.length === 0) {
|
||||
setError('圃場を1つ以上選択してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = selectedFields.map((field) => ({
|
||||
field_id: field.id,
|
||||
installed_seedling_boxes: effectiveBoxes(field.id).toFixed(2),
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
year,
|
||||
variety: selectedVariety.id,
|
||||
seedling_boxes_per_tan: seedlingBoxesPerTan || '0',
|
||||
default_seed_grams_per_box: defaultSeedGramsPerBox || '0',
|
||||
notes,
|
||||
entries,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
await api.post('/plans/rice-transplant-plans/', payload);
|
||||
} else {
|
||||
await api.put(`/plans/rice-transplant-plans/${planId}/`, payload);
|
||||
}
|
||||
router.push('/rice-transplant');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('保存に失敗しました。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unselectedFields = (candidateFields.length > 0 ? candidateFields : allFields).filter(
|
||||
(field) => !selectedFields.some((selected) => selected.id === field.id)
|
||||
);
|
||||
|
||||
const fieldRows = useMemo(
|
||||
() =>
|
||||
selectedFields.map((field) => ({
|
||||
field,
|
||||
defaultBoxes: calcBoxes[field.id] ?? '',
|
||||
boxCount:
|
||||
adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== ''
|
||||
? adjustedBoxes[field.id]
|
||||
: calcBoxes[field.id] ?? '',
|
||||
})),
|
||||
[selectedFields, calcBoxes, adjustedBoxes]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 text-gray-500">読み込み中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/rice-transplant')}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{isNew ? '田植え計画 新規作成' : '田植え計画 編集'}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="xl:col-span-2">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">計画名</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
placeholder="例: 2026年度 にこまる 第1回"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">年度</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
{years.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">種子資材</label>
|
||||
<select
|
||||
value={seedMaterialId}
|
||||
onChange={(e) =>
|
||||
setSeedMaterialId(e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="">選択してください</option>
|
||||
{seedStocks.map((stock) => (
|
||||
<option key={stock.material_id} value={stock.material_id}>
|
||||
{stock.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">
|
||||
苗箱1枚あたり種もみ(g)
|
||||
</label>
|
||||
<input
|
||||
value={defaultSeedGramsPerBox}
|
||||
onChange={(e) => setDefaultSeedGramsPerBox(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-right focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">対象圃場</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{selectedFields.map((field) => (
|
||||
<button
|
||||
key={field.id}
|
||||
onClick={() => removeField(field.id)}
|
||||
className="rounded-full border border-green-200 bg-green-50 px-3 py-1 text-xs text-green-800"
|
||||
>
|
||||
{field.name} ×
|
||||
</button>
|
||||
))}
|
||||
{selectedFields.length === 0 && (
|
||||
<p className="text-sm text-gray-500">圃場が選択されていません。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unselectedFields.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">追加可能</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unselectedFields.map((field) => (
|
||||
<button
|
||||
key={field.id}
|
||||
onClick={() => addField(field)}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{field.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-[2fr,1fr]">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">備考</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-800">集計</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>対象圃場</span>
|
||||
<span>{selectedFields.length}筆</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>苗箱合計</span>
|
||||
<span>{totalBoxes.toFixed(1)}枚</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>必要種もみ量</span>
|
||||
<span>{totalSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>{selectedSeedStock?.name || '種子在庫未設定'}</span>
|
||||
<span>{seedInventoryKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex justify-between font-semibold ${
|
||||
remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
<span>残在庫見込み</span>
|
||||
<span>{remainingSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">圃場</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">面積(反)</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">
|
||||
<div>苗箱数</div>
|
||||
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
|
||||
<div className="text-gray-500">反当 {seedlingBoxesPerTan || '0'}枚</div>
|
||||
<div className="text-gray-500">合計 {totalBoxes.toFixed(1)}枚</div>
|
||||
</div>
|
||||
<span className="mt-1 flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400">
|
||||
(枚)
|
||||
<button
|
||||
onClick={toggleRoundColumn}
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded font-bold leading-none ${
|
||||
boxesRounded
|
||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
||||
}`}
|
||||
title={boxesRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||||
>
|
||||
{boxesRounded ? '↩' : '≈'}
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-t border-gray-200 px-4 py-2 text-left text-xs font-medium text-gray-500">
|
||||
反当苗箱枚数
|
||||
</th>
|
||||
<th className="border-t border-gray-200 px-4 py-2" />
|
||||
<th className="border-t border-gray-200 px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<input
|
||||
value={seedlingBoxesPerTan}
|
||||
onChange={(e) => setSeedlingBoxesPerTan(e.target.value)}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:outline-none focus:ring-1 focus:ring-green-400"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyColumnDefaults}
|
||||
className="rounded border border-blue-300 px-3 py-1 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
反映
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{fieldRows.map(({ field, defaultBoxes, boxCount }) => (
|
||||
<tr key={field.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{field.name}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">
|
||||
{Number(field.area_tan).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs tabular-nums text-gray-500">
|
||||
既定 {defaultBoxes || '0.0'}枚
|
||||
</span>
|
||||
<input
|
||||
value={boxCount}
|
||||
onChange={(e) => updateBoxCount(field.id, e.target.value)}
|
||||
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/rice-transplant/new/page.tsx
Normal file
5
frontend/src/app/rice-transplant/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RiceTransplantEditPage from '../_components/RiceTransplantEditPage';
|
||||
|
||||
export default function NewRiceTransplantPage() {
|
||||
return <RiceTransplantEditPage />;
|
||||
}
|
||||
161
frontend/src/app/rice-transplant/page.tsx
Normal file
161
frontend/src/app/rice-transplant/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Pencil, Plus, Sprout, Trash2 } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { RiceTransplantPlan } from '@/types';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default function RiceTransplantPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('riceTransplantYear');
|
||||
if (saved) return parseInt(saved, 10);
|
||||
}
|
||||
return currentYear;
|
||||
});
|
||||
const [plans, setPlans] = useState<RiceTransplantPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/plans/rice-transplant-plans/?year=${year}`);
|
||||
setPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('田植え計画の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('riceTransplantYear', String(year));
|
||||
fetchPlans();
|
||||
}, [year]);
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
setError(null);
|
||||
if (!confirm(`「${name}」を削除しますか?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/plans/rice-transplant-plans/${id}/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(`「${name}」の削除に失敗しました。`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sprout className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-800">田植え計画</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/rice-transplant/new')}
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
{years.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
|
||||
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p>{year}年度の田植え計画はありません</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">計画名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">種子資材</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">圃場</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">苗箱合計</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">種もみ計画</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">残在庫見込み</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{plan.seed_material_name || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seedling_boxes}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seed_kg}kg</td>
|
||||
<td className={`px-4 py-3 text-right tabular-nums ${parseFloat(plan.remaining_seed_kg) < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
{plan.remaining_seed_kg}kg
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/rice-transplant/${plan.id}/edit`)}
|
||||
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
frontend/src/app/workrecords/page.tsx
Normal file
144
frontend/src/app/workrecords/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, NotebookText } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { WorkRecord } from '@/types';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_KEY = 'workRecordYear';
|
||||
|
||||
export default function WorkRecordsPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||
}
|
||||
return CURRENT_YEAR;
|
||||
});
|
||||
const [records, setRecords] = useState<WorkRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(YEAR_KEY, String(year));
|
||||
void fetchRecords();
|
||||
}, [year]);
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/workrecords/?year=${year}`);
|
||||
setRecords(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('作業記録の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveToSource = (record: WorkRecord) => {
|
||||
if (record.spreading_session) {
|
||||
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
|
||||
return;
|
||||
}
|
||||
if (record.levee_work_session) {
|
||||
router.push(`/levee-work?session=${record.levee_work_session}`);
|
||||
return;
|
||||
}
|
||||
if (record.delivery_plan_id) {
|
||||
router.push(`/distribution/${record.delivery_plan_id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<NotebookText className="h-6 w-6 text-green-700" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">作業記録</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow-sm">
|
||||
{loading ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-500">読み込み中...</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-400">この年度の作業記録はまだありません。</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">作業日</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">種別</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">タイトル</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">参照先</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-700">{record.work_date}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{record.work_type_display}</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{record.title}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{record.spreading_session
|
||||
? `散布実績 #${record.spreading_session}`
|
||||
: record.levee_work_session
|
||||
? `畔塗記録 #${record.levee_work_session}`
|
||||
: record.delivery_plan_name
|
||||
? `${record.delivery_plan_name}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{(record.spreading_session || record.levee_work_session || record.delivery_plan_id) && (
|
||||
<button
|
||||
onClick={() => moveToSource(record)}
|
||||
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
開く
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +1,503 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ChevronDown,
|
||||
Cloud,
|
||||
FileText,
|
||||
History,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Menu,
|
||||
NotebookText,
|
||||
Package,
|
||||
PencilLine,
|
||||
Shield,
|
||||
Sprout,
|
||||
Tractor,
|
||||
Truck,
|
||||
Upload,
|
||||
Construction,
|
||||
Wheat,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { logout } from '@/lib/api';
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: LucideIcon;
|
||||
match?: (pathname: string) => boolean;
|
||||
};
|
||||
|
||||
type NavGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'link' | 'group';
|
||||
href?: string;
|
||||
icon?: LucideIcon;
|
||||
items?: NavItem[];
|
||||
};
|
||||
|
||||
const matchesHref = (pathname: string, href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
key: 'home',
|
||||
label: 'ホーム',
|
||||
type: 'link',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
key: 'planning',
|
||||
label: '計画',
|
||||
type: 'group',
|
||||
icon: Wheat,
|
||||
items: [
|
||||
{ label: '作付け計画', href: '/allocation', icon: Wheat },
|
||||
{
|
||||
label: '施肥計画',
|
||||
href: '/fertilizer',
|
||||
icon: Sprout,
|
||||
match: (pathname) =>
|
||||
matchesHref(pathname, '/fertilizer') &&
|
||||
!matchesHref(pathname, '/fertilizer/spreading') &&
|
||||
!matchesHref(pathname, '/fertilizer/masters'),
|
||||
},
|
||||
{ label: '田植え計画', href: '/rice-transplant', icon: Tractor },
|
||||
{ label: '運搬計画', href: '/distribution', icon: Truck },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'records',
|
||||
label: '実績',
|
||||
type: 'group',
|
||||
icon: NotebookText,
|
||||
items: [
|
||||
{
|
||||
label: '散布実績',
|
||||
href: '/fertilizer/spreading',
|
||||
icon: PencilLine,
|
||||
},
|
||||
{ label: '畔塗記録', href: '/levee-work', icon: Construction },
|
||||
{ label: '作業記録', href: '/workrecords', icon: NotebookText },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'masters',
|
||||
label: 'マスター',
|
||||
type: 'group',
|
||||
icon: Package,
|
||||
items: [
|
||||
{ label: '圃場管理', href: '/fields', icon: MapPin },
|
||||
{
|
||||
label: '資材マスタ',
|
||||
href: '/materials/masters',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
label: '肥料マスタ',
|
||||
href: '/fertilizer/masters',
|
||||
icon: Sprout,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'support',
|
||||
label: '帳票・連携',
|
||||
type: 'group',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{
|
||||
label: '在庫管理',
|
||||
href: '/materials',
|
||||
icon: Package,
|
||||
match: (pathname) =>
|
||||
matchesHref(pathname, '/materials') && !matchesHref(pathname, '/materials/masters'),
|
||||
},
|
||||
{ label: '帳票出力', href: '/reports', icon: FileText },
|
||||
{ label: 'データ取込', href: '/import', icon: Upload },
|
||||
{ label: '気象', href: '/weather', icon: Cloud },
|
||||
{ label: 'メール履歴', href: '/mail/history', icon: History },
|
||||
{ label: 'メールルール', href: '/mail/rules', icon: Shield },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const userActions: NavItem[] = [
|
||||
{ label: 'パスワード変更', href: '/settings/password', icon: KeyRound },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const [openDesktopGroup, setOpenDesktopGroup] = useState<string | null>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [openMobileGroups, setOpenMobileGroups] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!navRef.current?.contains(event.target as Node)) {
|
||||
setOpenDesktopGroup(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpenDesktopGroup(null);
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenDesktopGroup(null);
|
||||
setMobileMenuOpen(false);
|
||||
setOpenMobileGroups((prev) => {
|
||||
const activeKey = getActiveGroupKey(pathname);
|
||||
if (!activeKey) return prev;
|
||||
return prev.includes(activeKey) ? prev : [activeKey];
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
const navigateTo = (href: string) => {
|
||||
setOpenDesktopGroup(null);
|
||||
setMobileMenuOpen(false);
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
const toggleDesktopGroup = (key: string) => {
|
||||
setOpenDesktopGroup((prev) => (prev === key ? null : key));
|
||||
};
|
||||
|
||||
const toggleMobileGroup = (key: string) => {
|
||||
setOpenMobileGroups((prev) =>
|
||||
prev.includes(key) ? prev.filter((groupKey) => groupKey !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
if (!mobileMenuOpen) {
|
||||
const activeKey = getActiveGroupKey(pathname);
|
||||
setOpenMobileGroups(activeKey ? [activeKey] : []);
|
||||
}
|
||||
setMobileMenuOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<button onClick={() => router.push('/dashboard')} className="text-xl font-bold text-green-700 hover:text-green-800 transition-colors">
|
||||
<nav ref={navRef} className="border-b border-gray-200 bg-white shadow-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-4 lg:gap-8">
|
||||
<button
|
||||
onClick={() => navigateTo('/dashboard')}
|
||||
className="text-lg font-bold text-green-700 transition-colors hover:text-green-800 sm:text-xl"
|
||||
>
|
||||
KeinaSystem
|
||||
</button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/dashboard')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4 mr-2" />
|
||||
ホーム
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/allocation')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/allocation')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Wheat className="h-4 w-4 mr-2" />
|
||||
作付け計画
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fields')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/fields') || pathname?.startsWith('/fields/')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
圃場管理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/reports')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/reports') || pathname?.startsWith('/reports/')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
帳票出力
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/import')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/import')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
データ取込
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/mail/history')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/mail/history')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
メール履歴
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/mail/rules')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/mail/rules')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
メールルール
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/weather')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/weather')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2" />
|
||||
気象
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
pathname?.startsWith('/fertilizer')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Sprout className="h-4 w-4 mr-2" />
|
||||
施肥計画
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/distribution')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
pathname?.startsWith('/distribution')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-2" />
|
||||
運搬計画
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/materials')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
pathname?.startsWith('/materials')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
在庫管理
|
||||
</button>
|
||||
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
{navGroups.map((group) =>
|
||||
group.type === 'link' ? (
|
||||
<DesktopLinkButton
|
||||
key={group.key}
|
||||
group={group}
|
||||
pathname={pathname}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
) : (
|
||||
<DesktopGroupButton
|
||||
key={group.key}
|
||||
group={group}
|
||||
isOpen={openDesktopGroup === group.key}
|
||||
pathname={pathname}
|
||||
onNavigate={navigateTo}
|
||||
onToggle={toggleDesktopGroup}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => router.push('/settings/password')}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||
title="パスワード変更"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
{userActions.map((item) => (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => navigateTo(item.href)}
|
||||
className={`rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
isItemActive(item, pathname)
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
title={item.label}
|
||||
>
|
||||
{item.icon ? <item.icon className="h-4 w-4" /> : item.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
||||
className="flex items-center rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
ログアウト
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
onClick={() => navigateTo('/settings/password')}
|
||||
className={`rounded-md p-2 transition-colors ${
|
||||
isItemActive(userActions[0], pathname)
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
title="パスワード変更"
|
||||
>
|
||||
<KeyRound className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="メニューを開く"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<div className="border-t border-gray-200 py-3 lg:hidden">
|
||||
<div className="space-y-1">
|
||||
{navGroups.map((group) =>
|
||||
group.type === 'link' ? (
|
||||
<MobileLinkButton
|
||||
key={group.key}
|
||||
group={group}
|
||||
pathname={pathname}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
) : (
|
||||
<MobileGroupButton
|
||||
key={group.key}
|
||||
group={group}
|
||||
isOpen={openMobileGroups.includes(group.key)}
|
||||
pathname={pathname}
|
||||
onNavigate={navigateTo}
|
||||
onToggle={toggleMobileGroup}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-3 flex w-full items-center rounded-lg px-3 py-3 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
<LogOut className="mr-3 h-4 w-4" />
|
||||
ログアウト
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopLinkButton({
|
||||
group,
|
||||
pathname,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
pathname: string;
|
||||
onNavigate: (href: string) => void;
|
||||
}) {
|
||||
const active = isGroupActive(group, pathname);
|
||||
const Icon = group.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => group.href && onNavigate(group.href)}
|
||||
className={`flex items-center rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
active ? 'bg-green-50 text-green-700' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
|
||||
{group.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopGroupButton({
|
||||
group,
|
||||
isOpen,
|
||||
pathname,
|
||||
onNavigate,
|
||||
onToggle,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
isOpen: boolean;
|
||||
pathname: string;
|
||||
onNavigate: (href: string) => void;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
const active = isGroupActive(group, pathname);
|
||||
const Icon = group.icon;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => onToggle(group.key)}
|
||||
className={`flex items-center rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
active || isOpen
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
|
||||
{group.label}
|
||||
<ChevronDown className={`ml-2 h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && group.items ? (
|
||||
<div className="absolute left-0 top-full z-20 mt-2 w-64 rounded-xl border border-gray-200 bg-white p-2 shadow-lg">
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => onNavigate(item.href)}
|
||||
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isItemActive(item, pathname)
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.icon ? <item.icon className="mr-3 h-4 w-4" /> : null}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLinkButton({
|
||||
group,
|
||||
pathname,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
pathname: string;
|
||||
onNavigate: (href: string) => void;
|
||||
}) {
|
||||
const active = isGroupActive(group, pathname);
|
||||
const Icon = group.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => group.href && onNavigate(group.href)}
|
||||
className={`flex w-full items-center rounded-lg px-3 py-3 text-left text-sm transition-colors ${
|
||||
active ? 'bg-green-50 text-green-700' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{Icon ? <Icon className="mr-3 h-4 w-4" /> : null}
|
||||
{group.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileGroupButton({
|
||||
group,
|
||||
isOpen,
|
||||
pathname,
|
||||
onNavigate,
|
||||
onToggle,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
isOpen: boolean;
|
||||
pathname: string;
|
||||
onNavigate: (href: string) => void;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
const active = isGroupActive(group, pathname);
|
||||
const Icon = group.icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200">
|
||||
<button
|
||||
onClick={() => onToggle(group.key)}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-3 text-left text-sm transition-colors ${
|
||||
active || isOpen
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{Icon ? <Icon className="mr-3 h-4 w-4" /> : null}
|
||||
{group.label}
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && group.items ? (
|
||||
<div className="space-y-1 border-t border-gray-200 px-2 py-2">
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => onNavigate(item.href)}
|
||||
className={`flex w-full items-center rounded-lg px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
isItemActive(item, pathname)
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.icon ? <item.icon className="mr-3 h-4 w-4" /> : null}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isGroupActive(group: NavGroup, pathname: string) {
|
||||
if (group.type === 'link') {
|
||||
return group.href ? matchesHref(pathname, group.href) : false;
|
||||
}
|
||||
|
||||
return group.items?.some((item) => isItemActive(item, pathname)) ?? false;
|
||||
}
|
||||
|
||||
function isItemActive(item: NavItem, pathname: string) {
|
||||
if (item.match) {
|
||||
return item.match(pathname);
|
||||
}
|
||||
return matchesHref(pathname, item.href);
|
||||
}
|
||||
|
||||
function getActiveGroupKey(pathname: string) {
|
||||
return navGroups.find((group) => isGroupActive(group, pathname))?.key ?? null;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface Variety {
|
||||
id: number;
|
||||
crop: number;
|
||||
name: string;
|
||||
default_seedling_boxes_per_tan: string;
|
||||
seed_material: number | null;
|
||||
seed_material_name: string | null;
|
||||
}
|
||||
|
||||
export interface Crop {
|
||||
@@ -54,6 +57,16 @@ export interface Plan {
|
||||
variety: number;
|
||||
variety_name: string;
|
||||
notes: string | null;
|
||||
variety_change_count?: number;
|
||||
latest_variety_change?: {
|
||||
id: number;
|
||||
changed_at: string;
|
||||
old_variety_id: number | null;
|
||||
old_variety_name: string | null;
|
||||
new_variety_id: number | null;
|
||||
new_variety_name: string | null;
|
||||
fertilizer_moved_entry_count: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Fertilizer {
|
||||
@@ -88,7 +101,7 @@ export interface PesticideProfile {
|
||||
export interface Material {
|
||||
id: number;
|
||||
name: string;
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other';
|
||||
material_type_display: string;
|
||||
maker: string;
|
||||
stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece';
|
||||
@@ -115,13 +128,15 @@ export interface StockTransaction {
|
||||
occurred_on: string;
|
||||
note: string;
|
||||
fertilization_plan: number | null;
|
||||
spreading_item?: number | null;
|
||||
is_locked: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StockSummary {
|
||||
material_id: number;
|
||||
name: string;
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other';
|
||||
material_type_display: string;
|
||||
maker: string;
|
||||
stock_unit: string;
|
||||
@@ -140,7 +155,8 @@ export interface FertilizationEntry {
|
||||
field_area_tan?: string;
|
||||
fertilizer: number;
|
||||
fertilizer_name?: string;
|
||||
bags: number;
|
||||
bags: number | string;
|
||||
actual_bags?: string | null;
|
||||
}
|
||||
|
||||
export interface FertilizationPlan {
|
||||
@@ -154,8 +170,44 @@ export interface FertilizationPlan {
|
||||
entries: FertilizationEntry[];
|
||||
field_count: number;
|
||||
fertilizer_count: number;
|
||||
planned_total_bags: string;
|
||||
spread_total_bags: string;
|
||||
remaining_total_bags: string;
|
||||
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
||||
is_confirmed: boolean;
|
||||
confirmed_at: string | null;
|
||||
is_variety_change_plan: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RiceTransplantEntry {
|
||||
id?: number;
|
||||
field: number;
|
||||
field_name?: string;
|
||||
field_area_tan?: string;
|
||||
installed_seedling_boxes: string;
|
||||
default_seedling_boxes: string;
|
||||
planned_boxes: string;
|
||||
}
|
||||
|
||||
export interface RiceTransplantPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
year: number;
|
||||
variety: number;
|
||||
variety_name: string;
|
||||
crop_name: string;
|
||||
default_seed_grams_per_box: string;
|
||||
seedling_boxes_per_tan: string;
|
||||
notes: string;
|
||||
seed_material_name: string | null;
|
||||
entries: RiceTransplantEntry[];
|
||||
field_count: number;
|
||||
total_seedling_boxes: string;
|
||||
total_seed_kg: string;
|
||||
variety_seed_inventory_kg: string;
|
||||
remaining_seed_kg: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -180,6 +232,8 @@ export interface DeliveryTripItem {
|
||||
fertilizer: number;
|
||||
fertilizer_name: string;
|
||||
bags: string;
|
||||
spread_bags: string;
|
||||
remaining_bags: string;
|
||||
}
|
||||
|
||||
export interface DeliveryTrip {
|
||||
@@ -187,6 +241,7 @@ export interface DeliveryTrip {
|
||||
order: number;
|
||||
name: string;
|
||||
date: string | null;
|
||||
work_record_id: number | null;
|
||||
items: DeliveryTripItem[];
|
||||
}
|
||||
|
||||
@@ -197,6 +252,7 @@ export interface DeliveryAllEntry {
|
||||
fertilizer: number;
|
||||
fertilizer_name: string;
|
||||
bags: string;
|
||||
actual_bags?: string | null;
|
||||
}
|
||||
|
||||
export interface DeliveryPlan {
|
||||
@@ -222,6 +278,96 @@ export interface DeliveryPlanListItem {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SpreadingCandidate {
|
||||
field: number;
|
||||
field_name: string;
|
||||
field_area_tan: string;
|
||||
fertilizer: number;
|
||||
fertilizer_name: string;
|
||||
planned_bags: string;
|
||||
delivered_bags: string;
|
||||
spread_bags: string;
|
||||
spread_bags_other: string;
|
||||
current_session_bags: string;
|
||||
remaining_bags: string;
|
||||
}
|
||||
|
||||
export interface SpreadingSessionItem {
|
||||
id: number;
|
||||
field: number;
|
||||
field_name: string;
|
||||
fertilizer: number;
|
||||
fertilizer_name: string;
|
||||
actual_bags: string;
|
||||
planned_bags_snapshot: string;
|
||||
delivered_bags_snapshot: string;
|
||||
}
|
||||
|
||||
export interface SpreadingSession {
|
||||
id: number;
|
||||
year: number;
|
||||
date: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
work_record_id: number | null;
|
||||
items: SpreadingSessionItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LeveeWorkCandidate {
|
||||
field_id: number;
|
||||
field_name: string;
|
||||
field_area_tan: string;
|
||||
group_name: string | null;
|
||||
plan_id: number;
|
||||
crop_name: string;
|
||||
variety_name: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface LeveeWorkSessionItem {
|
||||
id: number;
|
||||
field: number;
|
||||
field_name: string;
|
||||
field_area_tan: string;
|
||||
group_name: string | null;
|
||||
plan: number | null;
|
||||
crop_name_snapshot: string;
|
||||
variety_name_snapshot: string;
|
||||
}
|
||||
|
||||
export interface LeveeWorkSession {
|
||||
id: number;
|
||||
year: number;
|
||||
date: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
work_record_id: number | null;
|
||||
item_count: number;
|
||||
total_area_tan: string;
|
||||
items: LeveeWorkSessionItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkRecord {
|
||||
id: number;
|
||||
work_date: string;
|
||||
work_type: 'fertilizer_delivery' | 'fertilizer_spreading' | 'levee_work';
|
||||
work_type_display: string;
|
||||
title: string;
|
||||
year: number;
|
||||
auto_created: boolean;
|
||||
delivery_trip: number | null;
|
||||
delivery_plan_id: number | null;
|
||||
delivery_plan_name: string | null;
|
||||
spreading_session: number | null;
|
||||
levee_work_session: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MailSender {
|
||||
id: number;
|
||||
type: 'address' | 'domain';
|
||||
|
||||
File diff suppressed because one or more lines are too long
48
sync_db.sh
Executable file
48
sync_db.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# サーバーのDBをローカルに同期するスクリプト
|
||||
#
|
||||
# 事前準備(サーバー側でkeinasystemユーザーとして実行):
|
||||
# docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
|
||||
#
|
||||
# 使用: bash sync_db.sh
|
||||
set -e
|
||||
|
||||
REMOTE_HOST="keinafarm"
|
||||
LOCAL_DUMP="/tmp/keinasystem_dump.sql"
|
||||
|
||||
echo "=== DBSync: サーバー → ローカル ==="
|
||||
|
||||
# 1. サーバーからdumpファイルをscpで取得
|
||||
echo "[1/4] サーバーからダンプファイルを取得..."
|
||||
scp "$REMOTE_HOST:/tmp/keinasystem_dump.sql" "$LOCAL_DUMP"
|
||||
echo " → ダンプ取得完了: $LOCAL_DUMP ($(du -sh $LOCAL_DUMP | cut -f1))"
|
||||
|
||||
# 2. ローカルのDBコンテナが起動しているか確認
|
||||
echo "[2/4] ローカルDBコンテナを確認..."
|
||||
if ! docker compose -f docker-compose.local.yml ps db 2>/dev/null | grep -q "running"; then
|
||||
echo " → ローカルDBコンテナが起動していません。起動します..."
|
||||
docker compose -f docker-compose.local.yml up -d db
|
||||
echo " → DB起動待機中..."
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
# 3. 既存データをドロップして復元
|
||||
echo "[3/4] ローカルDBにリストア(既存データをリセット)..."
|
||||
# DBを一旦削除して再作成してからリストア
|
||||
docker compose -f docker-compose.local.yml exec -T db \
|
||||
psql -U keinasystem -d postgres -c "DROP DATABASE IF EXISTS keinasystem;" --quiet
|
||||
docker compose -f docker-compose.local.yml exec -T db \
|
||||
psql -U keinasystem -d postgres -c "CREATE DATABASE keinasystem OWNER keinasystem;" --quiet
|
||||
cat "$LOCAL_DUMP" | docker compose -f docker-compose.local.yml exec -T db \
|
||||
psql -U keinasystem -d keinasystem --quiet
|
||||
echo " → リストア完了"
|
||||
|
||||
# クリーンアップ
|
||||
rm -f "$LOCAL_DUMP"
|
||||
|
||||
# 4. マイグレーション(サーバーより新しいマイグレーションを適用)
|
||||
echo "[4/4] マイグレーション実行..."
|
||||
docker compose -f docker-compose.local.yml exec backend python manage.py migrate
|
||||
|
||||
echo ""
|
||||
echo "=== 同期完了 ==="
|
||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
707
改善案/TODO管理機能仕様書案.md
Normal file
707
改善案/TODO管理機能仕様書案.md
Normal file
@@ -0,0 +1,707 @@
|
||||
# TODO管理機能仕様書案
|
||||
|
||||
> 作成日: 2026-04-09
|
||||
> 最終更新: 2026-04-09
|
||||
> 対象プロジェクト: `keinasystem`
|
||||
> 対象 Issue: `akira/keinasystem#17`
|
||||
> 位置づけ: 実装前ドラフト(レビュー反映版)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概要
|
||||
|
||||
繁忙期の作業を「どれから手を付けるか」の観点で整理するため、Redmine チケットライクな TODO 管理機能を追加する。
|
||||
|
||||
本機能は単なるメモではなく、以下の中間レイヤーとして位置付ける。
|
||||
|
||||
- 計画
|
||||
- TODO
|
||||
- 実績
|
||||
|
||||
将来的には、作付け計画を除く各種計画について、`計画 -> TODO -> 実績` の流れに挟める構造を目指す。
|
||||
ただし MVP では、まず TODO 管理の基本機能、対象圃場の管理、計画との紐づけ、完了時の実績連携導線を整備する。
|
||||
|
||||
---
|
||||
|
||||
## 2. 背景
|
||||
|
||||
現状は施肥計画、田植え計画、運搬計画などの個別機能はあるが、「今日やること」「今週先に処理すべきこと」を横断的に管理する仕組みがない。
|
||||
|
||||
そのため、繁忙期には以下の問題が起こりやすい。
|
||||
|
||||
- 作業の優先順位が頭の中や紙メモに依存する
|
||||
- 計画の一部だけを先に実行したい場合に管理しづらい
|
||||
- 実績入力までの間に「作業待ち」「着手中」の状態を置けない
|
||||
- 将来追加される作業系機能を共通の入口で扱えない
|
||||
|
||||
TODO 管理を導入し、計画単位ではなく「実際に動く作業単位」で優先順位と進行状態を管理できるようにする。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目的
|
||||
|
||||
### 3.1 目指す状態
|
||||
|
||||
- 未着手・進行中の作業を優先順で一覧できる
|
||||
- TODO は計画に紐づくものと、独立したものの両方を扱える
|
||||
- 計画に紐づく TODO では、計画全体ではなく一部圃場だけを対象にできる
|
||||
- 完了時に、必要なものは実績系アプリへ連携できる
|
||||
- 将来増える作業系アプリでも同じ TODO 基盤を使える
|
||||
|
||||
### 3.2 今回の対象
|
||||
|
||||
- Django 新規アプリ `apps/todos`
|
||||
- Next.js 画面 `frontend/src/app/todos`
|
||||
- REST API `/api/todos/`
|
||||
- 計画画面からの TODO 生成導線
|
||||
|
||||
### 3.3 今回やらないこと
|
||||
|
||||
- 期日通知、リマインダー、メール通知
|
||||
- 複数ユーザー割り当て
|
||||
- コメント、添付ファイル
|
||||
- 工数見積、実績時間記録
|
||||
- 完全な汎用ワークフローエンジン化
|
||||
|
||||
---
|
||||
|
||||
## 4. 基本方針
|
||||
|
||||
### 4.1 TODO の位置づけ
|
||||
|
||||
TODO は「作業指示」兼「実行待ちキュー」として扱う。
|
||||
|
||||
- 計画は年間またはまとまり単位の設計情報
|
||||
- TODO は実際に動く単位の作業
|
||||
- 実績は実際に完了した事実
|
||||
|
||||
### 4.2 計画との関係
|
||||
|
||||
- 1 計画に対して複数 TODO を紐づけられる
|
||||
- 1 TODO は複数計画を参照できる
|
||||
- ただし TODO の実際の対象圃場は TODO 側で明示管理する
|
||||
- 計画に含まれる圃場の一部だけを TODO 対象にすることを許可する
|
||||
|
||||
### 4.3 実績との関係
|
||||
|
||||
- TODO 完了時に、実績アプリを持つ作業は実績生成の入口にする
|
||||
- ただし、すべての TODO が実績アプリを持つとは限らない
|
||||
- 計画なし TODO、実績なし TODO も許容する
|
||||
|
||||
### 4.4 圃場グループの扱い
|
||||
|
||||
圃場グループは独立モデル化しない。
|
||||
既存の `Field.group_name` を参照用の属性として扱うにとどめ、TODO の正式な対象管理は圃場単位で保持する。
|
||||
|
||||
理由:
|
||||
|
||||
- 現状のデータモデルに独立したグループモデルが存在しない
|
||||
- TODO 完了後に履歴の再現性を保つには、最終的に対象圃場を確定保持した方が安全
|
||||
|
||||
---
|
||||
|
||||
## 5. 機能スコープ
|
||||
|
||||
### 5.1 IN
|
||||
|
||||
- TODO の作成、編集、削除
|
||||
- ステータス管理
|
||||
- 優先順位管理
|
||||
- 圃場単位の対象紐づけ
|
||||
- 作物、品種の補助的な分類紐づけ
|
||||
- 計画との紐づけ
|
||||
- 計画画面から TODO を生成
|
||||
- 完了済み、キャンセル済みの表示切り替え
|
||||
- 期日の強調表示
|
||||
- 並び替え API
|
||||
|
||||
### 5.2 OUT
|
||||
|
||||
- 通知
|
||||
- 担当者管理
|
||||
- 承認フロー
|
||||
- 複数段階ステータス
|
||||
- 実績アプリ未実装領域の詳細実績入力 UI
|
||||
|
||||
---
|
||||
|
||||
## 6. 用語整理
|
||||
|
||||
| 用語 | 意味 |
|
||||
|---|---|
|
||||
| TODO | 実際に着手・進行・完了する作業単位 |
|
||||
| 計画リンク | TODO が参照する施肥計画、田植え計画など |
|
||||
| 対象圃場 | その TODO で実際に作業対象となる圃場 |
|
||||
| 実績連携 | TODO 完了時に各実績アプリへ情報を渡すこと |
|
||||
|
||||
---
|
||||
|
||||
## 7. データモデル方針
|
||||
|
||||
### 7.1 Todo
|
||||
|
||||
TODO 本体。
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | bigint | ✓ | PK |
|
||||
| year | integer | ✓ | 年度 |
|
||||
| title | varchar(200) | ✓ | タイトル |
|
||||
| description | text | | 説明 |
|
||||
| status | enum | ✓ | `todo / doing / done / canceled` |
|
||||
| priority | integer | ✓ | 小さいほど上位 |
|
||||
| due_date | date | | 期日 |
|
||||
| work_type | enum | ✓ | 作業種別 |
|
||||
| should_link_record | boolean | ✓ | 完了時に実績連携導線を有効にするか |
|
||||
| completed_at | datetime | | 完了日時 |
|
||||
| canceled_at | datetime | | キャンセル日時 |
|
||||
| created_at | datetime | ✓ | |
|
||||
| updated_at | datetime | ✓ | |
|
||||
|
||||
### 7.1.1 ステータス
|
||||
|
||||
- `todo`: 未着手
|
||||
- `doing`: 進行中
|
||||
- `done`: 完了
|
||||
- `canceled`: キャンセル
|
||||
|
||||
### 7.1.2 並び順
|
||||
|
||||
- 基本は FILO とする
|
||||
- 新規作成時は最上位へ入る
|
||||
- `priority` は 1000 刻みの整数で保存する
|
||||
- 初回作成時は最上位 TODO の `priority - 1000` を新規 TODO に割り当てる
|
||||
- 一覧では `priority` 昇順で表示する
|
||||
- ユーザーが並び替えた後は、表示順に 1000, 2000, 3000... と振り直して保存する
|
||||
- 既存レコードの一括インクリメントや小数 priority は採用しない
|
||||
- 完了、キャンセル済みも `priority` は保持する
|
||||
- 一覧のデフォルト表示は `todo / doing` のみを `priority` 昇順で表示する
|
||||
|
||||
補足:
|
||||
|
||||
- 1000 刻みは API の中間挿入余地ではなく、再採番時の可読性のために採用する
|
||||
- 並び順変更は常に表示対象全体を受け取って再採番する前提とする
|
||||
|
||||
### 7.1.3 作業種別
|
||||
|
||||
作業種別は「計画に対応するもの」と「計画に対応しないもの」の両方を含める。
|
||||
|
||||
初期案:
|
||||
|
||||
- `general`: 一般
|
||||
- `fertilization`: 施肥
|
||||
- `rice_transplant`: 田植え
|
||||
- `delivery`: 運搬
|
||||
- `levee_work`: 畔塗
|
||||
- `pesticide`: 防除
|
||||
- `other_recorded`: 計画非紐づき実績系
|
||||
|
||||
補足:
|
||||
|
||||
- 実装時点で将来の全計画種別を確定できない場合は、MVP では現行アプリに対応する種別を先行定義する
|
||||
- `general` はどれにも当てはまらない作業用に必須
|
||||
|
||||
### 7.2 TodoTargetField
|
||||
|
||||
TODO が実際に対象とする圃場。
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | bigint | ✓ | PK |
|
||||
| todo | FK(Todo) | ✓ | CASCADE |
|
||||
| field | FK(fields.Field) | ✓ | PROTECT |
|
||||
| field_name_snapshot | varchar(100) | ✓ | 保存時点の圃場名 |
|
||||
| group_name_snapshot | varchar(50) | | 保存時点の group_name |
|
||||
| created_at | datetime | ✓ | |
|
||||
|
||||
- `unique_together = ['todo', 'field']`
|
||||
|
||||
方針:
|
||||
|
||||
- TODO の対象管理は最終的に圃場単位で保持する
|
||||
- グループ、作物、品種から一括選択する UI は許可する
|
||||
- ただし保存時は対象圃場へ展開して保持する
|
||||
|
||||
### 7.3 TodoCrop / TodoVariety
|
||||
|
||||
TODO の分類補助用。
|
||||
|
||||
#### TodoCrop
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | bigint | ✓ | PK |
|
||||
| todo | FK(Todo) | ✓ | CASCADE |
|
||||
| crop | FK(plans.Crop) | ✓ | PROTECT |
|
||||
| created_at | datetime | ✓ | |
|
||||
|
||||
- `unique_together = ['todo', 'crop']`
|
||||
|
||||
#### TodoVariety
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | bigint | ✓ | PK |
|
||||
| todo | FK(Todo) | ✓ | CASCADE |
|
||||
| variety | FK(plans.Variety) | ✓ | PROTECT |
|
||||
| created_at | datetime | ✓ | |
|
||||
|
||||
- `unique_together = ['todo', 'variety']`
|
||||
|
||||
注意:
|
||||
|
||||
- 対象圃場の実体は `TodoTargetField` を正とする
|
||||
- `Crop` や `Variety` だけ紐づいていて圃場が 0 件の TODO は許可する
|
||||
- これにより、圃場未確定の準備作業も登録できる
|
||||
|
||||
### 7.4 TodoPlanLink
|
||||
|
||||
TODO と既存計画との紐づけ。
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | bigint | ✓ | PK |
|
||||
| todo | FK(Todo) | ✓ | CASCADE |
|
||||
| plan_type | enum | ✓ | 計画種別 |
|
||||
| fertilization_plan | FK | | 施肥計画 |
|
||||
| rice_transplant_plan | FK | | 田植え計画 |
|
||||
| delivery_plan | FK | | 運搬計画 |
|
||||
| created_at | datetime | ✓ | |
|
||||
|
||||
方針:
|
||||
|
||||
- 1 行に 1 種別のリンクだけを保持する
|
||||
- `plan_type` に応じて対応する FK だけを埋める
|
||||
- MVP は汎用 `GenericForeignKey` を使わず、明示 FK を優先する
|
||||
- 理由は API と serializer を単純に保ちやすいため
|
||||
|
||||
初期対象:
|
||||
|
||||
- 施肥計画 `FertilizationPlan`
|
||||
- 田植え計画 `RiceTransplantPlan`
|
||||
- 運搬計画 `DeliveryPlan`
|
||||
- 畔塗 `levee_work` は MVP では「計画リンクなしで持てる work_type」として扱う
|
||||
- 将来、畔塗に計画モデルが導入された時点で `TodoPlanLink` に追加する
|
||||
|
||||
補足:
|
||||
|
||||
- 作付け計画 `Plan` は「年内の計画情報」であり、TODO 生成元としては必須ではない
|
||||
- 当面は Issue 回答に合わせ、`作付け計画以外のすべての計画` を TODO の対象候補とする
|
||||
|
||||
### 7.5 TodoCompletionLink
|
||||
|
||||
完了時の実績連携先を記録する索引。
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | bigint | ✓ | PK |
|
||||
| todo | FK(Todo) | ✓ | TODO |
|
||||
| record_type | enum | ✓ | 実績種別 |
|
||||
| work_record | FK(workrecords.WorkRecord) | | 共通索引 |
|
||||
| spreading_session | FK(fertilizer.SpreadingSession) | | 施肥実績 |
|
||||
| rice_transplant_record_id | 将来 | | 田植え実績 |
|
||||
| created_at | datetime | ✓ | |
|
||||
|
||||
方針:
|
||||
|
||||
- 完了時に何へ連携したかを TODO 側から追えるようにする
|
||||
- `todo` は OneToOne に固定せず FK とする
|
||||
- 理由は 1 TODO から複数実績へ分割される可能性を残すため
|
||||
- 実績アプリが未実装の種別は空でよい
|
||||
- 将来の田植え実績導入時に拡張できる形にする
|
||||
|
||||
---
|
||||
|
||||
## 8. API 仕様案
|
||||
|
||||
### 8.1 一覧
|
||||
|
||||
- `GET /api/todos/`
|
||||
|
||||
主な query:
|
||||
|
||||
- `status=todo,doing`
|
||||
- `include_closed=true|false`
|
||||
- `work_type=...`
|
||||
- `due=overdue|today|upcoming`
|
||||
- `year=2026`
|
||||
|
||||
デフォルト:
|
||||
|
||||
- `include_closed=false`
|
||||
- `status=todo,doing`
|
||||
- `priority` 昇順
|
||||
|
||||
### 8.2 詳細取得
|
||||
|
||||
- `GET /api/todos/{id}/`
|
||||
|
||||
返却内容:
|
||||
|
||||
- TODO 本体
|
||||
- 対象圃場
|
||||
- 作物、品種
|
||||
- 計画リンク
|
||||
- 完了連携状況
|
||||
|
||||
### 8.3 作成
|
||||
|
||||
- `POST /api/todos/`
|
||||
|
||||
作成 payload 例:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "西田エリアの追肥",
|
||||
"description": "週内に先行実施",
|
||||
"status": "todo",
|
||||
"year": 2026,
|
||||
"due_date": "2026-04-12",
|
||||
"work_type": "fertilization",
|
||||
"should_link_record": true,
|
||||
"field_ids": [12, 18, 21],
|
||||
"crop_ids": [1],
|
||||
"variety_ids": [4],
|
||||
"plan_links": [
|
||||
{"plan_type": "fertilization", "plan_id": 8}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`plan_links` の吸収方針:
|
||||
|
||||
- API 入力は `plan_type + plan_id` の組で受ける
|
||||
- Serializer で `plan_type` を見て対応 FK へ変換する
|
||||
- 例:
|
||||
- `fertilization` -> `fertilization_plan_id`
|
||||
- `rice_transplant` -> `rice_transplant_plan_id`
|
||||
- `delivery` -> `delivery_plan_id`
|
||||
- DB 返却時は、フロントエンド向けに再び `plan_type + plan_id + plan_label` の形へ正規化して返す
|
||||
|
||||
### 8.4 更新
|
||||
|
||||
- `PATCH /api/todos/{id}/`
|
||||
|
||||
更新可能項目:
|
||||
|
||||
- タイトル
|
||||
- 説明
|
||||
- ステータス
|
||||
- 期日
|
||||
- 作業種別
|
||||
- 実績連携フラグ
|
||||
- 対象圃場
|
||||
- 分類
|
||||
- 計画リンク
|
||||
|
||||
### 8.5 削除
|
||||
|
||||
- `DELETE /api/todos/{id}/`
|
||||
|
||||
ルール案:
|
||||
|
||||
- 連携済み実績がある TODO は物理削除ではなく制限をかける案を優先
|
||||
- MVP ではまず `done` かつ実績連携済み TODO の削除可否を要確認とする
|
||||
|
||||
### 8.6 並び替え
|
||||
|
||||
- `PATCH /api/todos/reorder/`
|
||||
|
||||
payload 例:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"id": 31, "priority": 1000},
|
||||
{"id": 27, "priority": 2000},
|
||||
{"id": 42, "priority": 3000}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
方針:
|
||||
|
||||
- 一括更新で保存する
|
||||
- DnD が難しい場合も、矢印移動 UI から同 API を呼ぶ
|
||||
|
||||
### 8.7 計画から TODO 生成
|
||||
|
||||
- `POST /api/todos/from-plan/`
|
||||
|
||||
payload 例:
|
||||
|
||||
```json
|
||||
{
|
||||
"plan_type": "fertilization",
|
||||
"plan_id": 8,
|
||||
"title": "2026春肥の散布",
|
||||
"field_ids": [12, 18],
|
||||
"due_date": "2026-04-15",
|
||||
"should_link_record": true
|
||||
}
|
||||
```
|
||||
|
||||
生成ルール:
|
||||
|
||||
- 既存計画をリンクする
|
||||
- `field_ids` 未指定時は計画内の全圃場を初期対象にする
|
||||
- `work_type` は `plan_type` から自動補完する
|
||||
- タイトルは自動生成可能にする
|
||||
|
||||
### 8.8 完了処理
|
||||
|
||||
- `POST /api/todos/{id}/complete/`
|
||||
|
||||
方針:
|
||||
|
||||
- `status=done` にする専用入口を用意する
|
||||
- `should_link_record=true` かつ対応実績アプリがある場合、関連画面へ遷移するための情報を返す
|
||||
- MVP で自動実績作成まで行うか、完了導線のみ返すかは実装時に選べるようにする
|
||||
|
||||
---
|
||||
|
||||
## 9. UI 仕様案
|
||||
|
||||
### 9.1 一覧画面 `/todos`
|
||||
|
||||
表示内容:
|
||||
|
||||
- 未着手、進行中 TODO を優先表示
|
||||
- タイトル
|
||||
- ステータス
|
||||
- 期日
|
||||
- 作業種別
|
||||
- 対象圃場数
|
||||
- 紐づき計画
|
||||
|
||||
操作:
|
||||
|
||||
- 新規作成
|
||||
- ステータス変更
|
||||
- 並び替え
|
||||
- 完了済み、キャンセル済み表示切り替え
|
||||
- 絞り込み
|
||||
|
||||
視覚表現:
|
||||
|
||||
- 期限超過は赤系
|
||||
- 当日期限は強調
|
||||
- 進行中は目立つバッジ表示
|
||||
|
||||
### 9.2 詳細画面 `/todos/{id}`
|
||||
|
||||
表示・編集項目:
|
||||
|
||||
- タイトル
|
||||
- 説明
|
||||
- ステータス
|
||||
- 期日
|
||||
- 作業種別
|
||||
- 実績連携フラグ
|
||||
- 対象圃場
|
||||
- 分類作物、分類品種
|
||||
- 計画リンク
|
||||
|
||||
下部表示:
|
||||
|
||||
- 実績連携先
|
||||
- 完了日時
|
||||
- 更新日時
|
||||
|
||||
### 9.3 作成導線
|
||||
|
||||
MVP では少なくとも以下の 2 導線を持つ。
|
||||
|
||||
1. TODO 一覧から新規作成
|
||||
2. 計画詳細または一覧から TODO 生成
|
||||
|
||||
### 9.4 計画画面からの導線
|
||||
|
||||
対象候補:
|
||||
|
||||
- 施肥計画
|
||||
- 田植え計画
|
||||
- 運搬計画
|
||||
|
||||
ボタン例:
|
||||
|
||||
- `TODOを作成`
|
||||
- `この計画からTODO生成`
|
||||
|
||||
初期値:
|
||||
|
||||
- タイトル
|
||||
- 作業種別
|
||||
- 対象圃場候補
|
||||
- `should_link_record`
|
||||
|
||||
---
|
||||
|
||||
## 10. 実績連携の考え方
|
||||
|
||||
### 10.1 基本原則
|
||||
|
||||
- TODO は実績そのものではない
|
||||
- ただし、実績入力の起点にはなる
|
||||
- すべての TODO が実績へ行くわけではない
|
||||
|
||||
### 10.2 施肥
|
||||
|
||||
将来像:
|
||||
|
||||
1. 施肥計画を作る
|
||||
2. TODO を生成する
|
||||
3. TODO を実施する
|
||||
4. 完了時に施肥実績へつなぐ
|
||||
|
||||
考え方:
|
||||
|
||||
- 従来の `施肥計画 -> 施肥実績` に対し、間に TODO が入れるようにする
|
||||
- TODO 完了時は `SpreadingSession` 作成導線へつなぐ
|
||||
- 対象圃場は TODO の `TodoTargetField` を初期値として渡す
|
||||
|
||||
### 10.3 田植え
|
||||
|
||||
田植え実績アプリは今後実装予定であるため、今回の TODO 側では以下を前提にする。
|
||||
|
||||
- `rice_transplant` 種別の TODO を持てる
|
||||
- 完了時に将来の田植え実績へ接続できるよう索引設計を残す
|
||||
- MVP 時点では「完了済みだが実績アプリ未接続」の状態も許容する
|
||||
|
||||
### 10.4 実績アプリが無い作業
|
||||
|
||||
- `general` など、実績アプリに紐づかない TODO を許容する
|
||||
- その場合は `status=done` のみで完了とする
|
||||
|
||||
---
|
||||
|
||||
## 11. バリデーション方針
|
||||
|
||||
- `done` に遷移したら `completed_at` を自動設定する
|
||||
- `canceled` に遷移したら `canceled_at` を自動設定する
|
||||
- `done` から `todo` または `doing` への差し戻しは MVP では許可する
|
||||
- 差し戻し時も `completed_at` はクリアせず履歴値として保持する
|
||||
- `plan_links` に紐づく計画の年度と TODO の利用年度が必要なら将来追加する
|
||||
- `field_ids` が計画外圃場を含む場合は、`plan_links` が 1 件以上ある場合のみエラーにする
|
||||
- 複数 `plan_links` がある場合は、それぞれの計画に対して対象圃場整合性を検証する
|
||||
- `should_link_record=true` でも、対応実績アプリが無い場合は保存を許可する
|
||||
- `TodoTargetField.field` は `PROTECT` を採用する
|
||||
- 理由は、過去 TODO の対象圃場履歴を崩さないことを優先するため
|
||||
|
||||
### 11.1 レビュー反映済み判断
|
||||
|
||||
- `done -> todo/doing` の差し戻しは許可する
|
||||
- 差し戻し後も `completed_at` は監査用の履歴値として保持する
|
||||
- `TodoTargetField.field` は運用上の削除容易性より履歴保全を優先し、`PROTECT` を維持する
|
||||
- 実績連携フラグ名は `should_link_record` で確定する
|
||||
|
||||
---
|
||||
|
||||
## 12. 実装方針
|
||||
|
||||
### 12.1 Backend
|
||||
|
||||
- `apps/todos/models.py`
|
||||
- `apps/todos/admin.py`
|
||||
- `apps/todos/serializers.py`
|
||||
- `apps/todos/views.py`
|
||||
- `apps/todos/urls.py`
|
||||
- `apps/todos/migrations/`
|
||||
- `keinasystem/settings.py` へ app 追加
|
||||
- `keinasystem/urls.py` へ `/api/todos/` 追加
|
||||
|
||||
### 12.2 Frontend
|
||||
|
||||
- `frontend/src/app/todos/page.tsx`
|
||||
- `frontend/src/app/todos/[id]/page.tsx`
|
||||
- `frontend/src/app/todos/new/page.tsx`
|
||||
- 必要に応じて `_components` 配下に分離
|
||||
- ナビゲーションへ TODO 追加
|
||||
|
||||
### 12.3 実装順
|
||||
|
||||
1. モデル、admin、serializer、migration の作成
|
||||
2. TODO 一覧と CRUD API
|
||||
3. TODO 一覧と詳細 UI
|
||||
4. 並び替え API と UI
|
||||
5. 計画から TODO 生成
|
||||
6. 完了時の実績連携導線
|
||||
7. `makemigrations` と `migrate` を実行
|
||||
|
||||
---
|
||||
|
||||
## 13. テスト観点
|
||||
|
||||
- TODO を新規作成できる
|
||||
- 対象圃場を複数紐づけできる
|
||||
- 計画の一部圃場だけを対象にできる
|
||||
- 完了済み、キャンセル済みの表示切り替えができる
|
||||
- 並び替え後に順番が保持される
|
||||
- 計画画面から TODO を生成できる
|
||||
- 実績アプリ未接続の TODO でも完了できる
|
||||
- 実績連携済み TODO の挙動が壊れない
|
||||
|
||||
---
|
||||
|
||||
## 14. 未確定事項
|
||||
|
||||
### 14.1 work_type enum の最終一覧
|
||||
|
||||
今回の回答で方針は見えたが、初回実装でどこまで列挙するかは確定していない。
|
||||
|
||||
候補:
|
||||
|
||||
- 一般
|
||||
- 施肥
|
||||
- 田植え
|
||||
- 運搬
|
||||
- 畔塗
|
||||
- 防除
|
||||
- 計画非紐づき実績系
|
||||
|
||||
### 14.2 完了時の自動生成レベル
|
||||
|
||||
MVP で以下のどこまでやるかは実装前に決める。
|
||||
|
||||
- A. 完了ステータス変更のみ
|
||||
- B. 実績入力画面への導線生成
|
||||
- C. TODO 情報を使った実績レコード仮生成
|
||||
|
||||
### 14.3 削除ポリシー
|
||||
|
||||
実績連携後の TODO をどう扱うか。
|
||||
|
||||
案:
|
||||
|
||||
- 物理削除禁止
|
||||
- 論理削除
|
||||
- 参照整合性チェック付き物理削除
|
||||
|
||||
### 14.4 work_type と計画種別の追加ルール
|
||||
|
||||
MVP では以下を前提とする。
|
||||
|
||||
- work_type は先に定義する
|
||||
- plan_link は実在する計画モデルだけを持つ
|
||||
- work_type が存在しても、対応する計画 FK が未実装のことはあり得る
|
||||
|
||||
将来、新しい計画機能が増えたときは以下を同時に更新する。
|
||||
|
||||
- `Todo.work_type` choices
|
||||
- `TodoPlanLink.plan_type`
|
||||
- 対応 FK
|
||||
- 計画から TODO 生成 API
|
||||
|
||||
---
|
||||
|
||||
## 15. 提案する MVP 決定案
|
||||
|
||||
実装着手しやすさを優先し、MVP では以下を採用することを提案する。
|
||||
|
||||
- TODO は `year` を持つ
|
||||
- 対象管理は `TodoTargetField` を正とする
|
||||
- `work_type` は `general / fertilization / rice_transplant / delivery / levee_work / pesticide` を初期採用する
|
||||
- 計画リンクは明示 FK 方式で開始する
|
||||
- 実績連携フラグ名は `should_link_record` を採用する
|
||||
- 完了時はまず「実績入力画面への導線生成」を採用し、自動実績作成は後続検討とする
|
||||
- 並び替えは API 先行、UI は DnD 優先、難しければ矢印移動で代替する
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user