Compare commits
48 Commits
9f96d1f820
...
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 |
@@ -67,11 +67,22 @@
|
|||||||
"Bash(git diff:*)",
|
"Bash(git diff:*)",
|
||||||
"mcp__serena__find_symbol",
|
"mcp__serena__find_symbol",
|
||||||
"mcp__serena__get_symbols_overview",
|
"mcp__serena__get_symbols_overview",
|
||||||
"Bash(git status:*)"
|
"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": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\akira\\AppData\\Local\\Temp",
|
"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
|
nul
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.mcp.json
|
||||||
|
.codex
|
||||||
|
|||||||
@@ -133,3 +133,17 @@ symbol_info_budget:
|
|||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
# 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.
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
read_only_memory_patterns: []
|
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: {}
|
||||||
|
|||||||
571
CLAUDE.md
571
CLAUDE.md
@@ -1,540 +1,127 @@
|
|||||||
# Keina System - Claude 向けガイド
|
# Keina System - Claude 向けガイド
|
||||||
|
|
||||||
> **最終更新**: 2026-03-16
|
## プロジェクト概要
|
||||||
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
|
|
||||||
|
|
||||||
## 📌 このファイルの目的
|
|
||||||
|
|
||||||
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
|
|
||||||
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
|
|
||||||
|
|
||||||
## ⚠️ Claude への重要な指示
|
|
||||||
|
|
||||||
**このファイルは、セッションごとに必ず最初に読んでください。**
|
|
||||||
|
|
||||||
さらに、以下のルールを厳守してください:
|
|
||||||
|
|
||||||
### 📝 更新義務
|
|
||||||
|
|
||||||
**ドキュメントドリブンの徹底**
|
|
||||||
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
|
|
||||||
|
|
||||||
**機能追加・変更時は、必ずこのファイルを更新すること。**
|
|
||||||
|
|
||||||
- ✅ 新機能実装時 → 「実装状況」セクションを更新
|
|
||||||
- ✅ データモデル変更時 → 「データモデル概要」を更新
|
|
||||||
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
|
|
||||||
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
|
|
||||||
- ✅ 問題解決時 → 「トラブルシューティング」に追加
|
|
||||||
- ✅ 更新時は必ず「更新履歴」セクションに記録
|
|
||||||
|
|
||||||
|
|
||||||
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 プロジェクト概要(30秒で理解)
|
|
||||||
|
|
||||||
**何を作っているか:**
|
|
||||||
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
||||||
|
ユーザーは65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理。
|
||||||
|
|
||||||
**ユーザー:**
|
**技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
|
||||||
65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理
|
|
||||||
|
|
||||||
**技術スタック:**
|
**開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||||
- 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/
|
keinasystem_t02/
|
||||||
├── CLAUDE.md # このファイル(Claude向けガイド)
|
├── CLAUDE.md # このファイル
|
||||||
├── .cursor/
|
├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
|
||||||
│ └── rules/
|
├── document/ # 設計書・マスタードキュメント
|
||||||
│ └── 30_Cursorガイド.md # Cursor専用ガイド
|
|
||||||
├── document/ # 詳細設計書(人間向け)
|
|
||||||
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
|
||||||
│ ├── 01_プロダクトビジョン.md
|
|
||||||
│ ├── 02_ユーザーストーリー.md
|
|
||||||
│ ├── 03_データ仕様書.md
|
|
||||||
│ ├── 04_画面設計書.md
|
|
||||||
│ └── 05_実装優先順位.md
|
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── keinasystem/ # Django設定
|
│ ├── keinasystem/ # Django設定 (settings.py, urls.py)
|
||||||
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
|
|
||||||
│ │ └── urls.py # ルートURL設定
|
|
||||||
│ └── apps/
|
│ └── apps/
|
||||||
│ ├── fields/ # 圃場管理アプリ
|
│ ├── fields/ # 圃場管理(Field, OfficialKyosaiField, OfficialChusankanField)
|
||||||
│ │ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
|
│ ├── plans/ # 作付け計画(Plan, Crop, Variety)
|
||||||
│ │ ├── views.py # インポート機能、CRUD API
|
│ ├── weather/ # 気象データ(WeatherRecord)
|
||||||
│ │ └── urls.py
|
│ ├── reports/ # 申請書PDF生成
|
||||||
│ ├── plans/ # 作付け計画アプリ
|
│ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
|
||||||
│ │ ├── models.py # Plan, Crop(+base_temp), Variety
|
│ ├── workrecords/ # 作業記録索引
|
||||||
│ │ └── views.py # 作付け計画API、集計API
|
│ └── mail/ # メールフィルタリング(Windmill連携)
|
||||||
│ ├── weather/ # 気象データアプリ
|
└── frontend/src/app/
|
||||||
│ │ ├── models.py # WeatherRecord (1日1行)
|
├── allocation/ # 作付け計画編集(メイン画面)
|
||||||
│ │ ├── views.py # sync(APIキー), records, summary, gdd, similarity
|
├── fields/ # 圃場一覧・詳細
|
||||||
│ │ ├── urls.py
|
├── fertilizer/ # 施肥計画・散布実績
|
||||||
│ │ └── management/commands/fetch_weather.py # 初回一括取得・差分取得
|
├── distribution/ # 運搬計画
|
||||||
│ └── reports/ # 申請書生成アプリ
|
├── weather/ # 気象データ
|
||||||
│ ├── views.py # PDF生成API
|
├── reports/ # 申請書DL
|
||||||
│ └── templates/ # PDF用HTMLテンプレート
|
├── import/ # データ取込
|
||||||
└── frontend/
|
├── mail/ # メール管理
|
||||||
└── src/app/
|
└── settings/ # パスワード変更
|
||||||
├── allocation/ # 作付け計画編集画面(メイン)
|
|
||||||
├── fields/ # 圃場一覧・詳細
|
|
||||||
├── reports/ # 申請書ダウンロード
|
|
||||||
├── import/ # データ取込画面
|
|
||||||
├── mail/
|
|
||||||
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
|
|
||||||
│ ├── history/ # メール処理履歴
|
|
||||||
│ └── rules/ # 送信者ルール管理
|
|
||||||
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
|
|
||||||
└── settings/
|
|
||||||
└── password/ # パスワード変更
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🗄️ データモデル概要
|
## よくある作業パターン
|
||||||
|
|
||||||
### コアエンティティ
|
|
||||||
|
|
||||||
```
|
|
||||||
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. **運搬計画機能**(旧・分配計画、本番稼働中):
|
|
||||||
- 旧 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` にモデルクラスを追加
|
1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
|
||||||
2. `python manage.py makemigrations`
|
5. Serializer → 6. ViewSet → 7. URL登録
|
||||||
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`)
|
|
||||||
|
|
||||||
### 新しいAPI エンドポイントを追加する場合
|
### 新しいAPI / 画面を追加する場合
|
||||||
|
|
||||||
1. `apps/<app_name>/views.py` にビューを追加
|
- API: `views.py` → `urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
|
||||||
2. `apps/<app_name>/urls.py` にパスを追加
|
- 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
|
||||||
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. ローディング状態、エラー状態を適切に処理
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 トラブルシューティング
|
## デプロイ・トラブルシューティング
|
||||||
|
|
||||||
### 本番デプロイコマンド(必須)
|
|
||||||
|
|
||||||
```bash
|
```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'
|
ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
|
||||||
```
|
|
||||||
|
|
||||||
**Docker Compose 構成:**
|
# 本番ヘルスチェック(9項目、curlベース)
|
||||||
- `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秒)
|
|
||||||
bash scripts/check_prod.sh claude keina1234
|
bash scripts/check_prod.sh claude keina1234
|
||||||
# → 全 9 項目 PASS が出れば本番が正常稼働中
|
|
||||||
|
|
||||||
# ステップ2(任意): Playwrightでビジュアル確認する場合のプロンプト原則
|
# 本番マイグレーション(バックエンド変更時のみ)
|
||||||
# - 「認証できなければ即中止して報告せよ」を必ず明記
|
|
||||||
# - 「スクリーンショットには今日の日付が画面内に見えること」を要求
|
|
||||||
# - 「成功の証跡(HTTP レスポンスの実テキスト)を必ず添付すること」を要求
|
|
||||||
```
|
|
||||||
|
|
||||||
**本番バックエンドのマイグレーション適用(バックエンド変更時のみ):**
|
|
||||||
```bash
|
|
||||||
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
||||||
sudo -u keinasystem docker compose build backend && \
|
sudo -u keinasystem docker compose build backend && \
|
||||||
sudo -u keinasystem docker compose up -d && \
|
sudo -u keinasystem docker compose up -d && sleep 5 && \
|
||||||
sleep 5 && \
|
|
||||||
sudo -u keinasystem docker compose exec backend python manage.py migrate'
|
sudo -u keinasystem docker compose exec backend python manage.py migrate'
|
||||||
```
|
```
|
||||||
|
|
||||||
### マイグレーションエラー
|
- **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
|
||||||
|
- **CORS**: `settings.py` の `CORS_ALLOWED_ORIGINS`(localhost:3000 許可済み)
|
||||||
```bash
|
- **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
|
||||||
# マイグレーションをリセット(開発環境のみ!)
|
|
||||||
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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 詳細情報へのリンク
|
## マスタードキュメント(機能別リファレンス)
|
||||||
|
|
||||||
### マスタードキュメント(機能別の網羅的リファレンス)
|
特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
|
||||||
|
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
|
||||||
|
|
||||||
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。**
|
| 機能 | ドキュメント |
|
||||||
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が
|
|------|------------|
|
||||||
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
|
| 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
|
||||||
|
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
|
||||||
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
|
||||||
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
|
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
|
||||||
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
|
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
|
||||||
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md`
|
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
|
||||||
- **運搬計画機能(旧・分配計画)**: `document/14_マスタードキュメント_分配計画編.md`
|
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
|
||||||
|
| データモデル全体 | `document/03_データ仕様書.md` |
|
||||||
### 設計ドキュメント(プロジェクト横断)
|
|
||||||
|
|
||||||
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
|
|
||||||
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
|
|
||||||
- **データモデル詳細**: `document/03_データ仕様書.md`
|
|
||||||
- **画面設計**: `document/04_画面設計書.md`
|
|
||||||
- **実装手順**: `document/00_Gemini向け統合指示書.md`
|
|
||||||
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💡 新しいセッションでの推奨フロー
|
## セッション開始・終了フロー
|
||||||
|
|
||||||
|
### 開始時
|
||||||
1. この `CLAUDE.md` を読む
|
1. この `CLAUDE.md` を読む
|
||||||
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md`)
|
2. `HANDOVER.md` で前回の引き継ぎを確認する
|
||||||
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照
|
3. `TASK_CONTEXT.md` で現在の状況を把握する
|
||||||
4. 実装・修正を行う
|
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: 初版作成(ハイブリッドアプローチの方針決定)
|
|
||||||
|
|
||||||
|
|
||||||
|
### 終了時(または作業の区切りで必ず実行)
|
||||||
|
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` を参照。
|
||||||
@@ -74,6 +74,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
spread_status = serializers.SerializerMethodField()
|
spread_status = serializers.SerializerMethodField()
|
||||||
is_confirmed = serializers.BooleanField(read_only=True)
|
is_confirmed = serializers.BooleanField(read_only=True)
|
||||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||||
|
is_variety_change_plan = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationPlan
|
model = FertilizationPlan
|
||||||
@@ -94,6 +95,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
'spread_status',
|
'spread_status',
|
||||||
'is_confirmed',
|
'is_confirmed',
|
||||||
'confirmed_at',
|
'confirmed_at',
|
||||||
|
'is_variety_change_plan',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]
|
]
|
||||||
@@ -134,6 +136,9 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
return 'partial'
|
return 'partial'
|
||||||
return 'completed'
|
return 'completed'
|
||||||
|
|
||||||
|
def get_is_variety_change_plan(self, obj):
|
||||||
|
return obj.name.endswith('(品種変更移動)')
|
||||||
|
|
||||||
|
|
||||||
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ from decimal import Decimal
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Sum
|
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.materials.models import StockTransaction
|
||||||
from apps.workrecords.services import sync_spreading_work_record
|
from apps.workrecords.services import sync_spreading_work_record
|
||||||
from .models import FertilizationEntry, SpreadingSessionItem
|
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):
|
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
|
||||||
@@ -56,3 +67,130 @@ def sync_stock_uses_for_spreading_session(session):
|
|||||||
fertilization_plan=None,
|
fertilization_plan=None,
|
||||||
spreading_item=item,
|
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
|
||||||
|
|||||||
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())
|
||||||
@@ -31,7 +31,12 @@ from .serializers import (
|
|||||||
SpreadingSessionSerializer,
|
SpreadingSessionSerializer,
|
||||||
SpreadingSessionWriteSerializer,
|
SpreadingSessionWriteSerializer,
|
||||||
)
|
)
|
||||||
from .services import sync_actual_bags_for_pairs
|
from .services import (
|
||||||
|
FertilizationPlanMergeConflict,
|
||||||
|
FertilizationPlanMergeError,
|
||||||
|
merge_fertilization_plan_into,
|
||||||
|
sync_actual_bags_for_pairs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FertilizerViewSet(viewsets.ModelViewSet):
|
class FertilizerViewSet(viewsets.ModelViewSet):
|
||||||
@@ -123,6 +128,55 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@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(
|
||||||
|
{
|
||||||
|
'error': '競合する圃場・肥料があるためマージできません。',
|
||||||
|
'conflicts': exc.conflicts,
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
except FertilizationPlanMergeError as exc:
|
||||||
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
class CandidateFieldsView(APIView):
|
class CandidateFieldsView(APIView):
|
||||||
"""作付け計画から圃場候補を返す"""
|
"""作付け計画から圃場候補を返す"""
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|||||||
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)
|
||||||
|
|
||||||
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):
|
class MaterialType(models.TextChoices):
|
||||||
FERTILIZER = 'fertilizer', '肥料'
|
FERTILIZER = 'fertilizer', '肥料'
|
||||||
PESTICIDE = 'pesticide', '農薬'
|
PESTICIDE = 'pesticide', '農薬'
|
||||||
|
SEED = 'seed', '種子'
|
||||||
SEEDLING = 'seedling', '種苗'
|
SEEDLING = 'seedling', '種苗'
|
||||||
OTHER = 'other', 'その他'
|
OTHER = 'other', 'その他'
|
||||||
|
|
||||||
|
|||||||
@@ -112,11 +112,15 @@ class MaterialWriteSerializer(serializers.ModelSerializer):
|
|||||||
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
|
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
|
||||||
)
|
)
|
||||||
if (
|
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)
|
and (fertilizer_profile or pesticide_profile)
|
||||||
):
|
):
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
'種苗・その他には詳細プロファイルを設定できません。'
|
'種子・種苗・その他には詳細プロファイルを設定できません。'
|
||||||
)
|
)
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ class StockTransactionSerializer(serializers.ModelSerializer):
|
|||||||
source='get_transaction_type_display',
|
source='get_transaction_type_display',
|
||||||
read_only=True,
|
read_only=True,
|
||||||
)
|
)
|
||||||
|
is_locked = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockTransaction
|
model = StockTransaction
|
||||||
@@ -195,10 +200,15 @@ class StockTransactionSerializer(serializers.ModelSerializer):
|
|||||||
'occurred_on',
|
'occurred_on',
|
||||||
'note',
|
'note',
|
||||||
'fertilization_plan',
|
'fertilization_plan',
|
||||||
|
'spreading_item',
|
||||||
|
'is_locked',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['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):
|
class StockSummarySerializer(serializers.Serializer):
|
||||||
material_id = serializers.IntegerField()
|
material_id = serializers.IntegerField()
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
serializer_class = StockTransactionSerializer
|
serializer_class = StockTransactionSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
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):
|
def get_queryset(self):
|
||||||
queryset = StockTransaction.objects.select_related('material')
|
queryset = StockTransaction.objects.select_related('material')
|
||||||
@@ -77,6 +77,33 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return queryset
|
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):
|
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):
|
class Crop(models.Model):
|
||||||
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
||||||
base_temp = models.FloatField(default=0.0, 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:
|
class Meta:
|
||||||
verbose_name = "作物マスタ"
|
verbose_name = "作物マスタ"
|
||||||
@@ -17,6 +23,21 @@ class Crop(models.Model):
|
|||||||
class Variety(models.Model):
|
class Variety(models.Model):
|
||||||
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
||||||
name = models.CharField(max_length=100, 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:
|
class Meta:
|
||||||
verbose_name = "品種マスタ"
|
verbose_name = "品種マスタ"
|
||||||
@@ -42,3 +63,116 @@ class Plan(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.field.name} - {self.year} - {self.crop.name}"
|
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 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 Crop, Variety, Plan
|
||||||
|
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||||
|
from .services import NO_CHANGE, update_plan_with_variety_tracking
|
||||||
|
|
||||||
|
|
||||||
class VarietySerializer(serializers.ModelSerializer):
|
class VarietySerializer(serializers.ModelSerializer):
|
||||||
|
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Variety
|
model = Variety
|
||||||
fields = '__all__'
|
fields = [
|
||||||
|
'id',
|
||||||
|
'crop',
|
||||||
|
'name',
|
||||||
|
'default_seedling_boxes_per_tan',
|
||||||
|
'seed_material',
|
||||||
|
'seed_material_name',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CropSerializer(serializers.ModelSerializer):
|
class CropSerializer(serializers.ModelSerializer):
|
||||||
@@ -20,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||||
field_name = serializers.ReadOnlyField(source='field.name')
|
field_name = serializers.ReadOnlyField(source='field.name')
|
||||||
|
variety_change_count = serializers.SerializerMethodField()
|
||||||
|
latest_variety_change = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
@@ -30,7 +47,215 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
return Plan.objects.create(**validated_data)
|
return Plan.objects.create(**validated_data)
|
||||||
|
|
||||||
def update(self, instance, 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():
|
for attr, value in validated_data.items():
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
instance.save()
|
instance.save()
|
||||||
|
if entries_data is not None:
|
||||||
|
instance.entries.all().delete()
|
||||||
|
self._save_entries(instance, entries_data)
|
||||||
return instance
|
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 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 = DefaultRouter()
|
||||||
router.register(r'crops', views.CropViewSet)
|
router.register(r'crops', views.CropViewSet)
|
||||||
router.register(r'varieties', views.VarietyViewSet)
|
router.register(r'varieties', views.VarietyViewSet)
|
||||||
|
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
|
||||||
router.register(r'', views.PlanViewSet)
|
router.register(r'', views.PlanViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ from rest_framework import viewsets, status
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from .models import Crop, Variety, Plan
|
from .models import Crop, Variety, Plan, RiceTransplantPlan
|
||||||
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
|
from .serializers import (
|
||||||
|
CropSerializer,
|
||||||
|
VarietySerializer,
|
||||||
|
PlanSerializer,
|
||||||
|
RiceTransplantPlanSerializer,
|
||||||
|
RiceTransplantPlanWriteSerializer,
|
||||||
|
)
|
||||||
|
from .services import update_plan_with_variety_tracking
|
||||||
from apps.fields.models import Field
|
from apps.fields.models import Field
|
||||||
|
|
||||||
|
|
||||||
@@ -13,16 +20,20 @@ class CropViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class VarietyViewSet(viewsets.ModelViewSet):
|
class VarietyViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Variety.objects.all()
|
queryset = Variety.objects.select_related('seed_material', 'crop').all()
|
||||||
serializer_class = VarietySerializer
|
serializer_class = VarietySerializer
|
||||||
|
|
||||||
|
|
||||||
class PlanViewSet(viewsets.ModelViewSet):
|
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
|
serializer_class = PlanSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Plan.objects.all()
|
queryset = self.queryset
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=year)
|
queryset = queryset.filter(year=year)
|
||||||
@@ -114,19 +125,78 @@ class PlanViewSet(viewsets.ModelViewSet):
|
|||||||
updated = 0
|
updated = 0
|
||||||
created = 0
|
created = 0
|
||||||
for field_id in field_ids:
|
for field_id in field_ids:
|
||||||
plan, was_created = Plan.objects.update_or_create(
|
plan = Plan.objects.filter(field_id=field_id, year=year).first()
|
||||||
field_id=field_id,
|
if plan is None:
|
||||||
year=year,
|
Plan.objects.create(
|
||||||
defaults={'crop': crop, 'variety': variety}
|
field_id=field_id,
|
||||||
)
|
year=year,
|
||||||
if was_created:
|
crop=crop,
|
||||||
|
variety=variety,
|
||||||
|
)
|
||||||
created += 1
|
created += 1
|
||||||
else:
|
continue
|
||||||
updated += 1
|
|
||||||
|
update_plan_with_variety_tracking(
|
||||||
|
plan,
|
||||||
|
crop=crop,
|
||||||
|
variety=variety,
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def get_crops_with_varieties(self, request):
|
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)
|
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)
|
||||||
|
|||||||
@@ -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='畔塗記録',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ class WorkRecord(models.Model):
|
|||||||
class WorkType(models.TextChoices):
|
class WorkType(models.TextChoices):
|
||||||
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
||||||
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
||||||
|
LEVEE_WORK = 'levee_work', '畔塗'
|
||||||
|
|
||||||
work_date = models.DateField(verbose_name='作業日')
|
work_date = models.DateField(verbose_name='作業日')
|
||||||
work_type = models.CharField(
|
work_type = models.CharField(
|
||||||
@@ -31,6 +32,14 @@ class WorkRecord(models.Model):
|
|||||||
related_name='work_record',
|
related_name='work_record',
|
||||||
verbose_name='散布実績',
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -41,4 +50,3 @@ class WorkRecord(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.work_date} {self.get_work_type_display()}'
|
return f'{self.work_date} {self.get_work_type_display()}'
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class WorkRecordSerializer(serializers.ModelSerializer):
|
|||||||
'delivery_plan_id',
|
'delivery_plan_id',
|
||||||
'delivery_plan_name',
|
'delivery_plan_name',
|
||||||
'spreading_session',
|
'spreading_session',
|
||||||
|
'levee_work_session',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]
|
]
|
||||||
@@ -35,4 +36,3 @@ class WorkRecordSerializer(serializers.ModelSerializer):
|
|||||||
if obj.delivery_trip_id:
|
if obj.delivery_trip_id:
|
||||||
return obj.delivery_trip.delivery_plan.name
|
return obj.delivery_trip.delivery_plan.name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,18 @@ def sync_spreading_work_record(session):
|
|||||||
'delivery_trip': None,
|
'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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'delivery_trip',
|
'delivery_trip',
|
||||||
'delivery_trip__delivery_plan',
|
'delivery_trip__delivery_plan',
|
||||||
'spreading_session',
|
'spreading_session',
|
||||||
|
'levee_work_session',
|
||||||
)
|
)
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=year)
|
queryset = queryset.filter(year=year)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||||||
'apps.fertilizer',
|
'apps.fertilizer',
|
||||||
'apps.materials',
|
'apps.materials',
|
||||||
'apps.workrecords',
|
'apps.workrecords',
|
||||||
|
'apps.levee_work',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -60,4 +60,5 @@ urlpatterns = [
|
|||||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
path('api/materials/', include('apps.materials.urls')),
|
path('api/materials/', include('apps.materials.urls')),
|
||||||
path('api/workrecords/', include('apps.workrecords.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-01
|
||||||
> **最終更新**: 2026-03-15
|
> **最終更新**: 2026-03-17
|
||||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布確定)
|
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布実績記録)
|
||||||
> **実装状況**: 実装完了・本番稼働中
|
> **実装状況**: 実装完了・本番稼働中(散布実績連携追加)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 概要
|
## 概要
|
||||||
|
|
||||||
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
||||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力に加えて在庫引当と散布確定まで一連で扱う。
|
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力・在庫引当・散布実績記録・作業記録索引生成まで一連で扱う。
|
||||||
|
|
||||||
### 機能スコープ(IN / OUT)
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
@@ -18,11 +18,14 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 肥料マスタ管理 | 肥料購入管理 |
|
| 肥料マスタ管理 | 肥料購入管理 |
|
||||||
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照) |
|
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照) |
|
||||||
| 3方式の自動計算 | 個別作業日報の詳細管理 |
|
| 3方式の自動計算 | 運搬便ごとの散布充当追跡 |
|
||||||
| 作付け計画からの圃場自動取得 | |
|
| 作付け計画からの圃場自動取得 | 相手先ごとのPDF様式実装 |
|
||||||
| PDF出力(圃場×肥料マトリクス表) | |
|
| PDF出力(圃場×肥料マトリクス表) | 残肥返却・再入庫管理 |
|
||||||
| 在庫引当・引当解除 | |
|
| 在庫引当・引当解除 | |
|
||||||
| 散布確定(計画値確認 + 実績入力) | |
|
| 散布実績記録(日付単位・運搬済み肥料ベース) | |
|
||||||
|
| 作業記録索引(WorkRecord)自動生成 | |
|
||||||
|
| 在庫USE連携(散布実績保存時) | |
|
||||||
|
| 施肥計画進捗表示(未散布/一部散布/完了/計画超過) | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,11 +52,20 @@
|
|||||||
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
||||||
| year | int | required | 年度 |
|
| year | int | required | 年度 |
|
||||||
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
||||||
| is_confirmed | bool | default=False | 散布確定済みフラグ |
|
| is_confirmed | bool | default=False | ~~散布確定済みフラグ~~(deprecated: 新UIでは使用しない) |
|
||||||
| confirmed_at | datetime | nullable | 散布確定日時 |
|
| confirmed_at | datetime | nullable | ~~散布確定日時~~(deprecated: 新UIでは使用しない) |
|
||||||
| created_at | datetime | auto | |
|
| created_at | datetime | auto | |
|
||||||
| updated_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(施肥エントリ:圃場×肥料×袋数)
|
### FertilizationEntry(施肥エントリ:圃場×肥料×袋数)
|
||||||
|
|
||||||
| フィールド | 型 | 制約 | 説明 |
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
@@ -62,11 +74,60 @@
|
|||||||
| plan | FK(FertilizationPlan) | CASCADE | |
|
| plan | FK(FertilizationPlan) | CASCADE | |
|
||||||
| field | FK(fields.Field) | CASCADE | |
|
| field | FK(fields.Field) | CASCADE | |
|
||||||
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
|
| 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']`
|
- `unique_together = ['plan', 'field', 'fertilizer']`
|
||||||
- 順序: `field__display_order, field__id, fertilizer__name`
|
- 順序: `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 エンドポイント
|
## API エンドポイント
|
||||||
@@ -106,8 +167,8 @@
|
|||||||
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
||||||
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
||||||
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
||||||
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定(引当 → 使用へ変換) |
|
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | ~~散布確定~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||||
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | 散布確定取消(使用 → 引当に戻す) |
|
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | ~~散布確定取消~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||||
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
||||||
|
|
||||||
一覧レスポンス例(FertilizationPlan):
|
一覧レスポンス例(FertilizationPlan):
|
||||||
@@ -154,18 +215,60 @@ POST/PUT リクエスト例:
|
|||||||
|
|
||||||
PUT 時は entries が全置換(削除→再作成)。entries を省略した場合は既存を維持。
|
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
|
```json
|
||||||
{
|
{
|
||||||
"entries": [
|
"year": 2026,
|
||||||
{"field_id": 5, "fertilizer_id": 1, "actual_bags": 2.4},
|
"date": "2026-04-15",
|
||||||
{"field_id": 6, "fertilizer_id": 1, "actual_bags": 0}
|
"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`)
|
### 施肥計画一覧(`/fertilizer`)
|
||||||
|
|
||||||
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
||||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布確定状態
|
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布進捗
|
||||||
- 操作ボタン: PDF出力・編集・削除・散布確定
|
- 操作ボタン: PDF出力・編集・削除
|
||||||
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
||||||
|
- 進捗表示: `未散布` / `一部散布 3.5 / 8.0袋` / `散布完了` / `計画超過`
|
||||||
|
- 計画値と実績値を並べて表示
|
||||||
|
|
||||||
### 肥料マスタ(`/fertilizer/masters`)
|
### 肥料マスタ(`/fertilizer/masters`)
|
||||||
|
|
||||||
@@ -316,11 +421,11 @@ GET /api/plans/crops/
|
|||||||
6. **手動調整**: マトリクス表のセルを直接編集
|
6. **手動調整**: マトリクス表のセルを直接編集
|
||||||
7. **保存**: 「保存」ボタンで entries を一括送信
|
7. **保存**: 「保存」ボタンで entries を一括送信
|
||||||
|
|
||||||
#### 在庫連携・確定状態
|
#### 在庫連携・実績表示
|
||||||
|
|
||||||
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
|
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
|
||||||
- 散布確定済みの計画は情報バナーを表示し、編集操作をロック
|
- マトリクス表で `bags`(計画値)を編集可能、`actual_bags`(実績値)は読み取り専用で参照表示
|
||||||
- 「確定取消」で使用実績を引当に戻し、再編集できる
|
- 散布実績画面(`/fertilizer/spreading`)へのリンクを表示
|
||||||
|
|
||||||
#### マトリクスの表示仕様
|
#### マトリクスの表示仕様
|
||||||
|
|
||||||
@@ -329,16 +434,24 @@ GET /api/plans/crops/
|
|||||||
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
||||||
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
||||||
|
|
||||||
### 散布確定モーダル(`/fertilizer` 一覧から起動)
|
### 散布実績画面(`/fertilizer/spreading`)
|
||||||
|
|
||||||
- 全画面遷移ではなくモーダル表示
|
- 年度セレクタ(localStorage `fertilizerYear` と連動)
|
||||||
- 施肥計画編集と同じ視線移動になるよう、`圃場 = 行`、`肥料 = 列` のマトリクス表を採用
|
- 散布日入力(DateField)
|
||||||
- 画面上部に計画名・年度・作物/品種・対象圃場数・肥料数を表示
|
- セッション名入力(必須)
|
||||||
- 各セルは「薄いグレーの計画値」+「実績入力欄」の2段表示
|
- 運搬済み・未散布候補一覧を表示(`candidates` APIから取得)
|
||||||
- 行末に圃場ごとの実績合計、表フッターに肥料別合計と総合計を表示
|
- 圃場単位で選択可能(全部または一部)
|
||||||
- `0` を入力したセルは未散布として扱い、対応する引当を解除する
|
- 実績袋数の編集
|
||||||
|
- 差異がある場合はインライン警告表示
|
||||||
|
- 保存時に在庫USE連携・WorkRecord自動生成・FertilizationEntry.actual_bags再集計を実行
|
||||||
|
|
||||||
#### State 構成
|
### 作業記録画面(`/workrecords`)
|
||||||
|
|
||||||
|
- 年度セレクタ
|
||||||
|
- 日付・作業種別・タイトルの一覧表示
|
||||||
|
- 元データ(運搬回 / 散布セッション)への遷移リンク
|
||||||
|
|
||||||
|
#### State 構成(施肥計画編集画面)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 基本情報
|
// 基本情報
|
||||||
@@ -374,13 +487,15 @@ backend/apps/fertilizer/
|
|||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── admin.py # Django admin 登録
|
├── admin.py # Django admin 登録
|
||||||
├── apps.py # FertilizerConfig
|
├── apps.py # FertilizerConfig
|
||||||
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry
|
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry, SpreadingSession, SpreadingSessionItem
|
||||||
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
|
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer, SpreadingSessionSerializer
|
||||||
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
|
├── services.py # actual_bags再集計、WorkRecord自動生成、在庫USE連携
|
||||||
├── urls.py # DefaultRouter + candidate_fields/ + calculate/
|
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, SpreadingSessionViewSet, CandidateFieldsView, CalculateView
|
||||||
|
├── urls.py # DefaultRouter + candidate_fields/ + calculate/ + spreading/
|
||||||
├── migrations/
|
├── migrations/
|
||||||
│ ├── 0001_initial.py
|
│ ├── 0001_initial.py
|
||||||
│ └── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
│ ├── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
||||||
|
│ └── ... # SpreadingSession, SpreadingSessionItem, actual_bags 追加
|
||||||
└── templates/
|
└── templates/
|
||||||
└── fertilizer/
|
└── fertilizer/
|
||||||
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
||||||
@@ -398,25 +513,131 @@ frontend/src/app/fertilizer/
|
|||||||
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
||||||
├── masters/
|
├── masters/
|
||||||
│ └── page.tsx # 肥料マスタ管理
|
│ └── page.tsx # 肥料マスタ管理
|
||||||
|
├── spreading/
|
||||||
|
│ └── ... # 散布実績画面(一覧・作成・編集)
|
||||||
└── _components/
|
└── _components/
|
||||||
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
||||||
|
|
||||||
|
frontend/src/app/workrecords/
|
||||||
|
└── ... # 作業記録画面(一覧・詳細)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 変更されたファイル
|
### 変更されたファイル
|
||||||
|
|
||||||
| ファイル | 変更内容 |
|
| ファイル | 変更内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'` を追加 |
|
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'`, `'apps.workrecords'` を追加 |
|
||||||
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
|
| `backend/keinasystem/urls.py` | `api/fertilizer/`, `api/workrecords/` を追加 |
|
||||||
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
|
| `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 アイコン + 施肥計画メニューを追加 |
|
| `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)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/types/index.ts
|
// frontend/src/types/index.ts(主要な型のみ抜粋)
|
||||||
|
|
||||||
export interface Fertilizer {
|
export interface Fertilizer {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -437,6 +658,7 @@ export interface FertilizationEntry {
|
|||||||
fertilizer: number;
|
fertilizer: number;
|
||||||
fertilizer_name: string;
|
fertilizer_name: string;
|
||||||
bags: string;
|
bags: string;
|
||||||
|
actual_bags: string | null; // 散布実績集計値
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationPlan {
|
export interface FertilizationPlan {
|
||||||
@@ -449,6 +671,10 @@ export interface FertilizationPlan {
|
|||||||
field_count: number;
|
field_count: number;
|
||||||
fertilizer_count: number;
|
fertilizer_count: number;
|
||||||
entries: FertilizationEntry[];
|
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 を全削除→再作成する「全置換」方式。
|
PUT 時は entries を全削除→再作成する「全置換」方式。
|
||||||
部分更新は非対応(PATCH でも 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)
|
### Next.js ホットリロードが効かない問題(Windows + Docker)
|
||||||
|
|
||||||
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
||||||
@@ -499,6 +744,8 @@ Windows 環境では Docker ボリュームマウント経由のファイル変
|
|||||||
|
|
||||||
## 将来の拡張(スコープ外)
|
## 将来の拡張(スコープ外)
|
||||||
|
|
||||||
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
- **相手先別PDF様式**: 客先ごとの提出資料フォーマット(元データは散布実績から取得可能)
|
||||||
|
- **残肥返却・再入庫管理**: 散布後の残りを在庫に戻す処理
|
||||||
|
- **SpreadingAllocation**: 運搬便単位の散布充当追跡(現状は集計ベースで十分)
|
||||||
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
||||||
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数)
|
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||||
|
|||||||
@@ -396,3 +396,17 @@ PDF生成時のみサーバーサイドで同じ計算を実施。
|
|||||||
### エラー表示方針
|
### エラー表示方針
|
||||||
|
|
||||||
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
|
施肥計画機能と同じく 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 { api } from '@/lib/api';
|
||||||
import { Field, Crop, Plan } from '@/types';
|
import { Field, Crop, Plan } from '@/types';
|
||||||
import Navbar from '@/components/Navbar';
|
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 {
|
interface SummaryItem {
|
||||||
cropId: number;
|
cropId: number;
|
||||||
@@ -48,6 +48,13 @@ export default function AllocationPage() {
|
|||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
||||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('allocationYear', String(year));
|
localStorage.setItem('allocationYear', String(year));
|
||||||
@@ -233,17 +240,46 @@ export default function AllocationPage() {
|
|||||||
const existingPlan = getPlanForField(fieldId);
|
const existingPlan = getPlanForField(fieldId);
|
||||||
|
|
||||||
if (!existingPlan || !existingPlan.crop) return;
|
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);
|
setSaving(fieldId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/plans/${existingPlan.id}/`, {
|
const res = await api.patch(`/plans/${existingPlan.id}/`, {
|
||||||
variety,
|
variety,
|
||||||
notes: existingPlan.notes,
|
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);
|
await fetchData(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save variety:', error);
|
console.error('Failed to save variety:', error);
|
||||||
|
setToast({
|
||||||
|
type: 'error',
|
||||||
|
message: '品種変更に失敗しました。',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
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) => {
|
const toggleFieldSelection = (fieldId: number) => {
|
||||||
setSelectedFields((prev) => {
|
setSelectedFields((prev) => {
|
||||||
const next = new Set(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="flex-1 min-w-0 p-4 lg:p-0">
|
||||||
<div className="max-w-7xl mx-auto">
|
<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">
|
<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">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
作付け計画 <span className="text-green-700">{year}年度</span>
|
作付け計画 <span className="text-green-700">{year}年度</span>
|
||||||
@@ -873,27 +934,43 @@ export default function AllocationPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
value={selectedVarietyId || ''}
|
<select
|
||||||
onChange={(e) => {
|
value={selectedVarietyId || ''}
|
||||||
if (e.target.value === '__add__') {
|
onChange={(e) => {
|
||||||
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
if (e.target.value === '__add__') {
|
||||||
setNewVarietyName('');
|
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
||||||
} else {
|
setNewVarietyName('');
|
||||||
handleVarietyChange(field.id, e.target.value);
|
} 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"
|
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 value="">選択してください</option>
|
||||||
<option key={variety.id} value={variety.id}>
|
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
||||||
{variety.name}
|
<option key={variety.id} value={variety.id}>
|
||||||
</option>
|
{variety.name}
|
||||||
))}
|
</option>
|
||||||
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
))}
|
||||||
</select>
|
{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>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -1032,15 +1109,22 @@ export default function AllocationPage() {
|
|||||||
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{getVarietiesForCrop(managerCropId).map((v) => (
|
{getVarietiesForCrop(managerCropId).map((v) => (
|
||||||
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
|
<li key={v.id} className="rounded border border-gray-200 p-3">
|
||||||
<span className="text-sm text-gray-900">{v.name}</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<button
|
<span className="text-sm font-medium text-gray-900">{v.name}</span>
|
||||||
onClick={() => handleDeleteVariety(v.id, v.name)}
|
<button
|
||||||
className="text-red-400 hover:text-red-600 p-1"
|
onClick={() => handleDeleteVariety(v.id, v.name)}
|
||||||
title="削除"
|
className="text-red-400 hover:text-red-600 p-1"
|
||||||
>
|
title="削除"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<VarietyDefaultBoxesForm
|
||||||
|
varietyId={v.id}
|
||||||
|
initialValue={v.default_seedling_boxes_per_tan}
|
||||||
|
onSave={handleUpdateVarietyDefaultBoxes}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1105,3 +1189,47 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
|
|||||||
</div>
|
</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 } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { FileDown, NotebookText, Pencil, Plus, Sprout, Trash2, Truck } from 'lucide-react';
|
import { FileDown, GitMerge, NotebookText, Pencil, Plus, Sprout, Trash2, Truck, X } from 'lucide-react';
|
||||||
|
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -36,6 +36,14 @@ export default function FertilizerPage() {
|
|||||||
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('fertilizerYear', String(year));
|
localStorage.setItem('fertilizerYear', String(year));
|
||||||
@@ -83,6 +91,68 @@ export default function FertilizerPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -208,6 +278,16 @@ export default function FertilizerPage() {
|
|||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
編集
|
編集
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => handleDelete(plan.id, plan.name)}
|
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"
|
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"
|
||||||
@@ -225,6 +305,85 @@ export default function FertilizerPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
|
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
@@ -89,6 +89,14 @@ const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: Source
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SpreadingPage() {
|
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 router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryYear = Number(searchParams.get('year') || '0') || null;
|
const queryYear = Number(searchParams.get('year') || '0') || null;
|
||||||
@@ -350,6 +358,10 @@ export default function SpreadingPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError('名称を入力してください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!form.date) {
|
if (!form.date) {
|
||||||
setError('散布日を入力してください。');
|
setError('散布日を入力してください。');
|
||||||
return;
|
return;
|
||||||
|
|||||||
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';
|
import { Material } from '@/types';
|
||||||
|
|
||||||
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
|
export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc';
|
||||||
|
|
||||||
export interface MaterialFormState {
|
export interface MaterialFormState {
|
||||||
name: string;
|
name: string;
|
||||||
material_type: Material['material_type'];
|
material_type: Material['material_type'];
|
||||||
|
seed_variety_id: string;
|
||||||
maker: string;
|
maker: string;
|
||||||
stock_unit: Material['stock_unit'];
|
stock_unit: Material['stock_unit'];
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
@@ -33,6 +34,7 @@ interface MaterialFormProps {
|
|||||||
tab: MaterialTab;
|
tab: MaterialTab;
|
||||||
form: MaterialFormState;
|
form: MaterialFormState;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
seedVarietyOptions?: { id: number; label: string }[];
|
||||||
onBaseFieldChange: (
|
onBaseFieldChange: (
|
||||||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||||||
value: string | boolean
|
value: string | boolean
|
||||||
@@ -56,6 +58,7 @@ export default function MaterialForm({
|
|||||||
tab,
|
tab,
|
||||||
form,
|
form,
|
||||||
saving,
|
saving,
|
||||||
|
seedVarietyOptions = [],
|
||||||
onBaseFieldChange,
|
onBaseFieldChange,
|
||||||
onFertilizerFieldChange,
|
onFertilizerFieldChange,
|
||||||
onPesticideFieldChange,
|
onPesticideFieldChange,
|
||||||
@@ -244,14 +247,29 @@ export default function MaterialForm({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<select
|
{tab === 'seed' ? (
|
||||||
className={inputClassName}
|
<select
|
||||||
value={form.material_type}
|
className={inputClassName}
|
||||||
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
|
value={form.seed_variety_id}
|
||||||
>
|
onChange={(e) => onBaseFieldChange('seed_variety_id', e.target.value)}
|
||||||
<option value="other">その他</option>
|
>
|
||||||
<option value="seedling">種苗</option>
|
<option value="">品種未設定</option>
|
||||||
</select>
|
{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>
|
||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment } from 'react';
|
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';
|
import { StockSummary, StockTransaction } from '@/types';
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ interface StockOverviewProps {
|
|||||||
materialId: number,
|
materialId: number,
|
||||||
transactionType: StockTransaction['transaction_type']
|
transactionType: StockTransaction['transaction_type']
|
||||||
) => void;
|
) => void;
|
||||||
|
onEditTransaction: (transaction: StockTransaction) => void;
|
||||||
|
onDeleteTransaction: (transaction: StockTransaction) => void;
|
||||||
onToggleHistory: (materialId: number) => void;
|
onToggleHistory: (materialId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ export default function StockOverview({
|
|||||||
historyLoadingId,
|
historyLoadingId,
|
||||||
histories,
|
histories,
|
||||||
onOpenTransaction,
|
onOpenTransaction,
|
||||||
|
onEditTransaction,
|
||||||
|
onDeleteTransaction,
|
||||||
onToggleHistory,
|
onToggleHistory,
|
||||||
}: StockOverviewProps) {
|
}: StockOverviewProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -149,6 +153,24 @@ export default function StockOverview({
|
|||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
{transaction.note || '備考なし'}
|
{transaction.note || '備考なし'}
|
||||||
</span>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface StockTransactionFormProps {
|
|||||||
materials: Material[];
|
materials: Material[];
|
||||||
presetMaterialId?: number | null;
|
presetMaterialId?: number | null;
|
||||||
presetTransactionType?: TransactionType | null;
|
presetTransactionType?: TransactionType | null;
|
||||||
|
editingTransaction?: StockTransaction | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => Promise<void> | void;
|
onSaved: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export default function StockTransactionForm({
|
|||||||
materials,
|
materials,
|
||||||
presetMaterialId = null,
|
presetMaterialId = null,
|
||||||
presetTransactionType = null,
|
presetTransactionType = null,
|
||||||
|
editingTransaction = null,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: StockTransactionFormProps) {
|
}: StockTransactionFormProps) {
|
||||||
@@ -47,13 +49,21 @@ export default function StockTransactionForm({
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
|
if (editingTransaction) {
|
||||||
setTransactionType(presetTransactionType ?? 'purchase');
|
setMaterialId(String(editingTransaction.material));
|
||||||
setQuantity('');
|
setTransactionType(editingTransaction.transaction_type);
|
||||||
setOccurredOn(today());
|
setQuantity(editingTransaction.quantity);
|
||||||
setNote('');
|
setOccurredOn(editingTransaction.occurred_on);
|
||||||
|
setNote(editingTransaction.note || '');
|
||||||
|
} else {
|
||||||
|
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
|
||||||
|
setTransactionType(presetTransactionType ?? 'purchase');
|
||||||
|
setQuantity('');
|
||||||
|
setOccurredOn(today());
|
||||||
|
setNote('');
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [isOpen, presetMaterialId, presetTransactionType]);
|
}, [isOpen, presetMaterialId, presetTransactionType, editingTransaction]);
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
@@ -73,13 +83,18 @@ export default function StockTransactionForm({
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/materials/stock-transactions/', {
|
const payload = {
|
||||||
material: Number(materialId),
|
material: Number(materialId),
|
||||||
transaction_type: transactionType,
|
transaction_type: transactionType,
|
||||||
quantity,
|
quantity,
|
||||||
occurred_on: occurredOn,
|
occurred_on: occurredOn,
|
||||||
note,
|
note,
|
||||||
});
|
};
|
||||||
|
if (editingTransaction) {
|
||||||
|
await api.put(`/materials/stock-transactions/${editingTransaction.id}/`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/materials/stock-transactions/', payload);
|
||||||
|
}
|
||||||
await onSaved();
|
await onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e: unknown) {
|
} 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 className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">入出庫登録</h2>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -10,20 +10,35 @@ import MaterialForm, {
|
|||||||
} from '../_components/MaterialForm';
|
} from '../_components/MaterialForm';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Material } from '@/types';
|
import { Crop, Material } from '@/types';
|
||||||
|
|
||||||
const tabs: { key: MaterialTab; label: string }[] = [
|
const tabs: { key: MaterialTab; label: string }[] = [
|
||||||
{ key: 'fertilizer', label: '肥料' },
|
{ key: 'fertilizer', label: '肥料' },
|
||||||
{ key: 'pesticide', label: '農薬' },
|
{ key: 'pesticide', label: '農薬' },
|
||||||
|
{ key: 'seed', label: '種子' },
|
||||||
{ key: 'misc', label: 'その他' },
|
{ key: 'misc', label: 'その他' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
||||||
name: '',
|
name: '',
|
||||||
material_type:
|
material_type:
|
||||||
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
|
tab === 'fertilizer'
|
||||||
|
? 'fertilizer'
|
||||||
|
: tab === 'pesticide'
|
||||||
|
? 'pesticide'
|
||||||
|
: tab === 'seed'
|
||||||
|
? 'seed'
|
||||||
|
: 'other',
|
||||||
|
seed_variety_id: '',
|
||||||
maker: '',
|
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,
|
is_active: true,
|
||||||
notes: '',
|
notes: '',
|
||||||
fertilizer_profile: {
|
fertilizer_profile: {
|
||||||
@@ -42,10 +57,13 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type VarietyOption = { id: number; label: string };
|
||||||
|
|
||||||
export default function MaterialMastersPage() {
|
export default function MaterialMastersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tab, setTab] = useState<MaterialTab>('fertilizer');
|
const [tab, setTab] = useState<MaterialTab>('fertilizer');
|
||||||
const [materials, setMaterials] = useState<Material[]>([]);
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
|
const [crops, setCrops] = useState<Crop[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||||||
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
|
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
|
||||||
@@ -53,7 +71,7 @@ export default function MaterialMastersPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMaterials();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,11 +80,15 @@ export default function MaterialMastersPage() {
|
|||||||
}
|
}
|
||||||
}, [tab, editingId]);
|
}, [tab, editingId]);
|
||||||
|
|
||||||
const fetchMaterials = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/materials/materials/');
|
const [materialsRes, cropsRes] = await Promise.all([
|
||||||
setMaterials(res.data);
|
api.get('/materials/materials/'),
|
||||||
|
api.get('/plans/crops/'),
|
||||||
|
]);
|
||||||
|
setMaterials(materialsRes.data);
|
||||||
|
setCrops(cropsRes.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('資材マスタの取得に失敗しました。');
|
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) => {
|
const visibleMaterials = materials.filter((material) => {
|
||||||
if (tab === 'misc') {
|
if (tab === 'misc') {
|
||||||
return material.material_type === 'other' || material.material_type === 'seedling';
|
return material.material_type === 'other' || material.material_type === 'seedling';
|
||||||
@@ -90,9 +132,11 @@ export default function MaterialMastersPage() {
|
|||||||
|
|
||||||
const startEdit = (material: Material) => {
|
const startEdit = (material: Material) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const linkedVariety = getLinkedVariety(material.id);
|
||||||
setForm({
|
setForm({
|
||||||
name: material.name,
|
name: material.name,
|
||||||
material_type: material.material_type,
|
material_type: material.material_type,
|
||||||
|
seed_variety_id: linkedVariety ? String(linkedVariety.id) : '',
|
||||||
maker: material.maker,
|
maker: material.maker,
|
||||||
stock_unit: material.stock_unit,
|
stock_unit: material.stock_unit,
|
||||||
is_active: material.is_active,
|
is_active: material.is_active,
|
||||||
@@ -120,6 +164,23 @@ export default function MaterialMastersPage() {
|
|||||||
setForm(emptyForm(tab));
|
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 () => {
|
const handleSave = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -159,13 +220,27 @@ export default function MaterialMastersPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let savedMaterial: Material;
|
||||||
if (editingId === 'new') {
|
if (editingId === 'new') {
|
||||||
await api.post('/materials/materials/', payload);
|
const res = await api.post('/materials/materials/', payload);
|
||||||
|
savedMaterial = res.data;
|
||||||
} else {
|
} 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);
|
setEditingId(null);
|
||||||
setForm(emptyForm(tab));
|
setForm(emptyForm(tab));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -189,7 +264,7 @@ export default function MaterialMastersPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/materials/materials/${material.id}/`);
|
await api.delete(`/materials/materials/${material.id}/`);
|
||||||
await fetchMaterials();
|
await fetchData();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
const detail =
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -304,51 +395,10 @@ export default function MaterialMastersPage() {
|
|||||||
<p className="text-sm text-gray-500">読み込み中...</p>
|
<p className="text-sm text-gray-500">読み込み中...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
|
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
{tab === 'fertilizer' && (
|
{tab === 'fertilizer' && <FertilizerTable {...tableProps} />}
|
||||||
<FertilizerTable
|
{tab === 'pesticide' && <PesticideTable {...tableProps} />}
|
||||||
materials={visibleMaterials}
|
{tab === 'seed' && <SeedTable {...tableProps} />}
|
||||||
editingId={editingId}
|
{tab === 'misc' && <MiscTable {...tableProps} />}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -361,6 +411,8 @@ interface TableProps {
|
|||||||
editingId: number | 'new' | null;
|
editingId: number | 'new' | null;
|
||||||
form: MaterialFormState;
|
form: MaterialFormState;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
seedVarietyOptions: VarietyOption[];
|
||||||
|
getLinkedVarietyLabel: (materialId: number) => string;
|
||||||
onEdit: (material: Material) => void;
|
onEdit: (material: Material) => void;
|
||||||
onDelete: (material: Material) => void;
|
onDelete: (material: Material) => void;
|
||||||
onBaseFieldChange: (
|
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) {
|
function MiscTable(props: TableProps) {
|
||||||
return (
|
return (
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import Navbar from '@/components/Navbar';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Material, StockSummary, StockTransaction } from '@/types';
|
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 }[] = [
|
const tabs: { key: FilterTab; label: string }[] = [
|
||||||
{ key: 'all', label: '全て' },
|
{ key: 'all', label: '全て' },
|
||||||
{ key: 'fertilizer', label: '肥料' },
|
{ key: 'fertilizer', label: '肥料' },
|
||||||
{ key: 'pesticide', label: '農薬' },
|
{ key: 'pesticide', label: '農薬' },
|
||||||
|
{ key: 'seed', label: '種子' },
|
||||||
{ key: 'misc', label: 'その他' },
|
{ key: 'misc', label: 'その他' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default function MaterialsPage() {
|
|||||||
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
|
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
|
||||||
const [presetTransactionType, setPresetTransactionType] =
|
const [presetTransactionType, setPresetTransactionType] =
|
||||||
useState<StockTransaction['transaction_type'] | null>(null);
|
useState<StockTransaction['transaction_type'] | null>(null);
|
||||||
|
const [editingTransaction, setEditingTransaction] = useState<StockTransaction | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchInitialData();
|
fetchInitialData();
|
||||||
@@ -93,11 +95,41 @@ export default function MaterialsPage() {
|
|||||||
materialId: number | null,
|
materialId: number | null,
|
||||||
transactionType: StockTransaction['transaction_type'] | null
|
transactionType: StockTransaction['transaction_type'] | null
|
||||||
) => {
|
) => {
|
||||||
|
setEditingTransaction(null);
|
||||||
setPresetMaterialId(materialId);
|
setPresetMaterialId(materialId);
|
||||||
setPresetTransactionType(transactionType);
|
setPresetTransactionType(transactionType);
|
||||||
setIsTransactionOpen(true);
|
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 () => {
|
const handleSavedTransaction = async () => {
|
||||||
await fetchSummaryOnly();
|
await fetchSummaryOnly();
|
||||||
|
|
||||||
@@ -191,6 +223,8 @@ export default function MaterialsPage() {
|
|||||||
historyLoadingId={historyLoadingId}
|
historyLoadingId={historyLoadingId}
|
||||||
histories={histories}
|
histories={histories}
|
||||||
onOpenTransaction={handleOpenTransaction}
|
onOpenTransaction={handleOpenTransaction}
|
||||||
|
onEditTransaction={handleEditTransaction}
|
||||||
|
onDeleteTransaction={handleDeleteTransaction}
|
||||||
onToggleHistory={handleToggleHistory}
|
onToggleHistory={handleToggleHistory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +234,11 @@ export default function MaterialsPage() {
|
|||||||
materials={materials}
|
materials={materials}
|
||||||
presetMaterialId={presetMaterialId}
|
presetMaterialId={presetMaterialId}
|
||||||
presetTransactionType={presetTransactionType}
|
presetTransactionType={presetTransactionType}
|
||||||
onClose={() => setIsTransactionOpen(false)}
|
editingTransaction={editingTransaction}
|
||||||
|
onClose={() => {
|
||||||
|
setIsTransactionOpen(false);
|
||||||
|
setEditingTransaction(null);
|
||||||
|
}}
|
||||||
onSaved={handleSavedTransaction}
|
onSaved={handleSavedTransaction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ export default function WorkRecordsPage() {
|
|||||||
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
|
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (record.levee_work_session) {
|
||||||
|
router.push(`/levee-work?session=${record.levee_work_session}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (record.delivery_plan_id) {
|
if (record.delivery_plan_id) {
|
||||||
router.push(`/distribution/${record.delivery_plan_id}/edit`);
|
router.push(`/distribution/${record.delivery_plan_id}/edit`);
|
||||||
}
|
}
|
||||||
@@ -112,12 +116,14 @@ export default function WorkRecordsPage() {
|
|||||||
<td className="px-4 py-3 text-gray-600">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
{record.spreading_session
|
{record.spreading_session
|
||||||
? `散布実績 #${record.spreading_session}`
|
? `散布実績 #${record.spreading_session}`
|
||||||
|
: record.levee_work_session
|
||||||
|
? `畔塗記録 #${record.levee_work_session}`
|
||||||
: record.delivery_plan_name
|
: record.delivery_plan_name
|
||||||
? `${record.delivery_plan_name}`
|
? `${record.delivery_plan_name}`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{(record.spreading_session || record.delivery_plan_id) && (
|
{(record.spreading_session || record.levee_work_session || record.delivery_plan_id) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => moveToSource(record)}
|
onClick={() => moveToSource(record)}
|
||||||
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||||
|
|||||||
@@ -1,191 +1,503 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine } from 'lucide-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';
|
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() {
|
export default function Navbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
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 (
|
return (
|
||||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
<nav ref={navRef} className="border-b border-gray-200 bg-white shadow-sm">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex h-16 items-center justify-between">
|
||||||
<div className="flex items-center space-x-8">
|
<div className="flex items-center gap-4 lg:gap-8">
|
||||||
<button onClick={() => router.push('/dashboard')} className="text-xl font-bold text-green-700 hover:text-green-800 transition-colors">
|
<button
|
||||||
|
onClick={() => navigateTo('/dashboard')}
|
||||||
|
className="text-lg font-bold text-green-700 transition-colors hover:text-green-800 sm:text-xl"
|
||||||
|
>
|
||||||
KeinaSystem
|
KeinaSystem
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<button
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
onClick={() => router.push('/dashboard')}
|
{navGroups.map((group) =>
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
group.type === 'link' ? (
|
||||||
isActive('/dashboard')
|
<DesktopLinkButton
|
||||||
? 'text-green-700 bg-green-50'
|
key={group.key}
|
||||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
group={group}
|
||||||
}`}
|
pathname={pathname}
|
||||||
>
|
onNavigate={navigateTo}
|
||||||
<LayoutDashboard className="h-4 w-4 mr-2" />
|
/>
|
||||||
ホーム
|
) : (
|
||||||
</button>
|
<DesktopGroupButton
|
||||||
<button
|
key={group.key}
|
||||||
onClick={() => router.push('/allocation')}
|
group={group}
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
isOpen={openDesktopGroup === group.key}
|
||||||
isActive('/allocation')
|
pathname={pathname}
|
||||||
? 'text-green-700 bg-green-50'
|
onNavigate={navigateTo}
|
||||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
onToggle={toggleDesktopGroup}
|
||||||
}`}
|
/>
|
||||||
>
|
)
|
||||||
<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') && !pathname?.startsWith('/fertilizer/spreading')
|
|
||||||
? '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('/fertilizer/spreading')}
|
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
|
||||||
pathname?.startsWith('/fertilizer/spreading')
|
|
||||||
? 'text-green-700 bg-green-50'
|
|
||||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<PencilLine 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>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/workrecords')}
|
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
|
||||||
pathname?.startsWith('/workrecords')
|
|
||||||
? 'text-green-700 bg-green-50'
|
|
||||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<NotebookText className="h-4 w-4 mr-2" />
|
|
||||||
作業記録
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<button
|
<div className="hidden items-center gap-1 lg:flex">
|
||||||
onClick={() => router.push('/settings/password')}
|
{userActions.map((item) => (
|
||||||
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"
|
<button
|
||||||
title="パスワード変更"
|
key={item.href}
|
||||||
>
|
onClick={() => navigateTo(item.href)}
|
||||||
<KeyRound className="h-4 w-4" />
|
className={`rounded-md px-3 py-2 text-sm transition-colors ${
|
||||||
</button>
|
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
|
<button
|
||||||
onClick={handleLogout}
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
</nav>
|
</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;
|
id: number;
|
||||||
crop: number;
|
crop: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
default_seedling_boxes_per_tan: string;
|
||||||
|
seed_material: number | null;
|
||||||
|
seed_material_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Crop {
|
export interface Crop {
|
||||||
@@ -54,6 +57,16 @@ export interface Plan {
|
|||||||
variety: number;
|
variety: number;
|
||||||
variety_name: string;
|
variety_name: string;
|
||||||
notes: string | null;
|
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 {
|
export interface Fertilizer {
|
||||||
@@ -88,7 +101,7 @@ export interface PesticideProfile {
|
|||||||
export interface Material {
|
export interface Material {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other';
|
||||||
material_type_display: string;
|
material_type_display: string;
|
||||||
maker: string;
|
maker: string;
|
||||||
stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece';
|
stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece';
|
||||||
@@ -115,13 +128,15 @@ export interface StockTransaction {
|
|||||||
occurred_on: string;
|
occurred_on: string;
|
||||||
note: string;
|
note: string;
|
||||||
fertilization_plan: number | null;
|
fertilization_plan: number | null;
|
||||||
|
spreading_item?: number | null;
|
||||||
|
is_locked: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockSummary {
|
export interface StockSummary {
|
||||||
material_id: number;
|
material_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other';
|
||||||
material_type_display: string;
|
material_type_display: string;
|
||||||
maker: string;
|
maker: string;
|
||||||
stock_unit: string;
|
stock_unit: string;
|
||||||
@@ -161,6 +176,38 @@ export interface FertilizationPlan {
|
|||||||
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
||||||
is_confirmed: boolean;
|
is_confirmed: boolean;
|
||||||
confirmed_at: string | null;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -268,10 +315,46 @@ export interface SpreadingSession {
|
|||||||
updated_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 {
|
export interface WorkRecord {
|
||||||
id: number;
|
id: number;
|
||||||
work_date: string;
|
work_date: string;
|
||||||
work_type: 'fertilizer_delivery' | 'fertilizer_spreading';
|
work_type: 'fertilizer_delivery' | 'fertilizer_spreading' | 'levee_work';
|
||||||
work_type_display: string;
|
work_type_display: string;
|
||||||
title: string;
|
title: string;
|
||||||
year: number;
|
year: number;
|
||||||
@@ -280,6 +363,7 @@ export interface WorkRecord {
|
|||||||
delivery_plan_id: number | null;
|
delivery_plan_id: number | null;
|
||||||
delivery_plan_name: string | null;
|
delivery_plan_name: string | null;
|
||||||
spreading_session: number | null;
|
spreading_session: number | null;
|
||||||
|
levee_work_session: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
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 優先、難しければ矢印移動で代替する
|
||||||
476
改善案/issue_3_計画始動後の作付け変更_調査.md
Normal file
476
改善案/issue_3_計画始動後の作付け変更_調査.md
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
# Issue #3 調査メモ: 計画始動後の作付け変更について
|
||||||
|
|
||||||
|
## 対象 issue
|
||||||
|
|
||||||
|
- Gitea Issue `#3`
|
||||||
|
- タイトル: `計画始動後の作付け変更について`
|
||||||
|
- 登録日: 2026-04-05
|
||||||
|
|
||||||
|
## 1. まず明らかになっている必要があること
|
||||||
|
|
||||||
|
この課題は「作付け計画を変更したい」ではなく、
|
||||||
|
「すでに計画・運搬・散布の一部が動き始めた後で、将来分だけを安全に組み替えたい」が本質。
|
||||||
|
そのため、以下を仕様として先に決めないと実装を始めると破綻しやすい。
|
||||||
|
|
||||||
|
### 1-1. どこまでを履歴として固定し、どこから先を変更対象にするか
|
||||||
|
|
||||||
|
- すでに散布実績がある `圃場 × 肥料` は履歴として固定するのか
|
||||||
|
- 固定する場合、固定対象は以下のどこまで含むのか
|
||||||
|
- 散布実績
|
||||||
|
- 実績に対応する施肥計画エントリ
|
||||||
|
- 実績に対応する運搬明細
|
||||||
|
- 在庫 USE / RESERVE
|
||||||
|
- 作業記録
|
||||||
|
- 「まだ散布していない残計画」だけを別レコードへ移すのか
|
||||||
|
- 既存計画を上書きするのではなく、履歴保持のために分割するのか
|
||||||
|
|
||||||
|
### 1-2. 品種変更を何の単位で許可するか
|
||||||
|
|
||||||
|
- 圃場単位での変更を許可するのか
|
||||||
|
- 同じ年度中に圃場の品種変更履歴を残す必要があるのか
|
||||||
|
- 変更後の圃場は新しい品種の施肥計画へ付け替えるのか
|
||||||
|
- 変更前に散布済みの肥料は「旧品種の計画に残す」のか「圃場履歴として残す」のか
|
||||||
|
|
||||||
|
### 1-3. グループ変更の意味
|
||||||
|
|
||||||
|
ユーザー確認により、issue 本文の「グループ変更」は主に圃場管理の `group_name` を指す。
|
||||||
|
|
||||||
|
- 圃場マスタの `group_name` は後から変更されうる
|
||||||
|
- 運搬計画の配送グループも散布前なら変更されうる
|
||||||
|
- ただし散布後は、運搬計画グループは基本的に変更しない前提
|
||||||
|
|
||||||
|
このため、少なくとも以下を分けて考える必要がある。
|
||||||
|
|
||||||
|
- 圃場マスタ上の現在属性としてのグループ
|
||||||
|
- 履歴として固定すべき運搬計画上のグループ
|
||||||
|
|
||||||
|
### 1-4. 不整合をどこまで許容するか
|
||||||
|
|
||||||
|
- 旧品種の施肥実績が残ったまま、新品種の作付け計画へ変更してよいか
|
||||||
|
- 「今年のその圃場は最終的に何を作ったか」と「途中で何を前提に散布したか」がズレてもよいか
|
||||||
|
- PDF や一覧画面で、旧計画分と新計画分を同時に見せる必要があるか
|
||||||
|
|
||||||
|
### 1-5. ユーザー操作として必要な単位
|
||||||
|
|
||||||
|
- 圃場 1 筆だけ切り替えたいのか
|
||||||
|
- 複数圃場をまとめて切り替えたいのか
|
||||||
|
- 施肥済み分を残しつつ、未散布分を新計画へ一括移動したいのか
|
||||||
|
- 変更理由や変更日などの監査情報が必要か
|
||||||
|
|
||||||
|
## 2. 現行実装で起きていること
|
||||||
|
|
||||||
|
### 2-1. 候補圃場は「現在の作付け計画」から再計算される
|
||||||
|
|
||||||
|
施肥計画の圃場候補は、現在の `plans.Plan(year, variety)` を見て作られている。
|
||||||
|
|
||||||
|
- [backend/apps/fertilizer/views.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/views.py#L126)
|
||||||
|
- [frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx](/home/akira/develop/keinasystem/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx#L180)
|
||||||
|
|
||||||
|
そのため、作付け計画で圃場の品種を変更すると、
|
||||||
|
変更前の品種に紐づく施肥計画画面では、その圃場が追加候補に出なくなる。
|
||||||
|
issue にある「足川北上が圃場追加候補に出てこない」はこの挙動と一致する。
|
||||||
|
|
||||||
|
### 2-2. 施肥計画の実績集計は `year + field + fertilizer` 単位
|
||||||
|
|
||||||
|
散布実績から `actual_bags` を再集計するとき、対象は `plan__year + field_id + fertilizer_id` で更新される。
|
||||||
|
|
||||||
|
- [backend/apps/fertilizer/services.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/services.py#L11)
|
||||||
|
|
||||||
|
つまり現行実装では、
|
||||||
|
「どの施肥計画に対する実績か」ではなく、
|
||||||
|
「同年度のその圃場・その肥料の実績か」で紐づいている。
|
||||||
|
|
||||||
|
このため、同じ年度に圃場を別計画へ移したり、計画を分割したりすると、
|
||||||
|
実績が複数計画へ二重反映または意図しない再配分になる余地がある。
|
||||||
|
|
||||||
|
### 2-3. 施肥計画更新はエントリ全削除・全再作成
|
||||||
|
|
||||||
|
施肥計画更新時は、既存エントリを全削除して新しいエントリを作り直す。
|
||||||
|
|
||||||
|
- [backend/apps/fertilizer/serializers.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/serializers.py#L152)
|
||||||
|
|
||||||
|
この方式だと、「散布済みの行だけ残して、未散布分だけ移す」という操作単位を持てない。
|
||||||
|
履歴保持と将来計画の分離を実現するには、今の更新方式は不足している。
|
||||||
|
|
||||||
|
### 2-4. 運搬計画は年度内の全施肥エントリを前提に集計する
|
||||||
|
|
||||||
|
運搬計画詳細の未割当圃場・利用可能肥料・全明細は、`plan__year=obj.year` の全施肥エントリから作られている。
|
||||||
|
|
||||||
|
- [backend/apps/fertilizer/serializers.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/serializers.py#L291)
|
||||||
|
|
||||||
|
そのため、作付け変更に伴って施肥計画を分割・移管したい場合、
|
||||||
|
年度全体集計ベースの画面は旧計画と新計画を自然に区別できない。
|
||||||
|
|
||||||
|
### 2-5. 散布実績自体はスナップショットを保持している
|
||||||
|
|
||||||
|
散布実績明細は以下を保存している。
|
||||||
|
|
||||||
|
- 実散布袋数
|
||||||
|
- 計画袋数スナップショット
|
||||||
|
- 運搬済み袋数スナップショット
|
||||||
|
|
||||||
|
- [backend/apps/fertilizer/models.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/models.py#L211)
|
||||||
|
- [backend/apps/fertilizer/serializers.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/serializers.py#L437)
|
||||||
|
|
||||||
|
これは履歴保持の観点では良いが、
|
||||||
|
「どの施肥計画のどの行から発生した散布か」という計画レベルの参照は保持していない。
|
||||||
|
|
||||||
|
## 3. 影響を受ける仕様
|
||||||
|
|
||||||
|
### 3-1. 作付け計画
|
||||||
|
|
||||||
|
- 現在は `field + year` が 1 件で、その年度の最新状態のみを表す
|
||||||
|
- 途中変更の履歴を持たない
|
||||||
|
- 変更前後を区別したいなら、履歴テーブルか有効期間の考え方が必要
|
||||||
|
|
||||||
|
### 3-2. 施肥計画
|
||||||
|
|
||||||
|
- 候補圃場抽出ロジック
|
||||||
|
- 編集画面での圃場追加可否
|
||||||
|
- 既散布行と未散布行の扱い
|
||||||
|
- `actual_bags` の再集計単位
|
||||||
|
- 在庫引当の再作成ロジック
|
||||||
|
|
||||||
|
### 3-3. 圃場グループ
|
||||||
|
|
||||||
|
- 圃場マスタの `group_name` は現在値しか持っていない
|
||||||
|
- 後日変更されると、過去時点でどのグループだったかは追えない
|
||||||
|
- ただし現時点では、散布実績や施肥実績が `group_name` に直接依存している箇所は薄い
|
||||||
|
- よって圃場グループ変更は、主に表示・集計・将来計画側の問題として扱える
|
||||||
|
|
||||||
|
### 3-4. 運搬計画
|
||||||
|
|
||||||
|
- グループ割当の維持方法
|
||||||
|
- 年度全体施肥エントリを前提にした未割当圃場計算
|
||||||
|
- すでに運搬済みの明細を履歴として残しつつ、未運搬分だけ別グループへ再編できるか
|
||||||
|
|
||||||
|
### 3-5. 散布実績
|
||||||
|
|
||||||
|
- 既存実績の参照元計画が曖昧
|
||||||
|
- 変更後の計画へ実績が再集計されるかどうか
|
||||||
|
- 候補一覧生成時に、旧計画分と新計画分をどう見せ分けるか
|
||||||
|
|
||||||
|
### 3-6. 在庫管理
|
||||||
|
|
||||||
|
- 施肥計画更新時、RESERVE が全置換される
|
||||||
|
- 実績 USE は散布実績ベースで残る
|
||||||
|
- 途中変更時に「旧計画の引当を解除し、新計画へ再引当」が必要
|
||||||
|
- ただし散布済み分の USE は動かしてはいけない
|
||||||
|
|
||||||
|
### 3-7. 作業記録
|
||||||
|
|
||||||
|
- 作業記録は運搬回 / 散布実績への 1:1 参照で成立しており、履歴としては比較的安定
|
||||||
|
- 一方でタイトル等は計画名変更の影響を受けうる
|
||||||
|
|
||||||
|
## 4. この issue に対する現時点の結論
|
||||||
|
|
||||||
|
この問題は単なる「候補圃場の表示漏れ」ではない。
|
||||||
|
|
||||||
|
本質は以下の 2 点。
|
||||||
|
|
||||||
|
1. 現行システムは「現在の作付け計画」と「履歴として固定すべき施肥・運搬・散布」を分離していない
|
||||||
|
2. 施肥・運搬・散布の一部が `年度 + 圃場 + 肥料` 集計でつながっており、計画の再編単位を持っていない
|
||||||
|
|
||||||
|
したがって、候補圃場 API だけ直しても不十分で、
|
||||||
|
少なくとも「履歴固定」と「未実施分の再計画」の仕様分離が必要。
|
||||||
|
|
||||||
|
## 5. 実装前に必要な仕様決定
|
||||||
|
|
||||||
|
最低限、次の 4 点を決める必要がある。
|
||||||
|
|
||||||
|
1. 変更後も残すべき履歴の最小単位は何か
|
||||||
|
2. 未散布分をどの単位で旧計画から切り離すか
|
||||||
|
3. 品種変更後、既散布分を旧品種の施肥計画に残すのか、新品種側に見せ替えるのか
|
||||||
|
4. 圃場マスタの `group_name` 変更を履歴管理対象にするか、現在値扱いに留めるか
|
||||||
|
5. 散布前の運搬計画変更をどこまで許容するか
|
||||||
|
|
||||||
|
## 6. 推奨する実装方針の方向性
|
||||||
|
|
||||||
|
現時点では、次の方向が最も安全に見える。
|
||||||
|
|
||||||
|
### 方針A: 履歴固定 + 未実施分の再計画
|
||||||
|
|
||||||
|
- 散布実績がある `圃場 × 肥料` は旧施肥計画側に固定する
|
||||||
|
- 未散布分だけ新施肥計画へ移す
|
||||||
|
- 運搬済み明細も履歴として残し、未運搬分のみ再編対象にする
|
||||||
|
- 作付け計画の最新状態とは別に、施肥計画側で「履歴としての対象圃場集合」を保持する
|
||||||
|
- 圃場マスタの `group_name` は変更可能な現在属性として扱い、必要なら帳票側でスナップショット化を検討する
|
||||||
|
|
||||||
|
### 方針B: 候補圃場と実績参照を分離する
|
||||||
|
|
||||||
|
- 候補圃場表示は「現在の作付け計画」
|
||||||
|
- 既存計画の保持対象は「その計画に保存済みの圃場」
|
||||||
|
- 実績集計は `plan_id` またはそれに準ずる固定キーに寄せる
|
||||||
|
|
||||||
|
方針A/B を組み合わせないと、issue の A/B/C を同時には満たしにくい。
|
||||||
|
|
||||||
|
## 6-1. ユーザー確認を踏まえた補足結論
|
||||||
|
|
||||||
|
ユーザー確認により、優先順位は次のように見える。
|
||||||
|
|
||||||
|
1. 散布後の履歴固定が最優先
|
||||||
|
2. 散布前の運搬計画は変更可能
|
||||||
|
3. 圃場マスタのグループは現在値として後から変わりうる
|
||||||
|
|
||||||
|
したがって、構造上もっとも重要なのは
|
||||||
|
`作付け変更後も散布済みデータが崩れないこと`
|
||||||
|
であり、`group_name` 自体は二次的な論点。
|
||||||
|
ただし帳票や一覧で「当時のグループ」を見たい要求が出るなら、別途スナップショットが必要になる。
|
||||||
|
|
||||||
|
## 7. 次の調査・設計タスク案
|
||||||
|
|
||||||
|
1. 「既散布・未散布」「既運搬・未運搬」で分けた業務フローを図にする
|
||||||
|
2. 施肥計画エントリに履歴固定用の状態を持たせるか検討する
|
||||||
|
3. 散布実績の参照先を `year + field + fertilizer` から計画単位へ寄せる案を比較する
|
||||||
|
4. 圃場マスタ `group_name` を履歴化する必要があるかを判断する
|
||||||
|
5. UI 上で必要な操作を列挙する
|
||||||
|
6. その後に issue を「暫定対処」と「構造対応」に分割する
|
||||||
|
|
||||||
|
## 8. 追加提案に対する評価
|
||||||
|
|
||||||
|
ユーザーからの追加提案(初期):
|
||||||
|
|
||||||
|
- 変更履歴は必要
|
||||||
|
- 散布済み Entry も新品種計画へ移動する案を第一候補
|
||||||
|
- 未散布 Entry は新品種計画へ移動し、RESERVE も付け替える
|
||||||
|
- 圃場グループは対応不要
|
||||||
|
- 田植え計画も同様に移動
|
||||||
|
|
||||||
|
> **補足(最終確定)**: 施肥 Entry の扱いは後述 8-3 の検討を経て **(B) 対象圃場の全件を新品種計画へ移動** に確定した。
|
||||||
|
> 履歴スナップショットは将来必要になった時点で追加検討とする。
|
||||||
|
|
||||||
|
この提案には良い点が多い一方で、現行実装のまま採ると危険な点もある。
|
||||||
|
|
||||||
|
### 8-1. 採用しやすい点
|
||||||
|
|
||||||
|
#### a. 変更履歴モデルの新設
|
||||||
|
|
||||||
|
`PlanVarietyChange` のような履歴モデル追加は妥当。
|
||||||
|
少なくとも「いつ・どの圃場の品種が・何から何に変わったか」は残すべき。
|
||||||
|
|
||||||
|
補足:
|
||||||
|
|
||||||
|
- `plan FK` だけでなく `field_id` と `year` を冗長保持した方が将来参照しやすい
|
||||||
|
- 変更理由 `reason` があると運用上かなり有用
|
||||||
|
- 自動移動結果の件数も履歴に残せると監査しやすい
|
||||||
|
|
||||||
|
#### b. 対象圃場の施肥 Entry を新品種計画へ集約する
|
||||||
|
|
||||||
|
現在の計画・栽培記録・将来の集計を一貫させるには、
|
||||||
|
対象圃場の施肥 Entry を既散布/未散布で分断せず、新品種計画へ集約する方が自然。
|
||||||
|
RESERVE もその plan 構成に合わせて再生成する。
|
||||||
|
|
||||||
|
#### c. 田植え計画も同様に扱う
|
||||||
|
|
||||||
|
田植え計画も、候補圃場を現在の作付け計画から取っている以上、同種の問題を持つ。
|
||||||
|
施肥だけ直して田植えを放置すると整合しないため、同時に見るべきという指摘は正しい。
|
||||||
|
|
||||||
|
### 8-2. そのまま採ると危険な点
|
||||||
|
|
||||||
|
#### a. 全 Entry を新品種計画へ移動する案
|
||||||
|
|
||||||
|
これはもっとも議論が必要。
|
||||||
|
|
||||||
|
現行では散布実績は `SpreadingSessionItem(field, fertilizer)` にあり、
|
||||||
|
施肥計画との関係は `actual_bags` の再集計で後付けされている。
|
||||||
|
|
||||||
|
- [backend/apps/fertilizer/services.py](/home/akira/develop/keinasystem/backend/apps/fertilizer/services.py#L11)
|
||||||
|
|
||||||
|
この構造で散布済み Entry を新品種計画へ移すと、画面上は
|
||||||
|
「新品種の施肥計画に、旧品種前提で行った散布実績が載る」
|
||||||
|
ことになる。
|
||||||
|
|
||||||
|
業務的にそれを許容するなら成立するが、次の違和感が出る。
|
||||||
|
|
||||||
|
- 散布時点では旧品種前提だった履歴が、新品種計画の一部として見える
|
||||||
|
- 施肥計画 PDF や一覧で、後から見る人が経緯を誤解しやすい
|
||||||
|
- 「なぜこの新品種計画に既散布分が入っているのか」を履歴表示なしでは理解できない
|
||||||
|
|
||||||
|
したがって、全 Entry を移動するなら、少なくとも
|
||||||
|
`変更前品種で発生した実績である`
|
||||||
|
ことが UI で明示される必要がある。
|
||||||
|
|
||||||
|
ただし、将来の栽培記録実装では「その圃場・その年度の最終品種」に
|
||||||
|
施肥情報を集約したい要求が強いため、最終的には B案を採用する。
|
||||||
|
|
||||||
|
#### b. Entry の plan FK 付け替えだけでは履歴の意味が弱い
|
||||||
|
|
||||||
|
提案では `FertilizationEntry.plan` / `RiceTransplantEntry.plan` を付け替えるが、
|
||||||
|
それだけでは「なぜそこへ移ったか」が DB 上から分からない。
|
||||||
|
|
||||||
|
最低でも次が欲しい。
|
||||||
|
|
||||||
|
- どの変更イベントで移動したか
|
||||||
|
- 移動前 plan
|
||||||
|
- 移動後 plan
|
||||||
|
- 自動移動日時
|
||||||
|
|
||||||
|
つまり、履歴は `PlanVarietyChange` だけでなく、
|
||||||
|
Entry 移動の監査ログも別に持つ方が安全。
|
||||||
|
|
||||||
|
#### c. 施肥計画が複数ある場合の自動集約
|
||||||
|
|
||||||
|
「最新 1 件に集約」は実装は簡単だが、業務意味が崩れやすい。
|
||||||
|
issue 本文にもあるように、同年度・同品種で複数計画がありうる。
|
||||||
|
|
||||||
|
そこへ無条件に寄せると、
|
||||||
|
|
||||||
|
- 元肥用と追肥用
|
||||||
|
- 第1回散布分と第2回散布分
|
||||||
|
- ロット違い
|
||||||
|
|
||||||
|
のような意味を壊す可能性がある。
|
||||||
|
|
||||||
|
自動選択(最新)は暫定対応としてはあり得るが、本命仕様にはしにくい。
|
||||||
|
|
||||||
|
### 8-3. 推奨と最終決定
|
||||||
|
|
||||||
|
#### 変更履歴 ✅ 採用確定
|
||||||
|
|
||||||
|
モデル設計:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PlanVarietyChange
|
||||||
|
field FK(Field, PROTECT)
|
||||||
|
year int
|
||||||
|
plan FK(Plan, CASCADE)
|
||||||
|
changed_at datetime
|
||||||
|
old_variety FK(Variety, SET_NULL, null=True)
|
||||||
|
new_variety FK(Variety, SET_NULL, null=True)
|
||||||
|
reason text blank
|
||||||
|
moved_entry_count int default=0 # 自動移動した施肥エントリ数(監査用)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `plan FK` だけでなく `field_id` と `year` を冗長保持した方が将来参照しやすい
|
||||||
|
- `reason` があると運用上かなり有用
|
||||||
|
- `moved_entry_count` で自動移動の件数を残すことで監査ログを兼ねる
|
||||||
|
|
||||||
|
#### 施肥 Entry の扱い ✅ **(B) 対象圃場の全件を新品種計画へ移動** 確定
|
||||||
|
|
||||||
|
理由:
|
||||||
|
|
||||||
|
- 栽培記録の観点では「その圃場・その年度に最終的に作った品種」に施肥情報を集約した方が自然
|
||||||
|
- 既散布/未散布で計画が分裂すると、将来の集計や参照で特別処理が増える
|
||||||
|
- 旧計画側に圃場が残らないため、画面上の違和感が少ない
|
||||||
|
- `actual_bags` を含めて plan ごと付け替えることで、圃場単位の施肥履歴を新品種側へ一貫して寄せられる
|
||||||
|
|
||||||
|
散布済み/未散布に関係なく、**対象圃場の FertilizationEntry は全件移動**する。
|
||||||
|
RESERVE も移動後の plan 構成に合わせて新旧 plan 単位で再生成する。
|
||||||
|
|
||||||
|
#### 圃場グループ ✅ 対応不要 確定
|
||||||
|
|
||||||
|
圃場マスタの `group_name` は現在値として扱う。
|
||||||
|
帳票側でスナップショットが必要になれば別途検討。
|
||||||
|
|
||||||
|
#### 田植え計画 ✅ 対応確定(施肥とは判定軸が異なる)
|
||||||
|
|
||||||
|
施肥だけ直して田植えを放置すると整合しないため同時に対応する。
|
||||||
|
ただし田植え計画には actual_bags 相当の実績概念がないため、
|
||||||
|
**対象圃場の Entry は全件移動**(未散布判定なし)。
|
||||||
|
将来、田植え実績との連携が実装された場合は改めて設計する。
|
||||||
|
実装順は施肥の後でよい。
|
||||||
|
|
||||||
|
### 8-4. 移動先計画の選び方への見解
|
||||||
|
|
||||||
|
3案の中では、私は次を推す。
|
||||||
|
|
||||||
|
1. 本命仕様: `b. 新規作成(常に)`
|
||||||
|
2. 暫定実装: `a. 自動選択(最新)`
|
||||||
|
3. 初期段階では避けたい: `c. ユーザー選択`
|
||||||
|
|
||||||
|
理由:
|
||||||
|
|
||||||
|
- `b` は履歴が最も明確で、既存計画の意味を壊しにくい
|
||||||
|
- `a` は早く実装できるが、複数計画の意味を壊しうる
|
||||||
|
- `c` は柔軟だが、allocation 画面の操作が一気に複雑になる
|
||||||
|
|
||||||
|
実務上は、
|
||||||
|
「品種変更に伴って自動移動された分」は専用計画として分けた方が後から説明しやすい。
|
||||||
|
|
||||||
|
たとえば:
|
||||||
|
|
||||||
|
- `2026年度 たちはるか特栽 施肥計画(品種変更移動)`
|
||||||
|
- `2026年度 たちはるか特栽 田植え計画(品種変更移動)`
|
||||||
|
|
||||||
|
のような命名。
|
||||||
|
|
||||||
|
### 8-5. 実装ステップ(確定版)
|
||||||
|
|
||||||
|
散布済みEntryの扱いが確定したため、以下の順で実装する。
|
||||||
|
|
||||||
|
1. `PlanVarietyChange` モデル追加(履歴記録のみ・既存データに触らない)
|
||||||
|
2. 品種変更トリガーのサービス追加
|
||||||
|
3. 対象圃場の施肥 Entry `全件` を新品種計画(常に新規作成)へ移動する処理を実装
|
||||||
|
4. RESERVE 付け替えと `actual_bags` 再集計を確認
|
||||||
|
5. 田植え計画へ横展開
|
||||||
|
6. allocation 画面の履歴インジケータ追加
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 確定仕様まとめ
|
||||||
|
|
||||||
|
> 更新日: 2026-04-05
|
||||||
|
|
||||||
|
### 9-1. 決定事項一覧
|
||||||
|
|
||||||
|
| 項目 | 決定内容 |
|
||||||
|
|---|---|
|
||||||
|
| 施肥 Entry | **対象圃場の全件を新品種計画へ移動 + RESERVE再生成** |
|
||||||
|
| 移動先計画の選び方 | **常に新規作成**(既存計画には集約しない) |
|
||||||
|
| 移動先計画の命名 | `{year}年度 {品種名} 施肥計画(品種変更移動)` |
|
||||||
|
| 変更履歴 | **PlanVarietyChange モデルを新設** |
|
||||||
|
| 圃場グループ | **対応不要**(現在値扱いのまま) |
|
||||||
|
| 田植え計画 | **現時点では全件移動**(実績概念なし)。将来の実績連携実装後に再設計(実装は施肥の後) |
|
||||||
|
|
||||||
|
### 9-2. 品種変更時の自動処理フロー
|
||||||
|
|
||||||
|
`Plan.variety` が `A → B` に変更されたとき:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PlanVarietyChange を記録
|
||||||
|
field, year, plan, changed_at, old_variety=A, new_variety=B, reason
|
||||||
|
|
||||||
|
2. 施肥計画エントリの移動
|
||||||
|
|
||||||
|
対象: FertilizationPlan.variety=A かつ year=変更年度 かつ
|
||||||
|
FertilizationEntry.field=変更圃場(全件)
|
||||||
|
処理:
|
||||||
|
a. variety=B, year=変更年度 の新 FertilizationPlan を作成
|
||||||
|
名前: "{year}年度 {B品種名} 施肥計画(品種変更移動)"
|
||||||
|
b. 対象 FertilizationEntry の plan FK を新 plan へ付け替え
|
||||||
|
c. 旧 plan 全体の RESERVE を再生成(stock_service.create_reserves_for_plan(旧plan))
|
||||||
|
※ RESERVE は plan 単位で全置換管理のため、エントリ単位ではなく plan 単位で呼び出す
|
||||||
|
d. 新 plan 全体の RESERVE を生成(stock_service.create_reserves_for_plan(新plan))
|
||||||
|
e. PlanVarietyChange.moved_entry_count に移動件数を記録
|
||||||
|
|
||||||
|
3. 田植え計画エントリの移動
|
||||||
|
|
||||||
|
田植え計画には施肥計画の actual_bags に相当する実績概念がまだない
|
||||||
|
(RiceTransplantEntry は installed_seedling_boxes のみ、散布済み/未散布の区別がない)。
|
||||||
|
そのため、現時点では 対象圃場の Entry を全件移動 とする。
|
||||||
|
|
||||||
|
将来、田植え実績(田植え日・実績箱数等)との連携が実装された場合は、
|
||||||
|
「実施済み Entry は旧計画に残す」方針に揃えて再設計すること。
|
||||||
|
|
||||||
|
対象: RiceTransplantPlan.variety=A かつ year=変更年度 かつ
|
||||||
|
RiceTransplantEntry.field=変更圃場(全件)
|
||||||
|
処理:
|
||||||
|
a. variety=B, year=変更年度 の新 RiceTransplantPlan を作成
|
||||||
|
名前: "{year}年度 {B品種名} 田植え計画(品種変更移動)"
|
||||||
|
b. 対象 RiceTransplantEntry の plan FK を新 plan へ付け替え
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9-3. 変更しないもの(影響なし)
|
||||||
|
|
||||||
|
- `SpreadingSessionItem` — field+fertilizer リンクのため変更不要
|
||||||
|
- `actual_bags` 集計ロジック — 現方針では再利用可能。
|
||||||
|
ただし **同一 year+field+fertilizer の FertilizationEntry が複数計画にまたがって共存しないこと** が前提。
|
||||||
|
この制約は仕様上の invariant として守る必要がある(移動処理でエントリを複製しないこと)。
|
||||||
|
- `candidate_fields` API — Plan.variety 変更後は自然に新品種で候補が返る
|
||||||
|
- `WorkRecord` — 運搬/散布実績への 1:1 参照のため影響なし
|
||||||
|
|
||||||
|
### 9-4. 未解決・将来検討
|
||||||
|
|
||||||
|
- 変更履歴のスナップショットをどこまで持つか → 実装後に見直し
|
||||||
|
- allocation 画面の変更履歴インジケータ(実装ステップ6)
|
||||||
|
- `actual_bags` 集計を `year+field+fertilizer` から `plan単位` へ変更する大規模リファクタ(中長期)
|
||||||
528
改善案/ナビゲーション再編仕様書.md
Normal file
528
改善案/ナビゲーション再編仕様書.md
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
# ナビゲーション再編仕様書
|
||||||
|
|
||||||
|
> 作成日: 2026-04-07
|
||||||
|
> 対象: `frontend/src/components/Navbar.tsx`
|
||||||
|
> 方針: 第一候補「上段5分類 + ドロップダウン」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 背景
|
||||||
|
|
||||||
|
現状のグローバルナビゲーションは、機能追加のたびに横並びのボタンを増やしており、以下の問題が出ている。
|
||||||
|
|
||||||
|
- 上位階層の導線が多すぎて、目的の画面を探しにくい
|
||||||
|
- 「計画」「実績」「設定」「補助機能」が同じ粒度で並んでいる
|
||||||
|
- メール関連のように、単独トップに置くほどではない機能が場所を取りやすい
|
||||||
|
- 今後も機能追加が続くと、横幅不足と認知負荷の両方が悪化する
|
||||||
|
|
||||||
|
このため、トップナビは「日常的に使う業務カテゴリ」だけを見せ、個別画面はドロップダウン配下へ整理する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目的
|
||||||
|
|
||||||
|
### 1-1. 目指す状態
|
||||||
|
|
||||||
|
- 1階層目では「何をしたいか」で探せる
|
||||||
|
- 似た役割の画面を同じカテゴリに集約する
|
||||||
|
- 画面数が増えても、トップレベルの見た目を増やしすぎない
|
||||||
|
- PC とスマホで同じ情報設計を維持する
|
||||||
|
|
||||||
|
### 1-2. 今回の対象
|
||||||
|
|
||||||
|
- 共通ヘッダー内のグローバルナビゲーション再編
|
||||||
|
- メニュー分類、ラベル、並び順、開閉仕様の定義
|
||||||
|
- 各画面がどのカテゴリに属するかの明確化
|
||||||
|
|
||||||
|
### 1-3. 今回やらないこと
|
||||||
|
|
||||||
|
- 各業務画面そのものの UI 改修
|
||||||
|
- 権限別メニュー出し分け
|
||||||
|
- お気に入り機能、ピン留め機能
|
||||||
|
- ナビゲーションと連動したダッシュボード内容の刷新
|
||||||
|
|
||||||
|
### 1-4. 関連 Issue との役割分担
|
||||||
|
|
||||||
|
- Issue `#13 メニューがごちゃごちゃしてきたので、整理する` は、背景、論点、判断理由を残す親議論として扱う
|
||||||
|
- 本仕様書は、その議論を踏まえた実装向けの決定事項をまとめる文書として扱う
|
||||||
|
- 後から判断理由を確認したい場合は Issue `#13` を参照する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 基本方針
|
||||||
|
|
||||||
|
### 2-1. トップレベル構成
|
||||||
|
|
||||||
|
トップナビでは以下の 5 項目のみを常時表示する。
|
||||||
|
|
||||||
|
1. ホーム
|
||||||
|
2. 計画
|
||||||
|
3. 実績
|
||||||
|
4. マスター
|
||||||
|
5. 帳票・連携
|
||||||
|
|
||||||
|
右端には従来どおりユーザー操作を置く。
|
||||||
|
|
||||||
|
- パスワード変更
|
||||||
|
- ログアウト
|
||||||
|
|
||||||
|
### 2-2. 設計ルール
|
||||||
|
|
||||||
|
- 毎日使う業務カテゴリだけをトップに置く
|
||||||
|
- 個別機能名ではなく、業務単位で束ねる
|
||||||
|
- 設定、履歴、通知、補助系は単独トップにしない
|
||||||
|
- 同じ業務の前後工程は可能な限り同じカテゴリに寄せる
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 情報設計
|
||||||
|
|
||||||
|
### 3-1. カテゴリ構成
|
||||||
|
|
||||||
|
#### ホーム
|
||||||
|
|
||||||
|
- ダッシュボード
|
||||||
|
|
||||||
|
#### 計画
|
||||||
|
|
||||||
|
- 作付け計画
|
||||||
|
- 施肥計画
|
||||||
|
- 田植え計画
|
||||||
|
- 運搬計画
|
||||||
|
|
||||||
|
#### 実績
|
||||||
|
|
||||||
|
- 散布実績
|
||||||
|
- 畔塗記録
|
||||||
|
- 作業記録
|
||||||
|
|
||||||
|
#### マスター
|
||||||
|
|
||||||
|
- 圃場管理
|
||||||
|
- 作物
|
||||||
|
- 品種
|
||||||
|
- 資材マスタ
|
||||||
|
- 肥料マスタ
|
||||||
|
|
||||||
|
#### 帳票・連携
|
||||||
|
|
||||||
|
- 在庫管理
|
||||||
|
- 帳票出力
|
||||||
|
- データ取込
|
||||||
|
- 気象
|
||||||
|
- メール
|
||||||
|
|
||||||
|
### 3-2. メールの扱い
|
||||||
|
|
||||||
|
メール関連はトップ階層に個別表示しない。
|
||||||
|
`帳票・連携 > メール` の中にまとめる。
|
||||||
|
|
||||||
|
内訳:
|
||||||
|
|
||||||
|
- メール履歴
|
||||||
|
- メールルール
|
||||||
|
|
||||||
|
### 3-3. 設定の扱い
|
||||||
|
|
||||||
|
現状はパスワード変更のみのため、独立カテゴリにはしない。
|
||||||
|
初期実装では右上アイコンからのパスワード変更導線を維持し、設定系機能が増えた場合に別途 `設定` グループ化を検討する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 画面とメニューの対応
|
||||||
|
|
||||||
|
### 4-1. 現在の主要画面の所属
|
||||||
|
|
||||||
|
| カテゴリ | ラベル | パス |
|
||||||
|
|---|---|---|
|
||||||
|
| ホーム | ダッシュボード | `/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` |
|
||||||
|
|
||||||
|
### 4-2. アクティブ表示ルール
|
||||||
|
|
||||||
|
- その画面自身、またはその配下詳細画面にいる場合、所属カテゴリをアクティブにする
|
||||||
|
- ドロップダウン内の該当項目も個別にアクティブ表示する
|
||||||
|
- パス接頭辞だけで判定するとカテゴリが衝突する画面があるため、より具体的なパスを優先して判定する
|
||||||
|
- 特に `施肥計画` と `散布実績` はともに `/fertilizer` 配下を使うため、`/fertilizer/spreading` を先に判定し、`計画` 側から明示的に除外する
|
||||||
|
- 同様に `在庫管理` と `資材マスタ` はともに `/materials` 配下を使うため、`/materials/masters` を先に判定し、`帳票・連携` 側の `在庫管理` から明示的に除外する
|
||||||
|
- 例:
|
||||||
|
- `/fertilizer` は `計画` をアクティブ
|
||||||
|
- `/fertilizer/new` は `計画` をアクティブ
|
||||||
|
- `/fertilizer/[id]` および `/fertilizer/[id]/edit` は `計画` をアクティブ
|
||||||
|
- `/fertilizer/spreading` は `実績` をアクティブ
|
||||||
|
- `/fertilizer/spreading?...` も `実績` をアクティブ
|
||||||
|
- `/materials` は `帳票・連携` をアクティブ
|
||||||
|
- `/materials/masters` は `マスター` をアクティブ
|
||||||
|
- 例:
|
||||||
|
- `/fields/123` の場合は `マスター` がアクティブ
|
||||||
|
- `/fertilizer/10/edit` の場合は `計画` がアクティブ
|
||||||
|
- `/mail/history` の場合は `帳票・連携` と `メール履歴` がアクティブ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. PC 表示仕様
|
||||||
|
|
||||||
|
### 5-1. レイアウト
|
||||||
|
|
||||||
|
PC ではヘッダーを 3 ブロック構成とする。
|
||||||
|
|
||||||
|
1. 左: ブランド名 `KeinaSystem`
|
||||||
|
2. 中央: トップメニュー 5 項目
|
||||||
|
3. 右: パスワード変更、ログアウト
|
||||||
|
|
||||||
|
### 5-2. トップメニューの見せ方
|
||||||
|
|
||||||
|
- `ホーム` は単独リンク
|
||||||
|
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン付きメニュー
|
||||||
|
- ラベルの横に開閉アイコンを表示する
|
||||||
|
- 開いたメニューは白背景のパネルとして表示する
|
||||||
|
|
||||||
|
### 5-3. 開閉ルール
|
||||||
|
|
||||||
|
- クリックで開閉
|
||||||
|
- 開いている他メニューがある場合は、それを閉じてから新しいメニューを開く
|
||||||
|
- メニュー外クリックで閉じる
|
||||||
|
- `Esc` キーで閉じる
|
||||||
|
- キーボード操作は初期実装ではブラウザ標準の `Tab` 移動を基本とする
|
||||||
|
- 矢印キーによるドロップダウン項目間移動は Phase 1 の必須要件には含めない
|
||||||
|
- 項目クリック後は遷移して閉じる
|
||||||
|
|
||||||
|
### 5-4. ドロップダウンの表示内容
|
||||||
|
|
||||||
|
各項目は以下の順で並べる。
|
||||||
|
|
||||||
|
#### 計画
|
||||||
|
|
||||||
|
1. 作付け計画
|
||||||
|
2. 施肥計画
|
||||||
|
3. 田植え計画
|
||||||
|
4. 運搬計画
|
||||||
|
|
||||||
|
#### 実績
|
||||||
|
|
||||||
|
1. 散布実績
|
||||||
|
2. 畔塗記録
|
||||||
|
3. 作業記録
|
||||||
|
|
||||||
|
#### マスター
|
||||||
|
|
||||||
|
1. 圃場管理
|
||||||
|
2. 作物
|
||||||
|
3. 品種
|
||||||
|
4. 資材マスタ
|
||||||
|
5. 肥料マスタ
|
||||||
|
|
||||||
|
#### 帳票・連携
|
||||||
|
|
||||||
|
1. 在庫管理
|
||||||
|
2. 帳票出力
|
||||||
|
3. データ取込
|
||||||
|
4. 気象
|
||||||
|
5. メール
|
||||||
|
|
||||||
|
`メール` は 2 段構造にする方法と、直接展開せず一覧モーダル風に見せる方法があるが、初期実装ではシンプルさを優先し、`帳票・連携` ドロップダウン内に個別リンクを直接置く。
|
||||||
|
|
||||||
|
初期実装の並び:
|
||||||
|
|
||||||
|
1. 在庫管理
|
||||||
|
2. 帳票出力
|
||||||
|
3. データ取込
|
||||||
|
4. 気象
|
||||||
|
5. メール履歴
|
||||||
|
6. メールルール
|
||||||
|
|
||||||
|
なお情報設計上の名称としては `メール` を維持し、将来的に機能が増えた時点で再度サブグループ化する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. スマホ表示仕様
|
||||||
|
|
||||||
|
### 6-1. 基本方針
|
||||||
|
|
||||||
|
スマホではハンバーガーメニューを採用する。
|
||||||
|
PC と同じカテゴリ構成を維持し、見た目だけ縦並びにする。
|
||||||
|
|
||||||
|
### 6-2. 表示ルール
|
||||||
|
|
||||||
|
- 初期状態ではロゴ、メニューボタン、ログアウト系導線のみ表示
|
||||||
|
- メニューボタン押下で全画面または右スライドのメニューを開く
|
||||||
|
- カテゴリはアコーディオン形式で開閉する
|
||||||
|
|
||||||
|
### 6-3. 並び順
|
||||||
|
|
||||||
|
1. ホーム
|
||||||
|
2. 計画
|
||||||
|
3. 実績
|
||||||
|
4. マスター
|
||||||
|
5. 帳票・連携
|
||||||
|
6. パスワード変更
|
||||||
|
7. ログアウト
|
||||||
|
|
||||||
|
### 6-4. 開閉ルール
|
||||||
|
|
||||||
|
- `ホーム` は単独リンクとし、タップ時はそのまま `/dashboard` へ遷移する
|
||||||
|
- カテゴリ見出しタップで開閉
|
||||||
|
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
|
||||||
|
- 項目タップ後はメニューを閉じて画面遷移する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ラベル方針
|
||||||
|
|
||||||
|
### 7-1. トップ階層ラベル
|
||||||
|
|
||||||
|
- 短く、役割が伝わる言葉を使う
|
||||||
|
- 詳細機能名は 2 階層目へ寄せる
|
||||||
|
|
||||||
|
### 7-2. 用語ルール
|
||||||
|
|
||||||
|
- `ホーム` は利用者に最も分かりやすいため維持
|
||||||
|
- `計画` は作付け、施肥、田植え、運搬を含む包括名として使用
|
||||||
|
- `実績` は記録系業務のまとめ先とする
|
||||||
|
- `マスター` は日々の業務を支える基礎データ管理の置き場とする
|
||||||
|
- `帳票・連携` は出力、取込、通知、補助参照のまとめ先とする
|
||||||
|
|
||||||
|
将来 `帳票・連携` に機能が増えすぎた場合は、次の再編を検討する。
|
||||||
|
|
||||||
|
- `帳票`
|
||||||
|
- `連携`
|
||||||
|
- `通知`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. アイコン方針
|
||||||
|
|
||||||
|
### 8-1. トップ階層
|
||||||
|
|
||||||
|
トップ階層には必要最低限のアイコンのみ使用する。
|
||||||
|
|
||||||
|
- ホーム: 家またはダッシュボード系
|
||||||
|
- 計画: 作物または計画系
|
||||||
|
- 実績: チェック、記録系
|
||||||
|
- マスター: 設定、リスト、データベース系
|
||||||
|
- 帳票・連携: ファイル、送受信、クラウド系
|
||||||
|
|
||||||
|
ただし、文字認識を優先し、アイコンは補助扱いとする。
|
||||||
|
|
||||||
|
### 8-2. ドロップダウン内
|
||||||
|
|
||||||
|
現行アイコンを流用してよいが、すべてに付ける必要はない。
|
||||||
|
視認性よりも一覧性を優先し、テキスト中心でも可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 操作性・アクセシビリティ要件
|
||||||
|
|
||||||
|
- キーボードでトップメニューにフォーカス移動できること
|
||||||
|
- `Enter` または `Space` でドロップダウンを開閉できること
|
||||||
|
- ドロップダウン展開後、各項目へ `Tab` で到達できること
|
||||||
|
- `Esc` で閉じられること
|
||||||
|
- 矢印キーによる項目間移動は初期実装の必須要件には含めず、将来のアクセシビリティ強化項目として扱う
|
||||||
|
- 現在位置が視覚的に分かること
|
||||||
|
- タップ領域は十分に確保すること
|
||||||
|
- スマホで誤タップしにくい行間と余白を確保すること
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 実装方針
|
||||||
|
|
||||||
|
### 10-1. コンポーネント構成案
|
||||||
|
|
||||||
|
`Navbar.tsx` を以下の責務に分ける。
|
||||||
|
|
||||||
|
- ブランド表示
|
||||||
|
- トップメニュー定義
|
||||||
|
- ドロップダウン表示
|
||||||
|
- モバイルメニュー表示
|
||||||
|
- 右端ユーザー操作
|
||||||
|
|
||||||
|
必要に応じて次のような補助構成へ分割してよい。
|
||||||
|
|
||||||
|
- `navGroups` 定数
|
||||||
|
- `DesktopNav`
|
||||||
|
- `MobileNav`
|
||||||
|
|
||||||
|
### 10-2. メニュー定義データ化
|
||||||
|
|
||||||
|
個別ボタンの直書きはやめ、カテゴリ配列で管理する。
|
||||||
|
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
|
||||||
|
|
||||||
|
方針:
|
||||||
|
|
||||||
|
- グループ構成そのものが定義から読み取れることを優先する
|
||||||
|
- 通常ケースは `href` ベースで扱う
|
||||||
|
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
|
||||||
|
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
|
||||||
|
|
||||||
|
想定イメージ:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type NavItem = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
match?: (pathname: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'link' | 'group';
|
||||||
|
href?: string;
|
||||||
|
items?: NavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const navGroups = [
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
label: 'ホーム',
|
||||||
|
type: 'link',
|
||||||
|
href: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'planning',
|
||||||
|
label: '計画',
|
||||||
|
type: 'group',
|
||||||
|
items: [
|
||||||
|
{ label: '作付け計画', href: '/allocation' },
|
||||||
|
{ label: '施肥計画', href: '/fertilizer' },
|
||||||
|
{ label: '田植え計画', href: '/rice-transplant' },
|
||||||
|
{ label: '運搬計画', href: '/distribution' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10-3. アクティブ判定
|
||||||
|
|
||||||
|
アクティブ判定は、現在の `pathname` と各項目の対応パターンで管理する。
|
||||||
|
|
||||||
|
基本原則:
|
||||||
|
|
||||||
|
- URL はリソース・機能識別子として安定性を優先し、メニュー階層とは分離して扱う
|
||||||
|
- メニュー再編のたびに URL を変更しない
|
||||||
|
- アクティブ判定はナビ定義側のルールで吸収する
|
||||||
|
- ただし、全件をルーターのように再定義するのではなく、通常ケースは `href` ベース、衝突ケースだけ `match` を使う
|
||||||
|
|
||||||
|
補足:
|
||||||
|
|
||||||
|
- Next.js App Router の Route Groups は、URL を変えずにコード構造を整理する手段としては有効
|
||||||
|
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
|
||||||
|
|
||||||
|
パス接頭辞衝突が起きる組み合わせは、具体パス優先で次のように扱う。
|
||||||
|
|
||||||
|
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
|
||||||
|
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
|
||||||
|
|
||||||
|
通常判定の例:
|
||||||
|
|
||||||
|
- `/fertilizer`
|
||||||
|
- `/fertilizer/new`
|
||||||
|
- `/fertilizer/[id]/edit`
|
||||||
|
|
||||||
|
はすべて `施肥計画` 所属として扱う。
|
||||||
|
|
||||||
|
- `/materials`
|
||||||
|
- `/materials?tab=...`
|
||||||
|
|
||||||
|
は `在庫管理` 所属として扱う。
|
||||||
|
|
||||||
|
実装上は、上記の衝突ケースのみ `NavItem.match` を使う想定とする。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 段階導入案
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- PC ナビを 5 分類へ再編
|
||||||
|
- `作物` `品種` はマスター体系に含めるが、Phase 1 ではメニューに表示しない
|
||||||
|
- Phase 1 の `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみとする
|
||||||
|
- `作物` `品種` は未実装項目として仕様上は位置づけ、独立画面が用意できる Phase 2 でメニュー表示を開始する
|
||||||
|
- モバイルも同じ情報設計へ変更
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- `作物管理` `品種管理` を独立画面として追加
|
||||||
|
- `帳票・連携` 内の `メール` をサブグループ化
|
||||||
|
- よく使う画面の履歴や最近使った機能を補助表示
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- 将来マルチユーザー化した場合の再設計検討
|
||||||
|
- 権限や担当業務ごとの表示最適化の要否整理
|
||||||
|
- 単独利用を前提とする間は、Phase 3 は実施対象外とする
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 受け入れ条件
|
||||||
|
|
||||||
|
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
|
||||||
|
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
|
||||||
|
- 各画面でアクティブ状態が期待通りに表示されること
|
||||||
|
- PC とスマホで同じカテゴリ構成になっていること
|
||||||
|
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 想定される懸念と対応
|
||||||
|
|
||||||
|
### 13-1. 「マスター」に含める範囲
|
||||||
|
|
||||||
|
現時点では、`圃場管理` `作物` `品種` `資材マスタ` `肥料マスタ` を `マスター` として集約する。
|
||||||
|
日々の入力対象ではなく、業務の前提データを整える画面群としてまとめるのが自然である。
|
||||||
|
|
||||||
|
補足:
|
||||||
|
|
||||||
|
- `圃場管理` は圃場マスタとして独立性が高い
|
||||||
|
- `作物` と `品種` も本来マスター管理であり、現状は allocation 画面内で扱っているだけで、メニュー上は独立させる前提で考える
|
||||||
|
- `資材マスタ` と `肥料マスタ` はすでに独立ページがあり、`マスター` 配下に置くのが自然である
|
||||||
|
- 将来、地図、圃場グループ、所有者管理などが増えた場合は、`圃場マスタ` の再独立も検討する
|
||||||
|
|
||||||
|
### 13-2. 「帳票・連携」が少し広い
|
||||||
|
|
||||||
|
現時点では `在庫管理` `帳票出力` `データ取込` `気象` `メール` をまとめる。
|
||||||
|
完全に同じ性質ではないが、いずれも補助業務、参照、連携、出力に近い機能であり、トップ階層を増やしすぎないために同居させる。
|
||||||
|
|
||||||
|
### 13-3. 「データ取込」は日常操作ではない
|
||||||
|
|
||||||
|
`データ取込` は日常的に何度も使う画面ではなく、年度切替時や初期設定時に使う補助導線である。
|
||||||
|
そのためトップレベル常設には置かず、`帳票・連携` 配下に置く判断は妥当とする。
|
||||||
|
|
||||||
|
ただし今後、取込対象や運用頻度が増えた場合は、`設定` または `運用` 系カテゴリへの移設も検討対象とする。
|
||||||
|
|
||||||
|
### 13-4. 既存ユーザーが場所を見失う
|
||||||
|
|
||||||
|
初期導入時は以下を行う。
|
||||||
|
|
||||||
|
- 並び順をできるだけ業務の流れに合わせる
|
||||||
|
- ドロップダウン内で既存画面名は変更しない
|
||||||
|
- ダッシュボード上に主要導線を残す
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 結論
|
||||||
|
|
||||||
|
現行の 1 画面 1 ボタン方式は、今後の機能追加に対して拡張性が低い。
|
||||||
|
そのため、トップナビは `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類へ再編し、個別画面はドロップダウン配下に置く。
|
||||||
|
|
||||||
|
この構成により、利用者は「画面名」ではなく「やりたい業務」から機能を探せるようになり、将来の機能追加にも耐えやすくなる。
|
||||||
Reference in New Issue
Block a user