Compare commits

...

43 Commits

Author SHA1 Message Date
Akira
8de1ae70aa .codex .mcpを除外 2026-04-09 20:40:30 +09:00
akira
3901caf668 docs: reflect todo spec review feedback 2026-04-09 16:36:19 +09:00
akira
5f58c2c686 指摘を反映して 改善案/TODO管理機能仕様書案.md を更新しました。
主な修正は、Todo.year の追加、TodoCrop / TodoVariety の具体定義追加、priority の 1000 刻み再採番ルール明記、plan_links がある場合だけ対象圃場整合性を検証する形への明確化、levee_work を「MVP では計画リンクなしの work_type」として整理、plan_type + plan_id を Serializer で各 FK に変換する方針追記、admin.py と migrations の追加、見出し階層の修正です。

あわせて、実績連携フラグ名は should_link_record に寄せました。まだコミットはしていないので、必要ならこの修正分もそのままコミットします。
2026-04-09 16:34:30 +09:00
akira
83525c2f59 docs: add todo management spec draft 2026-04-09 16:27:42 +09:00
akira
627d7e4f59 docs: tighten pesticide ingredient limit consistency 2026-04-09 15:52:29 +09:00
akira
9059b2b51e 仕様書を更新しました。更新先は 農薬散布管理編.md です。
- 有効成分総使用回数の集計方式を COUNT(DISTINCT SprayEvent.id) に変更
  (農薬取締法上「1回の散布=1回の使用」の解釈に準拠、1イベント=1回で統一)
- PesticideIngredientLimit に「同一成分・同一作物であれば製品が異なっても上限値は同一」の注記を追加
- 設計判断 #5 を更新:有効成分も製品使用回数と同様に COUNT(DISTINCT SprayEvent.id) で集計する根拠を記載
- 設計判断の番号を整理(#7〜#10 → #8〜#11)
2026-04-09 15:44:44 +09:00
akira
7d2eb1ebe2 Findings
同一イベント内で同じ有効成分を含む複数製品を使った場合、総使用回数を過少計上します。
18_マスタードキュメント_農薬散布管理編.md:39 (line 39) では「同一有効成分を含む複数製品は合算カウント」と定義していますが、集計式は 同:251 (line 251) の COUNT(DISTINCT SprayEvent.id) です。これだと 1 回の散布で MEP剤A と MEP剤B を同時使用したケースが 2 回ではなく 1 回になります。1イベント=1回 は製品単位には合っても、有効成分の「複数製品合算」とは衝突しています。

SprayEventResolvedField を正源にしたはずなのに、設計判断がまだ旧仕様のままで矛盾しています。
集計の正源は 同:232 (line 232) で SprayEventResolvedField.crop_name_snapshot に統一されていますが、設計判断では 同:599 (line 599) に「作付け計画(Plan)と照合」と残っています。さらに 同:602 (line 602) では削除したはずの crop_snapshot / variety_snapshot をまだ保持対象として書いています。実装者がここを読むと旧設計に引っ張られます。

製品使用回数も、同一イベント内の重複明細をどう扱うかが未定義で、式とモデルが噛み合っていません。
集計式は 同:239 (line 239) の COUNT(DISTINCT SprayEvent.id) ですが、明細モデルには 同:213 (line 213) 以降で event + pesticide の一意制約がありません。つまり同じ農薬を同一イベントに 2 行入れられる設計なのに、集計では 1 回に潰れます。仕様として「同一イベント内で同一農薬は1回しか登録できない」を明記して一意制約を持たせるか、重複明細の意味を定義した方が安全です。

大筋ではかなり良くなっていて、特に「作物単位での法的管理」と「圃場ごとの正源を SprayEventResolvedField に寄せた」方向は明快でした。上の3点だけ揃えると、実装時の解釈ぶれがかなり減ります。
2026-04-09 15:16:45 +09:00
akira
3e2942b479 変更内容
集計ロジックの明確化(農薬取締法の要件を明示):

集計単位の説明に「農薬取締法上、使用回数は作物単位で管理する義務がある」を明記
グループ内に複数作物が混在する場合の動作を明示 → 同一イベントの農薬が水稲・大豆それぞれの回数に +1 カウントされる
集計の正源は SprayEventResolvedField.crop_name_snapshot(圃場ごと)に統一
不要フィールドの削除:

SprayEvent.crop_snapshot / variety_snapshot を削除(役割が SprayEventResolvedField に統合されたため)
APIレスポンス例からも除去
集計式の精緻化:

「1イベント = 1回」のカウント原則を明示(COUNT(DISTINCT SprayEvent.id))— グループ内に圃場が何筆あっても1散布作業は1回
2026-04-09 15:11:15 +09:00
akira
70fe3824b3 docs: refine pesticide management spec 2026-04-09 14:56:21 +09:00
akira
10f2b6f77f 農薬散布記録作成 2026-04-09 14:54:12 +09:00
akira
6dfcd0be06 テスト結果 2026-04-07 11:47:25 +09:00
akira
1371eef648 必須の指摘を修正しました。更新したのは Navbar.tsx です。
修正内容は2点です。

/fertilizer/masters を 施肥計画 のアクティブ判定から除外
運搬計画 のアイコンを FlaskConical から Truck に変更
確認は docker compose exec -T frontend npx tsc --noEmit で通っています。今回のレビューで必須だった重複アクティブはこれで解消しています
2026-04-07 11:19:05 +09:00
akira
ac0bc7b6a9 マスタードキュメント化しました。追加したのは 17_マスタードキュメント_ナビゲーション再編編.md です。
内容は、今回の仕様案をそのまま移すだけでなく、

なぜこの再編が必要になったか
なぜ マスター / 帳票・連携 という分類にしたか
URL とメニューをどう切り分けるか
Phase 1〜3 をどう進めるか
まで、長期参照向けに整理してあります。
改善案/ナビゲーション再編仕様書.md は実装向け詳細、今回追加したマスタードキュメントは長期保管用、Issue #13 は議論の背景、という役割分担にしています。必要なら次に、既存のどこかからこの 17_...md へ参照リンクを足します。
2026-04-07 11:12:19 +09:00
akira
1c474e9692 仕様書を更新しました。更新先は ナビゲーション再編仕様書.md です。
反映した内容は次の通りです。

5-3 に、初期実装ではブラウザ標準の Tab 移動を基本とすることを追記
5-3 に、矢印キーでの項目間移動は Phase 1 の必須要件外と明記
9 に、ドロップダウン展開後は Tab で各項目へ到達できることを追加
9 に、矢印キー移動は将来のアクセシビリティ強化項目として扱うと追記
これで、キーボード操作の範囲について実装者が迷いにくくなったはずです。
2026-04-07 11:09:51 +09:00
akira
0cd90e61db 仕様書を更新しました。変更先は ナビゲーション再編仕様書.md です。
今回入れた主な追記はこの3点です。

1-4 に Issue #13 との役割分担を追加
10-2 に NavGroup / NavItem の階層構造と match の位置づけを追加
10-3 に URL とナビゲーションの分離原則、Route Groups の扱い方を追加
これで、仕様書だけ読んでも実装方針が分かり、なぜそうしているかは Issue #13 にたどれる構成になりました。必要なら次に、10-2 のサンプル navGroups を今の分類に合わせてもっと具体化します。
2026-04-07 11:01:44 +09:00
akira
8de27de335 第2版 2026-04-07 10:33:29 +09:00
akira
71b8258281 メニューの整理案 2026-04-07 10:01:02 +09:00
Akira
4516a74772 Fix sync_db.sh to run migrate after DB restore
サーバーより新しいマイグレーションがローカルに存在する場合、
リストア後にmigrateを実行しないと500エラーになるバグを修正。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:32:23 +09:00
Akira
a42ccb5cda Add local production test environment setup
- docker-compose.local.yml: 本番Dockerfile使用・Traefikなし・ポート直接公開
- deploy_local.sh: ローカル環境のビルド・起動スクリプト
- sync_db.sh: サーバーDBダンプをローカルに取り込むスクリプト
- document/20_ローカルテスト環境.md: 手順ドキュメント

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:32:23 +09:00
akira
4a1db5ef27 #12 対応を入れました。
畔塗記録 API に total_area_tan を追加して、一覧の各記録に「圃場数 / 面積合計」が出るようにしました。あわせて、作成・編集フォームの「対象圃場一覧」にも、選択中の合計面積を表示しています。主な変更は serializers.py、tests.py、page.tsx、index.ts です。

確認できたこと:

docker compose -f docker-compose.develop.yml exec backend python manage.py test apps.levee_work OK
docker exec keinasystem_frontend npm run build OK
まだコミットはしていません。必要ならこのままコミットして push します。
2026-04-06 17:23:06 +09:00
akira
c90c6210e1 Add fertilization plan merge workflow 2026-04-06 16:49:44 +09:00
akira
c675b7b7ae Move all fertilization entries on variety change 2026-04-05 18:42:09 +09:00
akira
ae0249be69 Add allocation variety change history UI 2026-04-05 16:55:44 +09:00
akira
1d5bcc9dd6 Move rice transplant entries on variety change 2026-04-05 16:49:03 +09:00
akira
98814299cf Move unspread fertilization entries on variety change 2026-04-05 16:43:26 +09:00
akira
21fb2323eb Add plan variety change tracking 2026-04-05 16:32:57 +09:00
akira
5a9b6a053b 改善案/issue_3_計画始動後の作付け変更_調査.md#L428 (line 428)
田植え計画 | 施肥と同様に対応 という決定表現が、下の詳細仕様と少しズレています。詳細では #L463 (line 463) 以降で「現時点では全件移動」と明記されているので、表も「現時点では全件移動、将来実績連携後に再設計」くらいに合わせた方が誤読されません。

改善案/issue_3_計画始動後の作付け変更_調査.md#L444 (line 444)
actual_bags = 0 は現行 services.py では null に丸められる は少し断定が強いです。現行の再集計ロジックでは未該当なら NULL になりやすい、という理解は良いのですが、将来だけでなくデータ補正や手動更新でも 0 が入り得ます。仕様書上は「未散布判定は NULL または 0 を未散布扱いとするかどうか」を明示した方が安全です。
2026-04-05 14:09:48 +09:00
akira
429a98decb 改善案/issue_3_計画始動後の作付け変更_調査.md#L442 (line 442)
旧 plan の当該エントリに対応する RESERVE を削除 という書き方は、現行実装とずれています。RESERVE はエントリ単位ではなく fertilization_plan 単位で全置換管理です。backend/apps/materials/stock_service.py (line 10) の通り、実装上は「旧 plan 全体の RESERVE を再生成」「新 plan 全体の RESERVE を再生成」と書かないと誤実装されやすいです。

改善案/issue_3_計画始動後の作付け変更_調査.md#L437 (line 437)
未散布判定を actual_bags is NULL にしているのは危険です。actual_bags は散布実績再集計の結果で、将来のロジック変更や部分散布時に 0 や端数が入る可能性がありますし、「未散布」と「部分散布」を同一扱いできません。backend/apps/fertilizer/models.py (line 72) を踏まえると、少なくとも「actual_bags is null または 0」「一部散布済みは移動不可 or 分割対象」と明文化した方が安全です。

改善案/issue_3_計画始動後の作付け変更_調査.md#L367 (line 367) と 改善案/issue_3_計画始動後の作付け変更_調査.md#L449 (line 449)
田植え計画を「施肥と同様」とまとめていますが、田植え計画には actual_bags に相当する実績概念がまだありません。backend/apps/plans/serializers.py (line 177) 現状では「全 Entry 移動」なのか「将来の実績連携を見越して未実施分のみ移動」なのかを切り分けて書く必要があります。今の書き方だと、施肥と同じ判定軸があるように読めます。

改善案/issue_3_計画始動後の作付け変更_調査.md#L461 (line 461)
actual_bags 集計ロジックは「影響なし」と言い切らない方がいいです。今回の方針なら大きな改修は不要ですが、前提は「同一年・同圃場・同肥料の行が複数計画にまたがって共存しないこと」です。これは仕様上の制約なので、「影響なし」ではなく「現方針では再利用可能。ただし重複行を作らないことが前提」と書くのが正確です。
2026-04-05 14:07:59 +09:00
akira
4299c6eb4b 改善案No2 2026-04-05 14:00:50 +09:00
akira
8dd680e28a Update rice transplant plan spec document 2026-04-05 13:18:51 +09:00
akira
3eb2852b78 修正しました。
原因は RiceTransplantEditPage.tsx の初期値セット用 useEffect で、新規作成時に isNew を条件にしていたため、反当苗箱枚数 を入力しても毎回デフォルト値で上書きされていたことです。これを seedlingBoxesPerTan === '' のときだけ初期値を入れるように直したので、今は手入力できるはずです。

あわせて、同じファイルで 面積(反) は toFixed(2) 表示に変更しました。反当苗箱枚数 は入力欄のまま 1 桁運用に寄せる前提で、表示系はご要望に近づけています。再読み込みしてもう一度画面操作してみてください。
2026-04-05 12:23:22 +09:00
akira
5c2d17fe0a 大丈夫ではあるのですが、1本 migration が足りていませんでした。原因はこれです。
0006 で seedling_boxes_per_tan を installed_seedling_boxes にリネーム
その結果、DB 上の field メタ情報には元の表示名が残る
モデル側では今 verbose_name='設置苗箱枚数'
Django がその差分を AlterField として検出
なので、出ていた 0009_alter_ricetransplantentry_installed_seedling_boxes.py は正当です。こちらで 0009_alter_ricetransplantentry_installed_seedling_boxes.py を追加しました。

次はこれで進めれば大丈夫です。

git pull でこの migration を server 側へ反映
docker compose exec backend python manage.py migrate
必要なら docker compose exec backend python manage.py makemigrations plans --dry-run --verbosity 3 で警告が消えたか確認
これで plans の未反映 migration 警告は消えるはずです。
2026-04-05 12:17:04 +09:00
akira
182ef5d83d 該当エラーは直しました。
frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx に Variety 型の import を追加して、find のコールバック引数に明示型を付けています。今回の Parameter 'variety' implicitly has an 'any' type はこれで解消するはずです。

そのままもう一度 npm run build を回してみてください。もし次の型エラーが出たら、続けて直します。
2026-04-05 12:08:24 +09:00
akira
0131982c34 種子資材ベースへの切り替えを反映しました。
frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx では、田植え計画の主選択を 品種 から 種子資材 に変更し、画面上は種子資材名だけで進めるようにしました。保存時だけ内部的に、その種子資材に紐づく Variety を解決して既存 API に送っています。候補圃場取得もその内部参照で動く形です。frontend/src/app/rice-transplant/page.tsx の一覧表示も 作物 / 品種 ではなく 種子資材 列に変更しました。

仕様書 document/16_マスタードキュメント_田植え計画編.md も 年度 × 種子資材 を軸にした説明へ更新済みです。確認できたのは Python 側の py_compile までで、フロントのビルド確認はまだしていません。Issue #2 にも今回の変更内容をコメント済みです。
2026-04-05 12:04:36 +09:00
akira
491f05eee8 その判断で進めました。在庫管理を先に固めるように切り替えて、手元の実装もそちらを優先して直しています。
今回入れたのは、在庫履歴の編集・削除対応と、種子資材を資材マスタ側で品種に直接結び付ける流れです。backend/apps/materials/views.py と backend/apps/materials/serializers.py で stock-transactions の更新を許可しつつ、計画や実績に紐づく履歴はロック扱いにしました。画面側は frontend/src/app/materials/page.tsx、frontend/src/app/materials/_components/StockOverview.tsx、frontend/src/app/materials/_components/StockTransactionForm.tsx を更新して、手動登録の入出庫履歴をあとから編集・削除できるようにしています。

あわせて frontend/src/app/materials/masters/page.tsx と frontend/src/app/materials/_components/MaterialForm.tsx で、種子 タブから品種を直接選んで紐付ける形にしました。重複を避けるため、作付け計画側の品種管理モーダル frontend/src/app/allocation/page.tsx から種子資材の紐付け UI は外しています。Issue #2 にもこの方針でコメント追記済みです。

確認できたのは python3 -m py_compile までで、フロントのビルドは frontend/node_modules が無いためまだ回していません。次はブラウザで在庫管理の操作感を確認してから、田植え計画側の細部を詰めるのがよさそうです。
2026-04-05 11:43:03 +09:00
akira
a38472e4a0 品種ごとの種子在庫前提まで実装を進めました。
主な変更は、seed 資材種別の追加と Variety.seed_material の導入です。backend/apps/materials/models.py、backend/apps/plans/models.py、backend/apps/plans/serializers.py で、田植え計画が作物在庫ではなく品種に紐づく種子資材の現在庫を参照するように切り替えました。マイグレーションは backend/apps/materials/migrations/0005_material_seed_type.py と backend/apps/plans/migrations/0008_variety_seed_material.py を追加しています。

画面側は、frontend/src/app/materials/page.tsx と frontend/src/app/materials/masters/page.tsx に「種子」タブを追加し、frontend/src/app/allocation/page.tsx の品種管理モーダルで品種ごとに種子在庫資材を設定できるようにしました。田植え計画画面 frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx も、苗箱数 列中心に整理し、種もみkg 列を削除、反当苗箱枚数 の列反映と ≈ / ↩ の四捨五入トグルを施肥計画寄りの操作感に寄せています。仕様書 document/16_マスタードキュメント_田植え計画編.md も更新済みです。

確認できたのは python3 -m py_compile backend/apps/materials/models.py backend/apps/materials/serializers.py backend/apps/plans/models.py backend/apps/plans/serializers.py backend/apps/plans/views.py までです。frontend/node_modules が無いためフロントのビルド確認はまだできていません。Issue #2 にも反映内容をコメント済みです。必要なら次にコミットします。
2026-04-05 11:22:07 +09:00
akira
11b36b28a5 Issue #2 に最新の理解を記録し、その内容で仕様書と実装を修正しました。
document/16_マスタードキュメント_田植え計画編.md は、「行ごとに保持するのは圃場の苗箱数」「列側に反当苗箱枚数を持つ」「種もみg/箱 は全体共通値」という前提に更新しています。コード側は backend/apps/plans/models.py と backend/apps/plans/serializers.py で計画ヘッダに seedling_boxes_per_tan を追加し、backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py も作成しました。画面は frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx を施肥計画寄りに組み直し、列単位のデフォルト反映と四捨五入、行ごとの苗箱数入力に寄せています。frontend/src/types/index.ts も合わせて更新済みです。

確認できたのはバックエンドの構文チェックまでで、python3 -m py_compile backend/apps/plans/models.py backend/apps/plans/serializers.py backend/apps/plans/views.py は通過しています。フロントのビルド確認まではこの環境では回していません。Issue #2 にも今回の反映内容をコメント済みです。
2026-04-05 10:53:24 +09:00
akira
95c90dd699 Adjust rice transplant plan to store installed box counts 2026-04-05 10:26:14 +09:00
akira
9bcc5e5e21 butler2 と同じ issue テンプレートを設定しました。
追加したのは bug.md、design.md、feature.md の 3 つで、内容とラベル指定も butler2 に揃えています。
2026-04-05 08:16:37 +09:00
akira
0c57dd7886 Add rice transplant planning feature 2026-04-04 17:26:55 +09:00
akira
f236fe2f90 ソートできるようにしました。page.tsx
畔塗画面の対象圃場一覧で、圃場 / 面積 / グループ / 品種 の各ヘッダーを押すと昇順・降順を切り替えられます。初期状態は 圃場名昇順 です。選択状態はそのまま維持されるので、並べ替えてもチェックが外れることはありません。

必要なら次に、ソートだけでなく検索欄も足せます。圃場数が多いなら検索もかなり効きます。
2026-04-04 12:07:41 +09:00
akira
b7b9818855 feat: add levee work records 2026-04-04 11:32:26 +09:00
akira
c773c7d3b8 docs: add levee work master document 2026-04-04 11:13:11 +09:00
73 changed files with 8186 additions and 304 deletions

View File

@@ -70,11 +70,19 @@
"Bash(git status:*)", "Bash(git status:*)",
"Bash(npx next:*)", "Bash(npx next:*)",
"mcp__butler__butler__list_skills", "mcp__butler__butler__list_skills",
"mcp__butler__butler__get_skill_usage" "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"
] ]
} }
} }

View File

@@ -0,0 +1,26 @@
---
name: バグ報告
about: 不具合・予期しない動作の報告
labels: バグ
---
## 現在の状態なぜOpenか
1行で
## 次にすることNext Action
1行で
## ブロック要因
(なければ「なし」)
---
## 問題の概要
## 再現手順
## 期待する動作
## 実際の動作
## 関連

View File

@@ -0,0 +1,24 @@
---
name: 設計・方針決定
about: 実装前の設計議論・方針決定が必要なもの
labels: "種別: 設計待ち"
---
## 現在の状態なぜOpenか
1行で
## 次にすることNext Action
1行で
## ブロック要因
(なければ「なし」)
---
## 問題・背景
## 検討事項
## 完了条件
## 関連

View File

@@ -0,0 +1,24 @@
---
name: 機能追加
about: 新機能・改善提案
labels: 機能
---
## 現在の状態なぜOpenか
1行で
## 次にすることNext Action
1行で
## ブロック要因
(なければ「なし」)
---
## 概要
## 背景・目的
## 完了条件
## 関連

2
.gitignore vendored
View File

@@ -15,3 +15,5 @@ postgres_data/
nul nul
*.tsbuildinfo *.tsbuildinfo
.mcp.json
.codex

View File

@@ -107,6 +107,8 @@ ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` | | 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` | | 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` | | 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
| データモデル全体 | `document/03_データ仕様書.md` | | データモデル全体 | `document/03_データ仕様書.md` |
--- ---

View File

@@ -1,6 +1,6 @@
# 現在の作業状況 # 現在の作業状況
> **最終更新**: 2026-03-16 > **最終更新**: 2026-04-04
> **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中 > **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中
## 実装済み機能Phase 1 - MVP ## 実装済み機能Phase 1 - MVP
@@ -34,6 +34,11 @@
- 軽トラ1回分単位、グループ一括割り当て、回間移動 - 軽トラ1回分単位、グループ一括割り当て、回間移動
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md` - マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert 12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert
13. **田植え計画**MVP実装:
- 年度×品種単位で苗箱枚数・種もみ使用量を計画
- 作物単位の種もみ在庫kg、品種単位の反当苗箱枚数デフォルト
- 作付け計画から候補圃場を自動取得
- マスタードキュメント: `document/16_マスタードキュメント_田植え計画編.md`
## 既知の課題・技術的負債 ## 既知の課題・技術的負債

View File

@@ -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)

View File

@@ -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

View 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())

View File

@@ -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]

View File

@@ -0,0 +1 @@

View 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]

View 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 = '畔塗作業'

View 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')},
},
),
]

View File

@@ -0,0 +1 @@

View 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}'

View 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

View 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')

View 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)),
]

View 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)

View 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='資材種別',
),
),
]

View File

@@ -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', 'その他'

View File

@@ -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()

View File

@@ -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):
"""在庫集計一覧""" """在庫集計一覧"""

View File

@@ -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')},
},
),
]

View File

@@ -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',
),
]

View File

@@ -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='反当苗箱枚数'),
),
]

View 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='種子在庫資材',
),
),
]

View File

@@ -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='設置苗箱枚数',
),
),
]

View 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'],
},
),
]

View File

@@ -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}'

View File

@@ -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,
)

View 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)

View 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

View File

@@ -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)

View File

@@ -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 = [

View File

@@ -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()
if plan is None:
Plan.objects.create(
field_id=field_id, field_id=field_id,
year=year, year=year,
defaults={'crop': crop, 'variety': variety} crop=crop,
variety=variety,
) )
if was_created:
created += 1 created += 1
else: continue
update_plan_with_variety_tracking(
plan,
crop=crop,
variety=variety,
)
updated += 1 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)

View File

@@ -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='畔塗記録',
),
),
]

View File

@@ -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()}'

View File

@@ -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

View File

@@ -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,
},
)

View File

@@ -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

View File

@@ -46,6 +46,7 @@ INSTALLED_APPS = [
'apps.fertilizer', 'apps.fertilizer',
'apps.materials', 'apps.materials',
'apps.workrecords', 'apps.workrecords',
'apps.levee_work',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -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')),
] ]

37
deploy_local.sh Executable file
View 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
View 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:

View 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`
---
## まとめ
畔塗作業機能は、
「当年の水稲作付け圃場を候補として出し、日付単位で複数圃場をまとめて記録し、作業記録一覧へ自動反映する」
というシンプルな構成を基本とする。
この構成により、既存の作付け計画・作業記録の設計を壊さずに、
春作業の記録を自然に追加できる。

View 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` |

View 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`

View 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 等) |

View 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`)を使用

View File

@@ -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,6 +934,7 @@ export default function AllocationPage() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2">
<select <select
value={selectedVarietyId || ''} value={selectedVarietyId || ''}
onChange={(e) => { onChange={(e) => {
@@ -894,6 +956,21 @@ export default function AllocationPage() {
))} ))}
{selectedCropId > 0 && <option value="__add__">+ ...</option>} {selectedCropId > 0 && <option value="__add__">+ ...</option>}
</select> </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,8 +1109,9 @@ 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">
<span className="text-sm font-medium text-gray-900">{v.name}</span>
<button <button
onClick={() => handleDeleteVariety(v.id, v.name)} onClick={() => handleDeleteVariety(v.id, v.name)}
className="text-red-400 hover:text-red-600 p-1" className="text-red-400 hover:text-red-600 p-1"
@@ -1041,6 +1119,12 @@ export default function AllocationPage() {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </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>
);
}

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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,6 +247,20 @@ export default function MaterialForm({
/> />
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
{tab === 'seed' ? (
<select
className={inputClassName}
value={form.seed_variety_id}
onChange={(e) => onBaseFieldChange('seed_variety_id', e.target.value)}
>
<option value=""></option>
{seedVarietyOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
) : (
<select <select
className={inputClassName} className={inputClassName}
value={form.material_type} value={form.material_type}
@@ -252,6 +269,7 @@ export default function MaterialForm({
<option value="other"></option> <option value="other"></option>
<option value="seedling"></option> <option value="seedling"></option>
</select> </select>
)}
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
<input <input

View File

@@ -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>

View File

@@ -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;
} }
if (editingTransaction) {
setMaterialId(String(editingTransaction.material));
setTransactionType(editingTransaction.transaction_type);
setQuantity(editingTransaction.quantity);
setOccurredOn(editingTransaction.occurred_on);
setNote(editingTransaction.note || '');
} else {
setMaterialId(presetMaterialId ? String(presetMaterialId) : ''); setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase'); setTransactionType(presetTransactionType ?? 'purchase');
setQuantity(''); setQuantity('');
setOccurredOn(today()); setOccurredOn(today());
setNote(''); 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}

View File

@@ -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">

View File

@@ -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>

View 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)} />;
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,5 @@
import RiceTransplantEditPage from '../_components/RiceTransplantEditPage';
export default function NewRiceTransplantPage() {
return <RiceTransplantEditPage />;
}

View 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>
);
}

View File

@@ -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"

View File

@@ -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">
<div className="hidden items-center gap-1 lg:flex">
{userActions.map((item) => (
<button <button
onClick={() => router.push('/settings/password')} key={item.href}
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" onClick={() => navigateTo(item.href)}
title="パスワード変更" className={`rounded-md px-3 py-2 text-sm transition-colors ${
isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
title={item.label}
> >
<KeyRound className="h-4 w-4" /> {item.icon ? <item.icon className="h-4 w-4" /> : item.label}
</button> </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>
</div>
<div className="flex items-center gap-2 lg:hidden">
<button
onClick={() => navigateTo('/settings/password')}
className={`rounded-md p-2 transition-colors ${
isItemActive(userActions[0], pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
title="パスワード変更"
>
<KeyRound className="h-5 w-5" />
</button>
<button
onClick={toggleMobileMenu}
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-expanded={mobileMenuOpen}
aria-label="メニューを開く"
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{mobileMenuOpen && (
<div className="border-t border-gray-200 py-3 lg:hidden">
<div className="space-y-1">
{navGroups.map((group) =>
group.type === 'link' ? (
<MobileLinkButton
key={group.key}
group={group}
pathname={pathname}
onNavigate={navigateTo}
/>
) : (
<MobileGroupButton
key={group.key}
group={group}
isOpen={openMobileGroups.includes(group.key)}
pathname={pathname}
onNavigate={navigateTo}
onToggle={toggleMobileGroup}
/>
)
)}
<button
onClick={handleLogout}
className="mt-3 flex w-full items-center rounded-lg px-3 py-3 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
<LogOut className="mr-3 h-4 w-4" />
</button> </button>
</div> </div>
</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;
}

View File

@@ -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
View 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 "=== 同期完了 ==="

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View 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 優先、難しければ矢印移動で代替する

View 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単位` へ変更する大規模リファクタ(中長期)

View 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 分類へ再編し、個別画面はドロップダウン配下に置く。
この構成により、利用者は「画面名」ではなく「やりたい業務」から機能を探せるようになり、将来の機能追加にも耐えやすくなる。