Compare commits

..

62 Commits

Author SHA1 Message Date
akira
f04e1884d9 butlerを使えるようにする 2026-04-10 16:57:22 +09:00
akira
cc6823b071 docs: levee_work をトラクター作業(tractor_work)に再設計
- doc/15 を畔塗作業編からトラクター作業編に改訂
  荒代掻き・植代掻き・耕耘を追加、TractorWorkSession モデル導入
- doc/19 TODO管理編: work_type の levee_work → tractor_work 置換、
  work_subtype フィールド追加、TodoCompletionLink に tractor_work 追記
- Issue #21(代掻き実績登録)の仕様策定に対応
2026-04-10 13:39:47 +09:00
akira
b7b5ce3943 docs: TODO管理機能のマスタードキュメントを作成し仕様書案を更新
- 論点1-5の決定を仕様書案に反映
- document/19_マスタードキュメント_TODO管理編.md を新規作成
- CLAUDE.md のマスタードキュメント一覧に TODO管理を追加
2026-04-10 13:14:07 +09:00
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
Akira
edd2f2a274 現状でコミット 2026-03-27 14:59:25 +09:00
Akira
00fd4a8cba 削除: データモデル詳細(145行)→ 「document/03_データ仕様書.md を参照」に集約
移動: 実装状況・既知の課題・次のマイルストーン → TASK_CONTEXT.md へ
削除: 更新履歴(git log で追える)
圧縮: ディレクトリ構造、トラブルシューティング、作業パターンを要約
維持: 絶対制約、コーディング規約、デプロイコマンド、マスタードキュメントへのリンク
2026-03-18 09:25:44 +09:00
Akira
13c21ed7de ローカル更新済み:
13_マスタードキュメント_施肥計画編.md — 散布実績セクション整備、在庫連携・集計ルール・WorkRecord自動生成・前年度コピーのセクション追加、旧「散布確定モーダル」記述削除、型定義・ファイル構成・将来の拡張を更新
14_マスタードキュメント_分配計画編.md — 散布実績との連携・WorkRecord自動生成のセクション追加
CLAUDE.md — データモデル(SpreadingSession/Item, WorkRecord, actual_bags)追加、プロジェクト構造にfertilizer/workrecordsアプリ追加、実装状況に散布実績・作業記録索引を追記、更新履歴に2026-03-17エントリ追加
2026-03-17 20:31:22 +09:00
Akira
daae1a42e5 散布実績: 名称未入力時のバリデーションエラーを追加
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:05:36 +09:00
Akira
4e06318985 散布実績ページ: useSearchParamsをSuspense boundaryでラップ(本番ビルドエラー修正)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:03:16 +09:00
Akira
9f96d1f820 散布実績レビュー修正: バグ修正・仕様適合・デッドコード削除
- 候補API: 運搬済みフィルタ(date IS NOT NULL)を追加。
  delivery_plan_id指定時は全明細表示、年度全体時のみ日付フィルタ適用
- StockTransaction.spreading_item: CASCADE→SET_NULL に修正(仕様7.3準拠)
- perform_destroy: SET_NULL対応でUSEを明示削除してからsession削除
- ConfirmSpreadingModal.tsx: 未使用のため削除
- FertilizerEditPage.tsx: 旧散布確定関連デッドコード全除去
  (isConfirmed/confirmedAt state, handleUnconfirm, 確定取消ボタン, 確定済みバナー)
- services.py: 未使用のto_decimal_or_zero削除

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:56:13 +09:00
Akira
140d5e5a4d 施肥散布実績機能を実装し運搬・作業記録・在庫連携を追加 2026-03-17 19:28:52 +09:00
Akira
865d53ed9a 仕様書に追記しました。更新先は 施肥散布実績連携変更実装仕様.md です。
今回追加した内容は主にこの5点です。

FertilizationEntry.actual_bags の追加
SpreadingSessionItem 保存・更新・削除時の actual_bags 再集計ルール
copy_from_previous_year で actual_bags があれば次年度 bags 初期値に使う方針
施肥計画一覧・編集画面での 計画値 / 実績値 併記
RESERVE = bags、USE = actual_bags の併存整理
受け入れ条件にも、

actual_bags が再集計されること
計画値と実績値の両方が見えること
前年度コピーで actual_bags を使えること
を追加しています。
今回は仕様書更新のみで、コード変更やテストはしていません。必要なら次に、この内容をマスタードキュメント側へ反映します
2026-03-17 17:24:25 +09:00
Akira
c9ae99ebc8 CODEX版
昨日運んだ肥料を散布してきました。
それで、今は施肥計画に「散布確定」ボタンがあるのですが、それだと実態に合わない事がわかりました。
実際には運搬計画を元に、運んだ肥料を散布します。
順序は、運搬計画の1回目2回目などの順序には関係がなく
運搬計画のすべての中から、全部または一部の圃場に対して散布します。
散布中に、運搬計画から実際の散布袋数が変更になる場合があるので、変更に対処できなければなりません。。散布は日付単位で行い、その日付を元に作業記録が自動的に作成されるようにしたいです。
運搬計画にも日付をつけたので、それも作業記録が自動的に作成されるようにしたいです。

以上のような感じで、変更実装仕様を作成してもらえますか?
2026-03-17 16:26:41 +09:00
Akira
9dbbb48ee0 運搬計画PDF: 袋数を整数 or 小数1桁で表示(4桁表示を修正)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:15:14 +09:00
Akira
1f26d5001b ドキュメント更新: 運搬計画の実装状況を本番稼働中に、グループ操作機能を追記
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:09:55 +09:00
Akira
722ac4efd0 運搬計画: グループ単位の回間移動・未割当戻し機能を追加
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:04:06 +09:00
Akira
bba04f24c2 運搬計画: グループ一括割り当て機能を追加
各回の追加ドロップダウンに「+ グループを追加...」を追加。
グループ内の全圃場の未割り当て分を一括で回に追加できるようにした。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:53:32 +09:00
Akira
287a1ebb59 Set イテレーションを Array.from() に修正: 本番ビルドの TypeScript エラーを解消
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:40:06 +09:00
Akira
1c27a66691 分配計画を運搬計画に再設計: 軽トラ1回分を基本単位とする運搬回モデルを導入
実運用のワークフロー(複数施肥計画混在・軽トラ複数回・肥料指定)に合わせ、
旧 DistributionPlan/Group/GroupField を DeliveryPlan/Group/GroupField/Trip/TripItem に置き換え。
施肥計画への直接FK廃止→年度ベースで全施肥計画を横断。
回ごとの日付記録、圃場の回間移動、対象肥料フィルタ、回ごとPDF出力に対応。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:29:01 +09:00
Akira
eba6267495 変更したドキュメント
ファイル	変更内容
14_マスタードキュメント_分配計画編.md	全面改訂: 旧「分配計画」→ 新「運搬計画」。データモデル5テーブル、API仕様、画面UI操作、PDFフォーマットを記載
CLAUDE.md	データモデル概要(Distribution* → Delivery* に差し替え)、実装状況セクション、更新履歴を更新
13_マスタードキュメント_施肥計画編.md	OUT スコープの「圃場への配置計画」を「運搬計画」への参照に修正
内容を確認して、問題なければ実装に進みます。
2026-03-16 16:05:46 +09:00
109 changed files with 13642 additions and 2392 deletions

View File

@@ -64,11 +64,25 @@
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_0b7fd53b80bd968a__ echo \"=== Stock summary \\(should show reserved\\) ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
"Bash(git diff:*)"
"Bash(git diff:*)",
"mcp__serena__find_symbol",
"mcp__serena__get_symbols_overview",
"Bash(git status:*)",
"Bash(npx next:*)",
"mcp__butler__butler__list_skills",
"mcp__butler__butler__get_skill_usage",
"mcp__butler__inspect_runtime_config",
"mcp__butler__execute_task",
"Bash(git -C /home/akira/develop/keinasystem remote -v)",
"Bash(cat butler/skills/read_from_gitea*)",
"Bash(bash ~/.claude/scripts/gitea.sh GET /repos/akira/keinasystem/issues/11)"
],
"additionalDirectories": [
"C:\\Users\\akira\\AppData\\Local\\Temp",
"C:\\Users\\akira\\Develop\\keinasystem_t02"
"C:\\Users\\akira\\Develop\\keinasystem_t02",
"/home/akira/develop",
"/home/akira/.docker",
"/tmp"
]
}
}

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
*.tsbuildinfo
.mcp.json
.codex

View File

@@ -0,0 +1 @@
keinasystem_t02 は農業生産者向けの作付け計画・圃場管理システム。主要スタックは Django/DRF/PostgreSQL(PostGIS) のバックエンドと Next.js 14 App Router + TypeScript + Tailwind CSS のフロントエンド。backend/apps に fields, plans, weather, reports, fertilizer, materials, mail があり、frontend/src/app に各画面がある。ドキュメント駆動で、CLAUDE.md と document/*.md が重要な仕様ソース。Windows 環境で Docker Compose による開発を前提としている。

View File

@@ -0,0 +1 @@
コードと仕様の変更はドキュメントドリブンで進める。仕様変更時は document 配下や CLAUDE.md の更新が重要。バックエンドは Django/DRF の標準的なモデル・serializer・viewset 構成、フロントは Next.js App Router と TypeScript。完了時は影響範囲に応じて少なくとも関連ドキュメント確認、必要な migration 確認、frontend lint (`npm run lint`) や対象 API/画面の動作確認を行う。既存の dirty worktree は勝手に戻さない。

View File

@@ -0,0 +1 @@
Windows 環境の主要コマンド: `git status`, `rg <pattern>`, `Get-ChildItem`, `Get-Content <file>`, `docker compose -f docker-compose.develop.yml up -d`, `docker compose exec backend python manage.py migrate`, `docker compose exec backend python manage.py makemigrations`, `docker compose exec backend python manage.py runserver 0.0.0.0:8000`, `cd frontend; npm install; npm run dev`, `cd frontend; npm run lint`。開発用 compose では backend は `python manage.py runserver 0.0.0.0:8000`、frontend は `npm run dev` を利用する。

View File

@@ -133,3 +133,17 @@ symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}

22
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"mcpServers": {
"butler": {
"command": "uv",
"args": ["run", "python", "-m", "butler.mcp_facade"],
"cwd": "../butler2"
},
"serena": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/oraios/serena",
"serena",
"start-mcp-server",
"--context",
"ide-assistant",
"--project",
"."
]
}
}
}

551
CLAUDE.md
View File

@@ -1,519 +1,128 @@
# Keina System - Claude 向けガイド
> **最終更新**: 2026-03-05
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
## プロジェクト概要
## 📌 このファイルの目的
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
## ⚠️ Claude への重要な指示
**このファイルは、セッションごとに必ず最初に読んでください。**
さらに、以下のルールを厳守してください:
### 📝 更新義務
**ドキュメントドリブンの徹底**
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
**機能追加・変更時は、必ずこのファイルを更新すること。**
- ✅ 新機能実装時 → 「実装状況」セクションを更新
- ✅ データモデル変更時 → 「データモデル概要」を更新
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
- ✅ 問題解決時 → 「トラブルシューティング」に追加
- ✅ 更新時は必ず「更新履歴」セクションに記録
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
---
## 🎯 プロジェクト概要30秒で理解
**何を作っているか:**
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
ユーザーは65歳の農家元プログラマー、シングルユーザー、39筆の圃場を管理。
**ユーザー:**
65歳の農家元プログラマー、シングルユーザー、39筆の圃場を管理
**技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
**技術スタック:**
- Backend: Django 5.2 + DRF + PostGIS
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
- Database: PostgreSQL 16 + PostGIS 3.4
**開発方針:**
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
**開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
---
## 📂 プロジェクト構造
## 絶対に守るべき制約
1. **Field ↔ OfficialKyosaiField / OfficialChusankanField は M:N** — 決してFK (1:N) に戻さない
2. **年度+圃場の組み合わせは1つの Plan のみ** (`unique_together`)
3. **面積**: 表示=反(tan)、計算・保存=m2、変換: 1反=1000m2
4. **FertilizationEntry.fertilizer は PROTECT** — 使用中の肥料は削除不可
5. **3回同じコードを書くまでは抽象化しない**
6. **ドキュメントドリブン**: 仕様変更時はまず関連ドキュメントから更新する
## コーディング規約
- **Backend**: Django ベストプラクティス、日本語フィールドは `verbose_name` で対応
- **Frontend**: TypeScript strict mode、ESLint に従う
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
---
## プロジェクト構造
```
keinasystem_t02/
├── CLAUDE.md # このファイルClaude向けガイド
├── .cursor/
│ └── rules/
│ └── 30_Cursorガイド.md # Cursor専用ガイド
├── document/ # 詳細設計書(人間向け)
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
│ ├── 01_プロダクトビジョン.md
│ ├── 02_ユーザーストーリー.md
│ ├── 03_データ仕様書.md
│ ├── 04_画面設計書.md
│ └── 05_実装優先順位.md
├── CLAUDE.md # このファイル
├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
├── document/ # 設計書・マスタードキュメント
├── backend/
│ ├── keinasystem/ # Django設定
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
│ │ └── urls.py # ルートURL設定
│ ├── keinasystem/ # Django設定 (settings.py, urls.py)
│ └── apps/
│ ├── fields/ # 圃場管理アプリ
│ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
│ ├── views.py # インポート機能、CRUD API
│ └── urls.py
│ ├── plans/ # 作付け計画アプリ
│ ├── models.py # Plan, Crop(+base_temp), Variety
│ └── views.py # 作付け計画API、集計API
│ ├── weather/ # 気象データアプリ
├── models.py # WeatherRecord (1日1行)
├── views.py # sync(APIキー), records, summary, gdd, similarity
│ ├── urls.py
│ └── management/commands/fetch_weather.py # 初回一括取得・差分取得
── reports/ # 申請書生成アプリ
├── views.py # PDF生成API
└── templates/ # PDF用HTMLテンプレート
└── frontend/
└── src/app/
├── allocation/ # 作付け計画編集画面(メイン)
├── fields/ # 圃場一覧・詳細
├── reports/ # 申請書ダウンロード
├── import/ # データ取込画面
├── mail/
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
│ ├── history/ # メール処理履歴
│ └── rules/ # 送信者ルール管理
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
└── settings/
└── password/ # パスワード変更
│ ├── fields/ # 圃場管理Field, OfficialKyosaiField, OfficialChusankanField
├── plans/ # 作付け計画Plan, Crop, Variety
├── weather/ # 気象データWeatherRecord
├── reports/ # 申請書PDF生成
│ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
├── workrecords/ # 作業記録索引
└── mail/ # メールフィルタリングWindmill連携
└── frontend/src/app/
├── allocation/ # 作付け計画編集(メイン画面)
├── fields/ # 圃場一覧・詳細
├── fertilizer/ # 施肥計画・散布実績
├── distribution/ # 運搬計画
── weather/ # 気象データ
├── reports/ # 申請書DL
├── import/ # データ取込
├── mail/ # メール管理
└── settings/ # パスワード変更
```
---
## 🗄️ データモデル概要
### コアエンティティ
```
Field (実圃場)
├── 39筆の実際の農地
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
├── group_name, display_order (グループ分け・表示順)
└── ManyToMany関係
├── kyosai_fields (共済マスタ、M:N)
└── chusankan_fields (中山間マスタ、M:N)
OfficialKyosaiField (共済マスタ)
└── 31区画水稲共済細目書用
OfficialChusankanField (中山間マスタ)
├── 71区画中山間地域等直接支払交付金用
└── 17フィールド: c_id, chusankan_flag, oaza, aza, chiban,
branch_num, land_type, area, planting_area,
original_crop, manager, owner, slope,
base_amount, steep_slope_addition, smart_agri_addition,
payment_amount
Plan (作付け計画)
├── field (FK to Field)
├── year (年度)
├── crop (FK to Crop)
├── variety (FK to Variety, nullable)
└── unique_together = ['field', 'year']
Crop (作物マスタ)
├── name米、トウモロコシ、エンドウ、野菜、その他
└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加
Variety (品種マスタ)
├── crop (FK to Crop)
├── name (品種名)
└── unique_together = ['crop', 'name']
MailSender (送信者ルール)
├── email (EmailField, nullable)
├── domain (CharField, nullable)
├── rule ('never_notify' | 'always_notify')
└── ConstraintCheck: email/domain どちらか一方のみ
MailEmail (受信メール記録)
├── account (gmail / gmail_service / hotmail / xserver1〜xserver6、旧データxserver)
├── message_id (unique)
├── sender_email, sender_domain
├── subject, body_preview
├── received_at, llm_verdict (important/not_important)
├── notified_at (LINE通知日時、nullable)
└── feedback (important/not_important/never_notify/always_notify, nullable)
MailNotificationToken (フィードバックURL用トークン)
├── email (OneToOne FK to MailEmail)
└── token (UUID, unique)
WeatherRecord (日次気象記録)
├── date (DateField, unique)
├── temp_mean, temp_max, temp_min (気温℃)
├── sunshine_h (日照時間h)
├── precip_mm (降水量mm)
├── wind_max (最大風速m/s)
└── pressure_min (最低気圧hPa)
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
Fertilizer (肥料マスタ)
├── name肥料名、必須・unique
├── makerメーカー、任意
├── capacity_kg1袋重量kg、任意
├── nitrogen_pct / phosphorus_pct / potassium_pct成分%、任意)
└── notes備考、任意
FertilizationPlan (施肥計画)
├── name計画名
├── year年度
└── variety (FK to plans.Variety)
FertilizationEntry (施肥エントリ・中間テーブル)
├── plan (FK to FertilizationPlan, CASCADE)
├── field (FK to fields.Field, CASCADE)
├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可
├── bags袋数、Decimal
└── unique_together = ['plan', 'field', 'fertilizer']
DistributionPlan (分配計画)
├── fertilization_plan (FK to FertilizationPlan, CASCADE)
├── name計画名
└── groups → DistributionGroup
DistributionGroup (分配グループ)
├── distribution_plan (FK to DistributionPlan, CASCADE)
├── nameグループ名
├── order表示順
└── unique_together = ['distribution_plan', 'name']
DistributionGroupField (グループ圃場割り当て)
├── distribution_plan (FK to DistributionPlan, CASCADE) ← 一意制約用
├── group (FK to DistributionGroup, CASCADE)
├── field (FK to fields.Field, PROTECT)
└── unique_together = ['distribution_plan', 'field'] ← 1圃場=1グループ/1計画
```
### 重要な設計判断
1. **M:N関係に変更**: 当初はM:1だったが、実運用で「1つの実圃場が複数の申請区画に紐づく」ケースが判明し、ManyToManyに変更マイグレーション0003で実施
2. **面積単位の二重管理**:
- DB内部は `area_m2` (整数) で保存
- 表示用に `area_tan` (反, Decimal) も保持
- 理由: 申請書ではm2、農家の感覚では反
3. **品種は全作物で統一**:
- 「作付けしない」も「その他」作物の品種として扱う
- UI操作を統一するため
4. **グループ機能**:
- `group_name` (エリアや用途によるグループ分け)
- `display_order` (リスト表示時の順序)
- マイグレーション0004で追加
5. **年度管理の設計方針**(⚠️ Phase 2 で必ず参照):
- **作付け計画**: 年度セレクタで独立して来年度も選べる。選んだ年度はlocalStorageに保存して維持
- **過去年度**: 「参照モード」として視覚的に区別(背景色・バナー)
- **Phase 2 の栽培管理・販売管理**: グローバル作業年度を導入し、基本は今年度に従う
- **栽培記録・作業日誌**: 日付中心設計、年度は日付から自動算出
- 参考: ソリマチ農業簿記の年度管理方式(明示的に年度を選択、変更するまで固定)
---
## 🔑 重要な制約・ルール
### 絶対に守るべきこと
1. **データの整合性**
- 年度 + 圃場の組み合わせは1つの Plan のみ (`unique_together`)
- 作物 + 品種名の組み合わせは一意 (`unique_together`)
2. **面積の扱い**
- 表示: 反 (tan)
- 計算・保存: m2
- 変換: 1反 = 1000m2 (正確には991.736m2だが、実運用では1000で統一)
3. **M:N関係の重要性**
- Field と OfficialKyosaiField は M:N
- Field と OfficialChusankanField は M:N
- 決して FK (1:N) に戻さない
4. **シンプルさ優先**
- 過度な抽象化を避ける
- 3回同じコードを書くまでは抽象化しない
- ユーザーは1人、パフォーマンス最適化は後回し
### コーディング規約
- **Backend**: Django のベストプラクティスに従う
- **Frontend**: TypeScript strict mode、ESLint に従う
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
- **命名**: 日本語のフィールドは `verbose_name` で対応
---
## 📍 現在の実装状況
### ✅ 実装済みPhase 1 - MVP
1. **認証**: JWT認証アクセストークン24h、リフレッシュトークン7日
2. **圃場管理**:
- CRUD API (`/api/fields/`)
- ODS/Excelインポート (`/api/fields/import/`)
- グループ機能マイグレーション0004
3. **作付け計画**:
- 年度別の作付け計画 CRUD (`/api/plans/?year=2025`)
- 前年度コピー機能 (`/api/plans/copy_from_previous_year/`)
- 一括更新 (`/api/plans/bulk_update/`)
- 集計API (`/api/plans/summary/?year=2025`)
4. **申請書生成**:
- 水稲共済細目書 PDF (`/api/reports/kyosai/?year=2025`)
- 中山間交付金 PDF (`/api/reports/chusankan/?year=2025`)
5. **フロントエンド**:
- 作付け計画編集画面(集計サイドバー付き)
- 圃場一覧・詳細・新規作成
- データ取込画面
- 申請書ダウンロード画面
- ダッシュボード画面(概要サマリー、作物別集計、クイックアクセス)
6. **対応付け可視化・紐づけ管理** (E-2):
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
- 共通 LinkModal コンポーネント
7. **メールフィルタリング機能**Windmill連携:
- Django `apps/mail` アプリMailSender, MailEmail, MailNotificationToken
- Windmill向けAPIAPIキー認証: `GET /api/mail/sender-rule/`, `GET /api/mail/sender-context/`, `POST /api/mail/emails/`, `GET /api/mail/stats/`
- フィードバックAPI認証不要・UUIDトークン: `GET/POST /api/mail/feedback/<token>/`
- ルール管理APIJWT認証: `GET/POST/DELETE /api/mail/senders/`, `PATCH /api/mail/emails/<pk>/feedback/`
- フィードバックページ: `/mail/feedback/[token]`LINEからタップ一発、認証不要
- ルール管理ページ: `/mail/rules/`
- 処理履歴ページ: `/mail/history/`
- 対応アカウント: Gmail × 2、Xserver × 6本番稼働中、`account``xserver1``xserver6` で識別)
- Windmill フロー: `f/mail/mail_filter`(本番: windmill.keinafarm.net にデプロイ済み、10分間隔スケジュール
- To: ヘッダー宛先補正を実装Gmail先行取り込み時も @keinafarm.com 宛は xserver1〜xserver6 として記録/通知)
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
8. **パスワード変更機能**:
- Backend: `POST /api/auth/change-password/`JWT認証、`ChangePasswordView` in `keinasystem/urls.py`
- Frontend: `/settings/password` ページ
- Navbar: KeyRound アイコンボタン(ログアウトボタンの左隣)
9. **気象データ基盤**Windmill連携:
- Django `apps/weather` アプリWeatherRecord: 1日1行、2016-01-01〜
- データソース: Open-Meteo archive API窪川 lat=33.213, lon=133.133
- Windmill向けAPIAPIキー認証: `POST /api/weather/sync/`upsert、単一/リスト両対応)
- フロントエンド向けAPIJWT認証:
- `GET /api/weather/records/?year=&start=&end=` 日次レコード一覧
- `GET /api/weather/summary/?year=` 月別・年間サマリー(猛暑日・冬日数含む)
- `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度GDD
- `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較)
- 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]`
- Windmill フロー: `f/weather/weather_sync`本番稼働中、毎朝6時 Asia/Tokyo
- `Crop.base_temp`GDD計算の基準温度、default=0.0℃をCropモデルに追加
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts
- **将来計画**: 開花・収穫予測品種ごとの目標GDD設定 → 到達日予測)
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
10. **施肥計画機能**(本番稼働中):
- Django `apps/fertilizer` アプリFertilizer, FertilizationPlan, FertilizationEntry
- API: `/api/fertilizer/fertilizers/`, `/api/fertilizer/plans/`, `/api/fertilizer/calculate/`, `/api/fertilizer/candidate_fields/`
- PDF出力: `/api/fertilizer/plans/{id}/pdf/`WeasyPrint、A4横向き
- FertilizationEntry.fertilizer は PROTECT使用中の肥料は削除不可・migration 0002
- 自動計算3方式: per_tan反当袋数/ even均等配分/ nitrogen反当チッソ
- 四捨五入トグル: `≈`(丸め)/ `↩`(元の計算値に戻す)
- フロントエンド: `/fertilizer`(一覧)、`/fertilizer/masters`(肥料マスタ)、`/fertilizer/new``/fertilizer/[id]/edit`(編集)
- 施肥機能全体で alert/confirm を廃止し、React インラインバナーでエラー表示
- マスタードキュメント: `document/13_マスタードキュメント_施肥計画編.md`
10. **施肥計画機能**:
- Django `apps/fertilizer` アプリFertilizer, FertilizationPlan, FertilizationEntry
- APIJWT認証: `GET/POST /api/fertilizer/fertilizers/`, `GET/POST /api/fertilizer/plans/?year=`, `GET /api/fertilizer/plans/{id}/pdf/`, `GET /api/fertilizer/candidate_fields/?year=&variety_id=`, `POST /api/fertilizer/calculate/`
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new``/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
- スコープ外(将来): 購入管理
11. **分配計画機能**2026-03-02 実装):
- Django `apps/fertilizer` アプリに3モデル追加DistributionPlan, DistributionGroup, DistributionGroupField
- APIJWT認証: `GET/POST /api/fertilizer/distribution/?year=`, `GET/PUT/DELETE /api/fertilizer/distribution/{id}/`, `GET /api/fertilizer/distribution/{id}/pdf/`
- 施肥計画を元に圃場をカスタムグループに割り当て、グループ×肥料の集計表を生成
- PDF出力A4横向き・グループ合計行★圃場サブ行
- フロントエンド: `/distribution/`(一覧), `/distribution/new``/distribution/[id]/edit`(編集)
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
### 🚧 既知の課題・技術的負債
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
3. **テスト**: 自動テストが未実装Phase 2で追加予定
4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要
### 🔜 次の実装タスク(優先順)
差異レポートの全タスクA-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2は全件完了。
Phase 2 のタスクに進む段階。
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
### 📅 次のマイルストーンPhase 2
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
- 作業予定のカレンダー表示
- モバイル対応の改善(スマホでの記録入力)
---
## 🛠️ よくある作業パターン
## よくある作業パターン
### 新しいモデルを追加する場合
1. `apps/<app_name>/models.py` にモデルクラスを追加
2. `python manage.py makemigrations`
3. `python manage.py migrate`
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
6. ViewSet 作成 (`apps/<app_name>/views.py`)
7. URL登録 (`apps/<app_name>/urls.py`)
1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
5. Serializer → 6. ViewSet → 7. URL登録
### 新しいAPI エンドポイントを追加する場合
### 新しいAPI / 画面を追加する場合
1. `apps/<app_name>/views.py` にビューを追加
2. `apps/<app_name>/urls.py` にパスを追加
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
### 新しい画面を追加する場合
1. `frontend/src/app/<page_name>/page.tsx` を作成
2. 必要に応じてレイアウト調整 (`layout.tsx`)
3. API呼び出しは `useEffect` + `fetch` で実装
4. ローディング状態、エラー状態を適切に処理
- API: `views.py``urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
- 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
---
## 🔍 トラブルシューティング
### 本番デプロイコマンド(必須)
## デプロイ・トラブルシューティング
```bash
# deploy.sh で git pull → down → build → up -d を一括実行
# 本番デプロイ(git pull → build → up -d を一括実行
ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
```
**Docker Compose 構成:**
- `docker-compose.yml` = 本番用Traefik連携、gunicorn、prod Dockerfile
- `docker-compose.develop.yml` = 開発用ホットリロード、DEBUG=True
- 本番サーバー: `.env``.env.production` シンボリックリンク
- `deploy.sh` = 本番デプロイ、`develop.bat` = ローカル開発起動
### 本番確認手順(デプロイ後の必須チェック)
**⚠️ Playwrightビジュアルテストを使う前に、必ずcurlで先に確認すること。**
curlはキャッシュの影響を受けず、偽装不可能な確認手段。
```bash
# ステップ1: curlヘルスチェック全9項目、所要約10秒
# 本番ヘルスチェック9項目、curlベース
bash scripts/check_prod.sh claude keina1234
# → 全 9 項目 PASS が出れば本番が正常稼働中
# ステップ2任意: Playwrightでビジュアル確認する場合のプロンプト原則
# - 「認証できなければ即中止して報告せよ」を必ず明記
# - 「スクリーンショットには今日の日付が画面内に見えること」を要求
# - 「成功の証跡HTTP レスポンスの実テキスト)を必ず添付すること」を要求
```
**本番バックエンドのマイグレーション適用(バックエンド変更時のみ):**
```bash
# 本番マイグレーション(バックエンド変更時のみ)
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
sudo -u keinasystem docker compose build backend && \
sudo -u keinasystem docker compose up -d && \
sleep 5 && \
sudo -u keinasystem docker compose up -d && sleep 5 && \
sudo -u keinasystem docker compose exec backend python manage.py migrate'
```
### マイグレーションエラー
```bash
# マイグレーションをリセット(開発環境のみ!)
docker-compose exec backend python manage.py migrate <app_name> zero
docker-compose exec backend python manage.py makemigrations
docker-compose exec backend python manage.py migrate
```
### CORS エラー
- `backend/keinasystem/settings.py``CORS_ALLOWED_ORIGINS` を確認
- 現在は `http://localhost:3000``http://127.0.0.1:3000` を許可
### JWT トークンエラー
- トークンの有効期限を確認(アクセストークン: 24時間
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`
### PDF 生成エラー
- WeasyPrint のインストールを確認
- 日本語フォントの設定を確認HTMLテンプレートのCSS
- **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
- **CORS**: `settings.py``CORS_ALLOWED_ORIGINS`localhost:3000 許可済み)
- **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
---
## 📚 詳細情報へのリンク
## マスタードキュメント(機能別リファレンス)
### マスタードキュメント(機能別の網羅的リファレンス)
特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。**
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md`
- **分配計画機能**: `document/14_マスタードキュメント_分配計画編.md`
### 設計ドキュメント(プロジェクト横断)
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
- **データモデル詳細**: `document/03_データ仕様書.md`
- **画面設計**: `document/04_画面設計書.md`
- **実装手順**: `document/00_Gemini向け統合指示書.md`
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
| 機能 | ドキュメント |
|------|------------|
| 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
| TODO管理 | `document/19_マスタードキュメント_TODO管理編.md` |
| データモデル全体 | `document/03_データ仕様書.md` |
---
## 💡 新しいセッションでの推奨フロー
## セッション開始・終了フロー
### 開始時
1. この `CLAUDE.md` を読む
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md`
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照
4. 実装・修正を行う
5. 重要な設計判断があれば、この `CLAUDE.md` と該当マスタードキュメントを更新
---
## 📝 更新履歴
- 2026-03-05: メール通知機能を更新。MailEmail.account を xserver1〜xserver6 で識別可能に変更。Windmill mail_filter に To ヘッダー宛先補正を追加し、Gmail先行取り込みでも Xserver 宛先ラベルが崩れないよう修正。マスタードキュメント/仕様書を同期。
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除
- 2026-03-02: 分配計画機能を実装。`apps/fertilizer` に DistributionPlan/DistributionGroup/DistributionGroupField 追加、API `/api/fertilizer/distribution/`、PDF出力A4横・グループ★行圃場サブ行、フロントエンド `/distribution/`。マスタードキュメント `document/14_マスタードキュメント_分配計画編.md` 追加
- 2026-03-01: 施肥計画機能を実装・本番稼働。`apps/fertilizer`Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力, PROTECT migration 0002、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。施肥機能全体で alert/confirm 廃止・インラインバナーに統一。マスタードキュメント `document/13_マスタードキュメント_施肥計画編.md` 追加
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`WeatherRecord, 5 API、Windmill `f/weather/weather_sync`毎朝6時、フロントエンド `/weather`年別集計・期間指定・Rechartsグラフ`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPIAPIキー認証、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
- 2026-02-18: E-2対応付け可視化・紐づけ管理仕様追加。画面設計書・差異レポート・次タスク一覧を更新。完了済みタスク(A-8, D-1〜D-4, E-1)を既知の課題から除外
- 2026-02-17: ドキュメント一斉更新差異レポートA〜E反映、CSV→PDF統一、M:N関係、中山間モデル17列化、インライン編集方式、Navbar追加、既知の課題・次タスク一覧追加
- 2026-02-16: 初版作成(ハイブリッドアプローチの方針決定)
2. `HANDOVER.md` で前回の引き継ぎを確認する
3. `TASK_CONTEXT.md` で現在の状況を把握する
4. タスク対象の**マスタードキュメント**を読む
### 終了時(または作業の区切りで必ず実行)
1. `HANDOVER.md` を定型フォーマットで更新する
2. 重要な設計判断があれば `CLAUDE.md` と該当マスタードキュメントを更新
3. 実装状況に変化があれば `TASK_CONTEXT.md` を更新

34
HANDOVER.md Normal file
View File

@@ -0,0 +1,34 @@
# Goal
Phase 1 全タスク完了後の安定運用。Phase 2 移行準備。
# Done
- 施肥散布実績連携を実装・本番稼働2026-03-17
- 運搬計画を再設計・本番稼働2026-03-16
- CLAUDE.md を120行にスリム化、TASK_CONTEXT.md を分離
# In Progress
- なし
# Pending
- Phase 2 設計(栽培履歴管理、カレンダー表示、モバイル対応)
- 自動テスト導入
- フロントエンドの統一的エラーハンドリング
# Next Step
Phase 2 の最初のタスクを決定する(栽培履歴管理 or カレンダー表示 or モバイル対応)
# Decisions
- CLAUDE.md からデータモデル詳細・実装状況・更新履歴を分離2026-03-18
- 実装状況は TASK_CONTEXT.md で管理する方針に変更
# Commands Run
なし(ドキュメント整理のみ)
# Errors / Risks
- 認証: ログアウト処理が未実装(トークン破棄のみ)
- N+1問題が一部存在現状はデータ量が少なく問題なし
# Do Not Touch
- Field ↔ OfficialKyosaiField / OfficialChusankanField の M:N 関係
- FertilizationEntry.fertilizer の PROTECT 制約
- 旧 is_confirmed / confirmed_at カラムDB残留、UI未使用 — 将来のマイグレーションで削除予定)

57
TASK_CONTEXT.md Normal file
View File

@@ -0,0 +1,57 @@
# 現在の作業状況
> **最終更新**: 2026-04-04
> **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中
## 実装済み機能Phase 1 - MVP
1. **認証**: JWT認証アクセストークン24h、リフレッシュトークン7日
2. **圃場管理**: CRUD、ODS/Excelインポート、グループ機能
3. **作付け計画**: 年度別CRUD、前年度コピー、一括更新、集計API
4. **申請書生成**: 水稲共済細目書PDF、中山間交付金PDF
5. **フロントエンド**: 作付け計画編集、圃場一覧/詳細、データ取込、申請書DL、ダッシュボード
6. **対応付け可視化・紐づけ管理** (E-2): 圃場一覧「対応表」モード、共済/中山間リンク管理
7. **メールフィルタリング**Windmill連携:
- Django `apps/mail`、Windmill向けAPIAPIキー認証
- フィードバックページ認証不要・UUIDトークン、ルール管理、処理履歴
- 対応アカウント: Gmail × 2、Xserver × 6本番稼働中、10分間隔
- To ヘッダー宛先補正実装済み
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
8. **パスワード変更**: `POST /api/auth/change-password/``/settings/password`
9. **気象データ基盤**Windmill連携:
- Django `apps/weather`WeatherRecord: 1日1行、2016-01-01〜
- Open-Meteo archive API窪川、Windmill毎朝6時同期
- API: records, summary, gdd, similarity
- フロントエンド `/weather`年別集計・期間指定、Recharts
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
10. **施肥計画**(本番稼働中):
- 自動計算3方式: per_tan / even / nitrogen
- 四捨五入トグル、PDF出力A4横、PROTECT制約
- **散布実績**: 散布日単位記録、在庫USE連携、actual_bags再集計、WorkRecord自動生成
- マスタードキュメント: `document/13_マスタードキュメント_施肥計画編.md`
11. **運搬計画**(本番稼働中):
- 旧 Distribution → Delivery に再設計年度ベース、施肥計画FK廃止
- 軽トラ1回分単位、グループ一括割り当て、回間移動
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert
13. **田植え計画**MVP実装:
- 年度×品種単位で苗箱枚数・種もみ使用量を計画
- 作物単位の種もみ在庫kg、品種単位の反当苗箱枚数デフォルト
- 作付け計画から候補圃場を自動取得
- マスタードキュメント: `document/16_マスタードキュメント_田植え計画編.md`
## 既知の課題・技術的負債
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
3. **テスト**: 自動テストが未実装Phase 2で追加予定
4. **パフォーマンス**: N+1問題が一部存在
## 次のマイルストーンPhase 2
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
- 作業予定のカレンダー表示
- モバイル対応の改善(スマホでの記録入力)
差異レポートの全タスクA-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2は全件完了。
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照。

View File

@@ -1,5 +1,9 @@
from django.contrib import admin
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
SpreadingSession, SpreadingSessionItem,
)
@admin.register(Fertilizer)
@@ -14,30 +18,58 @@ class FertilizationEntryInline(admin.TabularInline):
@admin.register(FertilizationPlan)
class FertilizationPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'variety']
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
list_filter = ['year']
inlines = [FertilizationEntryInline]
class DistributionGroupFieldInline(admin.TabularInline):
model = DistributionGroupField
class DeliveryGroupFieldInline(admin.TabularInline):
model = DeliveryGroupField
extra = 0
readonly_fields = ['distribution_plan']
readonly_fields = ['delivery_plan']
class DistributionGroupInline(admin.TabularInline):
model = DistributionGroup
class DeliveryGroupInline(admin.TabularInline):
model = DeliveryGroup
extra = 0
@admin.register(DistributionPlan)
class DistributionPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'fertilization_plan', 'created_at']
list_filter = ['fertilization_plan__year']
inlines = [DistributionGroupInline]
class DeliveryTripItemInline(admin.TabularInline):
model = DeliveryTripItem
extra = 0
@admin.register(DistributionGroup)
class DistributionGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'distribution_plan', 'order']
inlines = [DistributionGroupFieldInline]
class DeliveryTripInline(admin.TabularInline):
model = DeliveryTrip
extra = 0
@admin.register(DeliveryPlan)
class DeliveryPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'created_at']
list_filter = ['year']
inlines = [DeliveryGroupInline, DeliveryTripInline]
@admin.register(DeliveryGroup)
class DeliveryGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'delivery_plan', 'order']
inlines = [DeliveryGroupFieldInline]
@admin.register(DeliveryTrip)
class DeliveryTripAdmin(admin.ModelAdmin):
list_display = ['delivery_plan', 'order', 'name', 'date']
inlines = [DeliveryTripItemInline]
class SpreadingSessionItemInline(admin.TabularInline):
model = SpreadingSessionItem
extra = 0
@admin.register(SpreadingSession)
class SpreadingSessionAdmin(admin.ModelAdmin):
list_display = ['year', 'date', 'name']
list_filter = ['year', 'date']
inlines = [SpreadingSessionItemInline]

View File

@@ -0,0 +1,127 @@
# Generated by Django 5.0 on 2026-03-16 07:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0006_fertilizationplan_confirmation'),
('fields', '0006_e1c_chusankan_17_fields'),
]
operations = [
migrations.CreateModel(
name='DeliveryGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='グループ名')),
('order', models.PositiveIntegerField(default=0, verbose_name='表示順')),
],
options={
'verbose_name': '配送先グループ',
'verbose_name_plural': '配送先グループ',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='DeliveryPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.IntegerField(verbose_name='年度')),
('name', models.CharField(max_length=200, verbose_name='計画名')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '運搬計画',
'verbose_name_plural': '運搬計画',
'ordering': ['-year', 'name'],
},
),
migrations.RemoveField(
model_name='distributiongroupfield',
name='group',
),
migrations.AlterUniqueTogether(
name='distributiongroupfield',
unique_together=None,
),
migrations.RemoveField(
model_name='distributiongroupfield',
name='distribution_plan',
),
migrations.RemoveField(
model_name='distributiongroupfield',
name='field',
),
migrations.RemoveField(
model_name='distributionplan',
name='fertilization_plan',
),
migrations.CreateModel(
name='DeliveryGroupField',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_assignments', to='fertilizer.deliverygroup', verbose_name='グループ')),
('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.deliveryplan', verbose_name='運搬計画')),
],
options={
'verbose_name': 'グループ圃場割り当て',
'verbose_name_plural': 'グループ圃場割り当て',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('delivery_plan', 'field')},
},
),
migrations.AddField(
model_name='deliverygroup',
name='delivery_plan',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.deliveryplan', verbose_name='運搬計画'),
),
migrations.CreateModel(
name='DeliveryTrip',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=0, verbose_name='何回目')),
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
('date', models.DateField(blank=True, null=True, verbose_name='運搬日')),
('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trips', to='fertilizer.deliveryplan', verbose_name='運搬計画')),
],
options={
'verbose_name': '運搬回',
'verbose_name_plural': '運搬回',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='DeliveryTripItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='袋数')),
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('trip', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.deliverytrip', verbose_name='運搬回')),
],
options={
'verbose_name': '運搬明細',
'verbose_name_plural': '運搬明細',
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
'unique_together': {('trip', 'field', 'fertilizer')},
},
),
migrations.DeleteModel(
name='DistributionGroup',
),
migrations.DeleteModel(
name='DistributionGroupField',
),
migrations.DeleteModel(
name='DistributionPlan',
),
migrations.AlterUniqueTogether(
name='deliverygroup',
unique_together={('delivery_plan', 'name')},
),
]

View File

@@ -0,0 +1,57 @@
# Generated by Django 5.0 on 2026-03-17 08:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0007_delivery_models'),
('fields', '0006_e1c_chusankan_17_fields'),
]
operations = [
migrations.CreateModel(
name='SpreadingSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.IntegerField(verbose_name='年度')),
('date', models.DateField(verbose_name='散布日')),
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '散布実績',
'verbose_name_plural': '散布実績',
'ordering': ['-date', '-id'],
},
),
migrations.AddField(
model_name='fertilizationentry',
name='actual_bags',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='実績袋数'),
),
migrations.CreateModel(
name='SpreadingSessionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actual_bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='実散布袋数')),
('planned_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='計画袋数スナップショット')),
('delivered_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='運搬済み袋数スナップショット')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.spreadingsession', verbose_name='散布実績')),
],
options={
'verbose_name': '散布実績明細',
'verbose_name_plural': '散布実績明細',
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
'unique_together': {('session', 'field', 'fertilizer')},
},
),
]

View File

@@ -69,6 +69,13 @@ class FertilizationEntry(models.Model):
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
)
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
actual_bags = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
verbose_name='実績袋数',
)
class Meta:
verbose_name = '施肥エントリ'
@@ -80,51 +87,48 @@ class FertilizationEntry(models.Model):
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}"
class DistributionPlan(models.Model):
"""分配計画:施肥計画の圃場をカスタムグループに割り当て、配置場所単位で集計する"""
fertilization_plan = models.ForeignKey(
FertilizationPlan, on_delete=models.CASCADE,
related_name='distribution_plans', verbose_name='施肥計画'
)
class DeliveryPlan(models.Model):
"""運搬計画:施肥計画の肥料を軽トラで運ぶ単位で計画・記録する"""
year = models.IntegerField(verbose_name='年度')
name = models.CharField(max_length=200, 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 = ['-fertilization_plan__year', 'name']
verbose_name = '運搬計画'
verbose_name_plural = '運搬計画'
ordering = ['-year', 'name']
def __str__(self):
return f"{self.fertilization_plan.year} {self.name}"
return f"{self.year} {self.name}"
class DistributionGroup(models.Model):
"""配グループ:ある場所にまとめて置く圃場のグループ"""
distribution_plan = models.ForeignKey(
DistributionPlan, on_delete=models.CASCADE,
related_name='groups', verbose_name='分配計画'
class DeliveryGroup(models.Model):
"""送先グループ:まとめて運ぶ圃場のグループ"""
delivery_plan = models.ForeignKey(
DeliveryPlan, on_delete=models.CASCADE,
related_name='groups', verbose_name='運搬計画'
)
name = models.CharField(max_length=100, verbose_name='グループ名')
order = models.PositiveIntegerField(default=0, verbose_name='表示順')
class Meta:
verbose_name = '配グループ'
verbose_name_plural = '配グループ'
unique_together = [['distribution_plan', 'name']]
verbose_name = '送先グループ'
verbose_name_plural = '送先グループ'
unique_together = [['delivery_plan', 'name']]
ordering = ['order', 'id']
def __str__(self):
return f"{self.distribution_plan} / {self.name}"
return f"{self.delivery_plan} / {self.name}"
class DistributionGroupField(models.Model):
"""圃場のグループへの割り当て1圃場=1グループ/1分配計画)"""
distribution_plan = models.ForeignKey(
DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
class DeliveryGroupField(models.Model):
"""圃場のグループへの割り当て1圃場=1グループ/1運搬計画)"""
delivery_plan = models.ForeignKey(
DeliveryPlan, on_delete=models.CASCADE, verbose_name='運搬計画'
)
group = models.ForeignKey(
DistributionGroup, on_delete=models.CASCADE,
DeliveryGroup, on_delete=models.CASCADE,
related_name='field_assignments', verbose_name='グループ'
)
field = models.ForeignKey(
@@ -134,8 +138,111 @@ class DistributionGroupField(models.Model):
class Meta:
verbose_name = 'グループ圃場割り当て'
verbose_name_plural = 'グループ圃場割り当て'
unique_together = [['distribution_plan', 'field']]
unique_together = [['delivery_plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f"{self.group.name} / {self.field.name}"
class DeliveryTrip(models.Model):
"""運搬回軽トラ1回分の積載"""
delivery_plan = models.ForeignKey(
DeliveryPlan, on_delete=models.CASCADE,
related_name='trips', verbose_name='運搬計画'
)
order = models.PositiveIntegerField(default=0, verbose_name='何回目')
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
date = models.DateField(null=True, blank=True, verbose_name='運搬日')
class Meta:
verbose_name = '運搬回'
verbose_name_plural = '運搬回'
ordering = ['order', 'id']
def __str__(self):
return f"{self.delivery_plan} / {self.order + 1}回目"
class DeliveryTripItem(models.Model):
"""運搬明細:圃場×肥料単位の袋数"""
trip = models.ForeignKey(
DeliveryTrip, on_delete=models.CASCADE,
related_name='items', verbose_name='運搬回'
)
field = models.ForeignKey(
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
)
fertilizer = models.ForeignKey(
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
)
bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='袋数')
class Meta:
verbose_name = '運搬明細'
verbose_name_plural = '運搬明細'
unique_together = [['trip', 'field', 'fertilizer']]
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
def __str__(self):
return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}"
class SpreadingSession(models.Model):
"""散布日単位の実績"""
year = models.IntegerField(verbose_name='年度')
date = models.DateField(verbose_name='散布日')
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
notes = models.TextField(blank=True, default='', verbose_name='備考')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '散布実績'
verbose_name_plural = '散布実績'
ordering = ['-date', '-id']
def __str__(self):
label = self.name.strip() or f'{self.date}'
return f'{self.year} {label}'
class SpreadingSessionItem(models.Model):
"""散布実績明細:圃場×肥料ごとの実績"""
session = models.ForeignKey(
SpreadingSession,
on_delete=models.CASCADE,
related_name='items',
verbose_name='散布実績',
)
field = models.ForeignKey(
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
)
fertilizer = models.ForeignKey(
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
)
actual_bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='実散布袋数')
planned_bags_snapshot = models.DecimalField(
max_digits=10,
decimal_places=4,
verbose_name='計画袋数スナップショット',
)
delivered_bags_snapshot = models.DecimalField(
max_digits=10,
decimal_places=4,
verbose_name='運搬済み袋数スナップショット',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '散布実績明細'
verbose_name_plural = '散布実績明細'
unique_together = [['session', 'field', 'fertilizer']]
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
def __str__(self):
return (
f'{self.session} / {self.field.name} / '
f'{self.fertilizer.name}: {self.actual_bags}'
)

View File

@@ -1,5 +1,22 @@
from decimal import Decimal
from django.db.models import Sum
from rest_framework import serializers
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
from apps.workrecords.services import sync_delivery_work_record
from .models import (
DeliveryGroup,
DeliveryGroupField,
DeliveryPlan,
DeliveryTrip,
DeliveryTripItem,
FertilizationEntry,
FertilizationPlan,
Fertilizer,
SpreadingSession,
SpreadingSessionItem,
)
from .services import sync_actual_bags_for_pairs, sync_spreading_session_side_effects
class FertilizerSerializer(serializers.ModelSerializer):
@@ -33,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
class Meta:
model = FertilizationEntry
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
fields = [
'id',
'field',
'field_name',
'field_area_tan',
'fertilizer',
'fertilizer_name',
'bags',
'actual_bags',
]
class FertilizationPlanSerializer(serializers.ModelSerializer):
@@ -42,15 +68,36 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
entries = FertilizationEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField()
fertilizer_count = serializers.SerializerMethodField()
planned_total_bags = serializers.SerializerMethodField()
spread_total_bags = serializers.SerializerMethodField()
remaining_total_bags = serializers.SerializerMethodField()
spread_status = serializers.SerializerMethodField()
is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True)
is_variety_change_plan = serializers.SerializerMethodField()
class Meta:
model = FertilizationPlan
fields = [
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
'id',
'name',
'year',
'variety',
'variety_name',
'crop_name',
'calc_settings',
'entries',
'field_count',
'fertilizer_count',
'planned_total_bags',
'spread_total_bags',
'remaining_total_bags',
'spread_status',
'is_confirmed',
'confirmed_at',
'is_variety_change_plan',
'created_at',
'updated_at',
]
def get_variety_name(self, obj):
@@ -65,9 +112,35 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
def get_fertilizer_count(self, obj):
return obj.entries.values('fertilizer').distinct().count()
def get_planned_total_bags(self, obj):
total = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
return str(total)
def get_spread_total_bags(self, obj):
total = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
return str(total)
def get_remaining_total_bags(self, obj):
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
return str(planned - actual)
def get_spread_status(self, obj):
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
if actual <= 0:
return 'unspread'
if actual > planned:
return 'over_applied'
if actual < planned:
return 'partial'
return 'completed'
def get_is_variety_change_plan(self, obj):
return obj.name.endswith('(品種変更移動)')
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
"""保存用entries を一括で受け取る)"""
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
@@ -77,7 +150,8 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
def create(self, validated_data):
entries_data = validated_data.pop('entries', [])
plan = FertilizationPlan.objects.create(**validated_data)
self._save_entries(plan, entries_data)
pairs = self._save_entries(plan, entries_data)
sync_actual_bags_for_pairs(plan.year, pairs)
return plan
def update(self, instance, validated_data):
@@ -87,22 +161,24 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
instance.save()
if entries_data is not None:
instance.entries.all().delete()
self._save_entries(instance, entries_data)
pairs = self._save_entries(instance, entries_data)
sync_actual_bags_for_pairs(instance.year, pairs)
return instance
def _save_entries(self, plan, entries_data):
pairs = set()
for entry in entries_data:
pairs.add((entry['field_id'], entry['fertilizer_id']))
FertilizationEntry.objects.create(
plan=plan,
field_id=entry['field_id'],
fertilizer_id=entry['fertilizer_id'],
bags=entry['bags'],
)
return pairs
# ─── 分配計画 ────────────────────────────────────────────────────────────
class DistributionGroupFieldSerializer(serializers.ModelSerializer):
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source='field.id', read_only=True)
name = serializers.CharField(source='field.name', read_only=True)
area_tan = serializers.DecimalField(
@@ -110,128 +186,307 @@ class DistributionGroupFieldSerializer(serializers.ModelSerializer):
)
class Meta:
model = DistributionGroupField
model = DeliveryGroupField
fields = ['id', 'name', 'area_tan']
class DistributionGroupReadSerializer(serializers.ModelSerializer):
fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class DeliveryGroupReadSerializer(serializers.ModelSerializer):
fields = DeliveryGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class Meta:
model = DistributionGroup
model = DeliveryGroup
fields = ['id', 'name', 'order', 'fields']
class FertilizationPlanForDistributionSerializer(serializers.ModelSerializer):
"""分配計画詳細に埋め込む施肥計画情報肥料一覧・entries 含む)"""
variety_name = serializers.SerializerMethodField()
crop_name = serializers.SerializerMethodField()
fertilizers = serializers.SerializerMethodField()
entries = serializers.SerializerMethodField()
class DeliveryTripItemSerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
spread_bags = serializers.SerializerMethodField()
remaining_bags = serializers.SerializerMethodField()
class Meta:
model = FertilizationPlan
fields = ['id', 'name', 'year', 'variety_name', 'crop_name', 'fertilizers', 'entries']
def get_variety_name(self, obj):
return obj.variety.name
def get_crop_name(self, obj):
return obj.variety.crop.name
def get_fertilizers(self, obj):
fert_ids = obj.entries.values_list('fertilizer_id', flat=True).distinct()
from .models import Fertilizer as F
fertilizers = F.objects.filter(id__in=fert_ids).order_by('name')
return [{'id': f.id, 'name': f.name} for f in fertilizers]
def get_entries(self, obj):
return [
{'field': e.field_id, 'fertilizer': e.fertilizer_id, 'bags': str(e.bags)}
for e in obj.entries.all()
]
class DistributionPlanListSerializer(serializers.ModelSerializer):
fertilization_plan_id = serializers.IntegerField(source='fertilization_plan.id', read_only=True)
fertilization_plan_name = serializers.CharField(source='fertilization_plan.name', read_only=True)
year = serializers.IntegerField(source='fertilization_plan.year', read_only=True)
variety_name = serializers.SerializerMethodField()
crop_name = serializers.SerializerMethodField()
group_count = serializers.SerializerMethodField()
field_count = serializers.SerializerMethodField()
class Meta:
model = DistributionPlan
model = DeliveryTripItem
fields = [
'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
'created_at', 'updated_at',
'id',
'field',
'field_name',
'fertilizer',
'fertilizer_name',
'bags',
'spread_bags',
'remaining_bags',
]
def get_variety_name(self, obj):
return obj.fertilization_plan.variety.name
def get_spread_bags(self, obj):
total = (
SpreadingSessionItem.objects.filter(
session__year=obj.trip.delivery_plan.year,
field_id=obj.field_id,
fertilizer_id=obj.fertilizer_id,
).aggregate(total=Sum('actual_bags'))['total']
)
return str(total or Decimal('0'))
def get_crop_name(self, obj):
return obj.fertilization_plan.variety.crop.name
def get_remaining_bags(self, obj):
total = (
SpreadingSessionItem.objects.filter(
session__year=obj.trip.delivery_plan.year,
field_id=obj.field_id,
fertilizer_id=obj.fertilizer_id,
).aggregate(total=Sum('actual_bags'))['total']
)
spread_total = total or Decimal('0')
return str(obj.bags - spread_total)
class DeliveryTripReadSerializer(serializers.ModelSerializer):
items = DeliveryTripItemSerializer(many=True, read_only=True)
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
class Meta:
model = DeliveryTrip
fields = ['id', 'order', 'name', 'date', 'work_record_id', 'items']
class DeliveryPlanListSerializer(serializers.ModelSerializer):
group_count = serializers.SerializerMethodField()
trip_count = serializers.SerializerMethodField()
class Meta:
model = DeliveryPlan
fields = [
'id',
'year',
'name',
'group_count',
'trip_count',
'created_at',
'updated_at',
]
def get_group_count(self, obj):
return obj.groups.count()
def get_field_count(self, obj):
return obj.distributiongroupfield_set.count()
def get_trip_count(self, obj):
return obj.trips.count()
class DistributionPlanReadSerializer(serializers.ModelSerializer):
fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
groups = DistributionGroupReadSerializer(many=True, read_only=True)
class DeliveryPlanReadSerializer(serializers.ModelSerializer):
groups = DeliveryGroupReadSerializer(many=True, read_only=True)
trips = DeliveryTripReadSerializer(many=True, read_only=True)
unassigned_fields = serializers.SerializerMethodField()
available_fertilizers = serializers.SerializerMethodField()
all_entries = serializers.SerializerMethodField()
class Meta:
model = DistributionPlan
fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
model = DeliveryPlan
fields = [
'id',
'year',
'name',
'groups',
'trips',
'unassigned_fields',
'available_fertilizers',
'all_entries',
'created_at',
'updated_at',
]
def get_unassigned_fields(self, obj):
assigned_ids = obj.distributiongroupfield_set.values_list('field_id', flat=True)
plan_field_ids = obj.fertilization_plan.entries.values_list('field_id', flat=True).distinct()
from apps.fields.models import Field as F
unassigned = F.objects.filter(id__in=plan_field_ids).exclude(id__in=assigned_ids).order_by('display_order', 'id')
assigned_ids = DeliveryGroupField.objects.filter(
delivery_plan=obj
).values_list('field_id', flat=True)
plan_field_ids = FertilizationEntry.objects.filter(
plan__year=obj.year
).values_list('field_id', flat=True).distinct()
from apps.fields.models import Field
unassigned = Field.objects.filter(
id__in=plan_field_ids
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
return [{'id': f.id, 'name': f.name, 'area_tan': str(f.area_tan)} for f in unassigned]
def get_available_fertilizers(self, obj):
fert_ids = FertilizationEntry.objects.filter(
plan__year=obj.year
).values_list('fertilizer_id', flat=True).distinct()
fertilizers = Fertilizer.objects.filter(id__in=fert_ids).order_by('name')
return [{'id': f.id, 'name': f.name} for f in fertilizers]
class DistributionPlanWriteSerializer(serializers.ModelSerializer):
fertilization_plan_id = serializers.IntegerField(write_only=True)
def get_all_entries(self, obj):
entries = FertilizationEntry.objects.filter(
plan__year=obj.year
).select_related('field', 'fertilizer')
return [
{
'field': entry.field_id,
'field_name': entry.field.name,
'field_area_tan': str(entry.field.area_tan),
'fertilizer': entry.fertilizer_id,
'fertilizer_name': entry.fertilizer.name,
'bags': str(entry.bags),
'actual_bags': str(entry.actual_bags) if entry.actual_bags is not None else None,
}
for entry in entries
]
class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
trips = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
model = DistributionPlan
fields = ['id', 'name', 'fertilization_plan_id', 'groups']
model = DeliveryPlan
fields = ['id', 'year', 'name', 'groups', 'trips']
def create(self, validated_data):
groups_data = validated_data.pop('groups', [])
plan = DistributionPlan.objects.create(**validated_data)
trips_data = validated_data.pop('trips', [])
plan = DeliveryPlan.objects.create(**validated_data)
self._save_groups(plan, groups_data)
self._save_trips(plan, trips_data)
return plan
def update(self, instance, validated_data):
groups_data = validated_data.pop('groups', None)
trips_data = validated_data.pop('trips', None)
instance.name = validated_data.get('name', instance.name)
instance.year = validated_data.get('year', instance.year)
instance.save()
if groups_data is not None:
instance.groups.all().delete()
self._save_groups(instance, groups_data)
if trips_data is not None:
instance.trips.all().delete()
self._save_trips(instance, trips_data)
return instance
def _save_groups(self, plan, groups_data):
for g_data in groups_data:
group = DistributionGroup.objects.create(
distribution_plan=plan,
name=g_data['name'],
order=g_data.get('order', 0),
for group_data in groups_data:
group = DeliveryGroup.objects.create(
delivery_plan=plan,
name=group_data['name'],
order=group_data.get('order', 0),
)
for field_id in g_data.get('field_ids', []):
DistributionGroupField.objects.create(
distribution_plan=plan,
for field_id in group_data.get('field_ids', []):
DeliveryGroupField.objects.create(
delivery_plan=plan,
group=group,
field_id=field_id,
)
def _save_trips(self, plan, trips_data):
for trip_data in trips_data:
trip = DeliveryTrip.objects.create(
delivery_plan=plan,
order=trip_data.get('order', 0),
name=trip_data.get('name', ''),
date=trip_data.get('date'),
)
for item in trip_data.get('items', []):
DeliveryTripItem.objects.create(
trip=trip,
field_id=item['field_id'],
fertilizer_id=item['fertilizer_id'],
bags=item['bags'],
)
sync_delivery_work_record(trip)
class SpreadingSessionItemReadSerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
class Meta:
model = SpreadingSessionItem
fields = [
'id',
'field',
'field_name',
'fertilizer',
'fertilizer_name',
'actual_bags',
'planned_bags_snapshot',
'delivered_bags_snapshot',
]
class SpreadingSessionSerializer(serializers.ModelSerializer):
items = SpreadingSessionItemReadSerializer(many=True, read_only=True)
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
class Meta:
model = SpreadingSession
fields = [
'id',
'year',
'date',
'name',
'notes',
'work_record_id',
'items',
'created_at',
'updated_at',
]
class SpreadingSessionItemWriteInputSerializer(serializers.Serializer):
field_id = serializers.IntegerField()
fertilizer_id = serializers.IntegerField()
actual_bags = serializers.DecimalField(max_digits=10, decimal_places=4)
planned_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
delivered_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
class SpreadingSessionWriteSerializer(serializers.ModelSerializer):
items = SpreadingSessionItemWriteInputSerializer(many=True, write_only=True)
class Meta:
model = SpreadingSession
fields = ['id', 'year', 'date', 'name', 'notes', 'items']
def validate_items(self, value):
if not value:
raise serializers.ValidationError('items を1件以上指定してください。')
seen = set()
for item in value:
if item['actual_bags'] <= 0:
raise serializers.ValidationError('actual_bags は 0 より大きい値を指定してください。')
key = (item['field_id'], item['fertilizer_id'])
if key in seen:
raise serializers.ValidationError('同一 session 内で field + fertilizer を重複登録できません。')
seen.add(key)
return value
def create(self, validated_data):
items_data = validated_data.pop('items', [])
session = SpreadingSession.objects.create(**validated_data)
new_pairs = self._replace_items(session, items_data)
sync_spreading_session_side_effects(session, new_pairs)
return session
def update(self, instance, validated_data):
items_data = validated_data.pop('items', [])
old_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
new_pairs = self._replace_items(instance, items_data)
sync_spreading_session_side_effects(instance, old_pairs | new_pairs)
return instance
def _replace_items(self, session, items_data):
session.items.all().delete()
new_pairs = set()
for item in items_data:
new_pairs.add((item['field_id'], item['fertilizer_id']))
SpreadingSessionItem.objects.create(
session=session,
field_id=item['field_id'],
fertilizer_id=item['fertilizer_id'],
actual_bags=item['actual_bags'],
planned_bags_snapshot=item['planned_bags_snapshot'],
delivered_bags_snapshot=item['delivered_bags_snapshot'],
)
return new_pairs

View File

@@ -0,0 +1,196 @@
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from apps.materials.stock_service import create_reserves_for_plan, delete_reserves_for_plan
from apps.materials.models import StockTransaction
from apps.workrecords.services import sync_spreading_work_record
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem
class FertilizationPlanMergeError(Exception):
pass
class FertilizationPlanMergeConflict(FertilizationPlanMergeError):
def __init__(self, conflicts):
super().__init__('merge conflict')
self.conflicts = conflicts
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
pairs = {
(int(field_id), int(fertilizer_id))
for field_id, fertilizer_id in field_fertilizer_pairs
}
if not pairs:
return
for field_id, fertilizer_id in pairs:
total = (
SpreadingSessionItem.objects.filter(
session__year=year,
field_id=field_id,
fertilizer_id=fertilizer_id,
).aggregate(total=Sum('actual_bags'))['total']
)
FertilizationEntry.objects.filter(
plan__year=year,
field_id=field_id,
fertilizer_id=fertilizer_id,
).update(actual_bags=total)
@transaction.atomic
def sync_spreading_session_side_effects(session, field_fertilizer_pairs):
sync_actual_bags_for_pairs(session.year, field_fertilizer_pairs)
sync_stock_uses_for_spreading_session(session)
sync_spreading_work_record(session)
@transaction.atomic
def sync_stock_uses_for_spreading_session(session):
StockTransaction.objects.filter(spreading_item__session=session).delete()
session_items = session.items.select_related('fertilizer__material')
for item in session_items:
material = getattr(item.fertilizer, 'material', None)
if material is None:
continue
StockTransaction.objects.create(
material=material,
transaction_type=StockTransaction.TransactionType.USE,
quantity=item.actual_bags,
occurred_on=session.date,
note=f'散布実績「{session.name.strip() or session.date}',
fertilization_plan=None,
spreading_item=item,
)
@transaction.atomic
def move_fertilization_entries_for_variety_change(change):
moved_count = 0
old_variety_id = change.old_variety_id
new_variety = change.new_variety
if old_variety_id is None or new_variety is None:
return 0
old_plans = (
FertilizationPlan.objects
.filter(
year=change.year,
variety_id=old_variety_id,
entries__field_id=change.field_id,
)
.distinct()
.prefetch_related('entries')
)
for old_plan in old_plans:
entries_to_move = list(
old_plan.entries.filter(
field_id=change.field_id,
).order_by('id')
)
if not entries_to_move:
continue
new_plan = FertilizationPlan.objects.create(
name=f'{change.year}年度 {new_variety.name} 施肥計画(品種変更移動)',
year=change.year,
variety=new_variety,
calc_settings=old_plan.calc_settings,
)
FertilizationEntry.objects.filter(
id__in=[entry.id for entry in entries_to_move]
).update(plan=new_plan)
create_reserves_for_plan(old_plan)
create_reserves_for_plan(new_plan)
moved_count += len(entries_to_move)
return moved_count
@transaction.atomic
def merge_fertilization_plan_into(source_plan, target_plan):
if source_plan.id == target_plan.id:
raise FertilizationPlanMergeError('同じ施肥計画にはマージできません。')
if source_plan.year != target_plan.year:
raise FertilizationPlanMergeError('年度が異なる施肥計画にはマージできません。')
if source_plan.variety_id != target_plan.variety_id:
raise FertilizationPlanMergeError('品種が異なる施肥計画にはマージできません。')
if source_plan.is_confirmed or target_plan.is_confirmed:
raise FertilizationPlanMergeError('散布確定済みの施肥計画はマージできません。')
source_entries = list(
source_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
)
if not source_entries:
raise FertilizationPlanMergeError('移動元の施肥計画にマージ対象の entry がありません。')
source_pairs = {(entry.field_id, entry.fertilizer_id) for entry in source_entries}
target_entries = list(
target_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
)
target_pairs = {(entry.field_id, entry.fertilizer_id): entry for entry in target_entries}
conflicts = [
{
'field_id': entry.field_id,
'field_name': entry.field.name,
'fertilizer_id': entry.fertilizer_id,
'fertilizer_name': entry.fertilizer.name,
}
for entry in source_entries
if (entry.field_id, entry.fertilizer_id) in target_pairs
]
if conflicts:
raise FertilizationPlanMergeConflict(conflicts)
FertilizationEntry.objects.filter(
id__in=[entry.id for entry in source_entries]
).update(plan=target_plan)
target_plan.calc_settings = _merge_calc_settings(
target_plan.calc_settings,
source_plan.calc_settings,
)
target_plan.save()
create_reserves_for_plan(target_plan)
moved_count = len(source_entries)
deleted_source_plan = False
if not FertilizationEntry.objects.filter(plan=source_plan).exists():
delete_reserves_for_plan(source_plan)
source_plan.delete()
deleted_source_plan = True
else:
create_reserves_for_plan(source_plan)
return {
'moved_entry_count': moved_count,
'deleted_source_plan': deleted_source_plan,
}
def _merge_calc_settings(target_settings, source_settings):
merged = list(target_settings or [])
existing_fertilizer_ids = {
setting.get('fertilizer_id')
for setting in merged
if isinstance(setting, dict)
}
for setting in source_settings or []:
if not isinstance(setting, dict):
continue
fertilizer_id = setting.get('fertilizer_id')
if fertilizer_id in existing_fertilizer_ids:
continue
merged.append(setting)
existing_fertilizer_ids.add(fertilizer_id)
return merged

View File

@@ -0,0 +1,76 @@
{% load fertilizer_tags %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 12mm; }
body { font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic Pro", sans-serif; font-size: 10pt; }
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; }
.subtitle { text-align: center; font-size: 9pt; color: #555; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 6px; }
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
th { background: #e8f5e9; text-align: center; }
.col-name { text-align: left; }
.group-row { font-weight: bold; background: #c8e6c9; }
.group-row td { font-size: 10pt; }
.group-star { color: #2e7d32; margin-right: 2px; }
.field-row td { font-size: 8.5pt; color: #444; background: #fafafa; }
.field-indent { padding-left: 14px; }
tr.total-row { font-weight: bold; background: #f5f5f5; }
.zero { color: #bbb; }
.page-break { page-break-before: always; }
</style>
</head>
<body>
{% for page in trip_pages %}
{% if not forloop.first %}<div class="page-break"></div>{% endif %}
<h1>運搬計画書 {{ page.trip.order|add:1 }}回目</h1>
<p class="subtitle">
{{ plan.year }}年度 「{{ plan.name }}」
{% if page.trip.name %}{{ page.trip.name }}{% endif %}
{% if page.trip.date %}{{ page.trip.date }}{% endif %}
</p>
<table>
<thead>
<tr>
<th class="col-name">グループ / 圃場</th>
{% for fert in page.fertilizers %}
<th>{{ fert.name }}<br><small>(袋)</small></th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for group in page.group_rows %}
{# グループ合計行 #}
<tr class="group-row">
<td class="col-name"><span class="group-star"></span>{{ group.name }}</td>
{% for total in group.totals %}
<td>{% if total %}{{ total|bags_fmt }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
</tr>
{# 圃場サブ行 #}
{% for row in group.field_rows %}
<tr class="field-row">
<td class="col-name field-indent">{{ row.field.name }}{{ row.field.area_tan }}反)</td>
{% for cell in row.cells %}
<td>{% if cell %}{{ cell|bags_fmt }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td class="col-name">合計</td>
{% for total in page.fert_totals %}
<td>{{ total|bags_fmt }}</td>
{% endfor %}
</tr>
</tfoot>
</table>
{% endfor %}
</body>
</html>

View File

@@ -0,0 +1,15 @@
from decimal import Decimal
from django import template
register = template.Library()
@register.filter
def bags_fmt(value):
"""袋数を整数 or 小数点以下1桁で表示する。"""
if value is None or value == '':
return value
d = Decimal(str(value))
if d == d.to_integral_value():
return str(int(d))
return str(d.quantize(Decimal('0.1')))

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

@@ -5,10 +5,12 @@ from . import views
router = DefaultRouter()
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
urlpatterns = [
path('', include(router.urls)),
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
path('spreading/candidates/', views.SpreadingCandidatesView.as_view(), name='spreading-candidates'),
path('', include(router.urls)),
]

View File

@@ -1,10 +1,10 @@
from decimal import Decimal, InvalidOperation
from django.db.models import Sum
from django.http import HttpResponse
from django.template.loader import render_to_string
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -12,20 +12,30 @@ from weasyprint import HTML
from apps.fields.models import Field
from apps.materials.stock_service import (
confirm_spreading as confirm_spreading_service,
create_reserves_for_plan,
delete_reserves_for_plan,
unconfirm_spreading,
)
from apps.plans.models import Plan, Variety
from .models import Fertilizer, FertilizationPlan, DistributionPlan
from apps.plans.models import Plan
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
SpreadingSession, SpreadingSessionItem,
)
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
DistributionPlanListSerializer,
DistributionPlanReadSerializer,
DistributionPlanWriteSerializer,
DeliveryPlanListSerializer,
DeliveryPlanReadSerializer,
DeliveryPlanWriteSerializer,
SpreadingSessionSerializer,
SpreadingSessionWriteSerializer,
)
from .services import (
FertilizationPlanMergeConflict,
FertilizationPlanMergeError,
merge_fertilization_plan_into,
sync_actual_bags_for_pairs,
)
@@ -57,8 +67,6 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
create_reserves_for_plan(instance)
def perform_update(self, serializer):
if serializer.instance.is_confirmed:
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
instance = serializer.save()
create_reserves_for_plan(instance)
@@ -120,67 +128,54 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
return response
@action(detail=True, methods=['post'], url_path='confirm_spreading')
def confirm_spreading(self, request, pk=None):
plan = self.get_object()
@action(detail=True, methods=['get'])
def merge_targets(self, request, pk=None):
source_plan = self.get_object()
targets = (
FertilizationPlan.objects
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
.exclude(id=source_plan.id)
.prefetch_related('entries')
.order_by('-updated_at', 'id')
)
data = [
{
'id': plan.id,
'name': plan.name,
'field_count': plan.entries.values('field').distinct().count(),
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
'is_confirmed': plan.is_confirmed,
}
for plan in targets
]
return Response(data)
if plan.is_confirmed:
@action(detail=True, methods=['post'])
def merge_into(self, request, pk=None):
source_plan = self.get_object()
target_plan_id = request.data.get('target_plan_id')
if not target_plan_id:
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
try:
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
except FertilizationPlan.DoesNotExist:
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
try:
result = merge_fertilization_plan_into(source_plan, target_plan)
except FertilizationPlanMergeConflict as exc:
return Response(
{'detail': 'この計画は既に散布確定済みです。'},
status=status.HTTP_400_BAD_REQUEST,
)
entries_data = request.data.get('entries', [])
if not entries_data:
return Response(
{'detail': '実績データが空です。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries = []
for entry in entries_data:
field_id = entry.get('field_id')
fertilizer_id = entry.get('fertilizer_id')
if not field_id or not fertilizer_id:
return Response(
{'detail': 'field_id と fertilizer_id が必要です。'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
actual_bags = Decimal(str(entry.get('actual_bags', 0)))
except InvalidOperation:
return Response(
{'detail': 'actual_bags は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries.append(
{
'field_id': field_id,
'fertilizer_id': fertilizer_id,
'actual_bags': actual_bags,
}
'error': '競合する圃場・肥料があるためマージできません。',
'conflicts': exc.conflicts,
},
status=status.HTTP_409_CONFLICT,
)
except FertilizationPlanMergeError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
confirm_spreading_service(plan, actual_entries)
plan.refresh_from_db()
serializer = self.get_serializer(plan)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='unconfirm')
def unconfirm(self, request, pk=None):
plan = self.get_object()
if not plan.is_confirmed:
return Response(
{'detail': 'この計画はまだ確定されていません。'},
status=status.HTTP_400_BAD_REQUEST,
)
unconfirm_spreading(plan)
plan.refresh_from_db()
serializer = self.get_serializer(plan)
return Response(serializer.data)
return Response(result)
class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す"""
@@ -281,126 +276,373 @@ class CalculateView(APIView):
return Response(results)
class DistributionPlanViewSet(viewsets.ModelViewSet):
class DeliveryPlanViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = DistributionPlan.objects.select_related(
'fertilization_plan', 'fertilization_plan__variety', 'fertilization_plan__variety__crop'
).prefetch_related(
qs = DeliveryPlan.objects.prefetch_related(
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
'fertilization_plan__entries', 'fertilization_plan__entries__field',
'fertilization_plan__entries__fertilizer',
'distributiongroupfield_set',
'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
)
year = self.request.query_params.get('year')
if year:
qs = qs.filter(fertilization_plan__year=year)
qs = qs.filter(year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return DistributionPlanWriteSerializer
return DeliveryPlanWriteSerializer
if self.action == 'list':
return DistributionPlanListSerializer
return DistributionPlanReadSerializer
return DeliveryPlanListSerializer
return DeliveryPlanReadSerializer
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
dist_plan = self.get_object()
fert_plan = dist_plan.fertilization_plan
plan = self.get_object()
# 施肥計画の肥料一覧(名前順)
fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
# 全tripのitemから使用肥料を収集
all_items = DeliveryTripItem.objects.filter(
trip__delivery_plan=plan
).select_related('field', 'fertilizer')
fert_ids = all_items.values_list('fertilizer_id', flat=True).distinct()
fertilizers = sorted(
Fertilizer.objects.filter(id__in=fert_ids),
key=lambda f: f.name
)
# entries を (field_id, fertilizer_id) → bags のマトリクスに変換
entry_map = {}
for e in fert_plan.entries.all():
entry_map[(e.field_id, e.fertilizer_id)] = e.bags
# グループ情報: field_id → group_name
field_group_map = {}
for gf in DeliveryGroupField.objects.filter(
delivery_plan=plan
).select_related('group', 'field'):
field_group_map[gf.field_id] = gf.group
# グループ行の構築
groups = dist_plan.groups.prefetch_related('field_assignments__field').all()
group_rows = []
for group in groups:
fields_in_group = [
a.field for a in group.field_assignments.select_related('field').order_by('field__display_order', 'field__id')
# 回ごとにページを構築
trip_pages = []
for trip in plan.trips.prefetch_related('items__field', 'items__fertilizer').all():
items = trip.items.all()
if not items:
continue
# この回の肥料一覧
trip_fert_ids = set(item.fertilizer_id for item in items)
trip_fertilizers = [f for f in fertilizers if f.id in trip_fert_ids]
# items を (field_id, fertilizer_id) → bags のマトリクスに変換
item_map = {}
for item in items:
item_map[(item.field_id, item.fertilizer_id)] = item.bags
# グループごとにまとめる
groups_dict = {} # group_name → {'group': group, 'fields': [field, ...]}
ungrouped_fields = []
for item in items:
group = field_group_map.get(item.field_id)
if group:
if group.name not in groups_dict:
groups_dict[group.name] = {'group': group, 'fields': []}
if item.field not in groups_dict[group.name]['fields']:
groups_dict[group.name]['fields'].append(item.field)
else:
if item.field not in ungrouped_fields:
ungrouped_fields.append(item.field)
# グループを order 順にソート
sorted_groups = sorted(groups_dict.values(), key=lambda g: (g['group'].order, g['group'].id))
group_rows = []
for g_data in sorted_groups:
fields_in_group = sorted(g_data['fields'], key=lambda f: (f.display_order, f.id))
group_totals = []
for fert in trip_fertilizers:
total = sum(
item_map.get((f.id, fert.id), Decimal('0'))
for f in fields_in_group
)
group_totals.append(total)
field_rows = []
for field in fields_in_group:
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
field_rows.append({'field': field, 'cells': cells})
group_rows.append({
'name': g_data['group'].name,
'totals': group_totals,
'field_rows': field_rows,
})
# 未グループ圃場
if ungrouped_fields:
ungrouped_fields = sorted(ungrouped_fields, key=lambda f: (f.display_order, f.id))
ua_totals = [
sum(item_map.get((f.id, fert.id), Decimal('0')) for f in ungrouped_fields)
for fert in trip_fertilizers
]
field_rows = []
for field in ungrouped_fields:
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
field_rows.append({'field': field, 'cells': cells})
group_rows.append({
'name': '未グループ',
'totals': ua_totals,
'field_rows': field_rows,
})
fert_totals = [
sum(r['totals'][i] for r in group_rows)
for i in range(len(trip_fertilizers))
]
# グループ合計(肥料ごと)
group_totals = []
for fert in fertilizers:
total = sum(
entry_map.get((f.id, fert.id), Decimal('0'))
for f in fields_in_group
)
group_totals.append(total)
group_row_total = sum(group_totals)
# 圃場サブ行
field_rows = []
for field in fields_in_group:
cells = [entry_map.get((field.id, fert.id), '') for fert in fertilizers]
row_total = sum(v for v in cells if v != '')
field_rows.append({'field': field, 'cells': cells, 'total': row_total})
group_rows.append({
'name': group.name,
'totals': group_totals,
'row_total': group_row_total,
'field_rows': field_rows,
trip_pages.append({
'trip': trip,
'fertilizers': trip_fertilizers,
'group_rows': group_rows,
'fert_totals': fert_totals,
})
# 未割り当て圃場
assigned_ids = dist_plan.distributiongroupfield_set.values_list('field_id', flat=True)
plan_field_ids = fert_plan.entries.values_list('field_id', flat=True).distinct()
unassigned_fields = Field.objects.filter(
id__in=plan_field_ids
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
unassigned_rows = []
if unassigned_fields.exists():
ua_totals = []
for fert in fertilizers:
total = sum(
entry_map.get((f.id, fert.id), Decimal('0'))
for f in unassigned_fields
)
ua_totals.append(total)
unassigned_rows = [{
'name': '未割り当て',
'totals': ua_totals,
'row_total': sum(ua_totals),
'field_rows': [
{
'field': f,
'cells': [entry_map.get((f.id, fert.id), '') for fert in fertilizers],
'total': sum(entry_map.get((f.id, fert.id), Decimal('0')) for fert in fertilizers),
}
for f in unassigned_fields
],
}]
all_group_rows = group_rows + unassigned_rows
fert_totals = [
sum(r['totals'][i] for r in all_group_rows)
for i in range(len(fertilizers))
]
context = {
'dist_plan': dist_plan,
'fert_plan': fert_plan,
'fertilizers': fertilizers,
'group_rows': all_group_rows,
'fert_totals': fert_totals,
'grand_total': sum(fert_totals),
'plan': plan,
'trip_pages': trip_pages,
}
html_string = render_to_string('fertilizer/distribution_pdf.html', context)
html_string = render_to_string('fertilizer/delivery_pdf.html', context)
pdf_file = HTML(string=html_string).write_pdf()
response = HttpResponse(pdf_file, content_type='application/pdf')
response['Content-Disposition'] = (
f'attachment; filename="distribution_{fert_plan.year}_{dist_plan.id}.pdf"'
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
)
return response
class SpreadingSessionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = SpreadingSession.objects.prefetch_related(
'items',
'items__field',
'items__fertilizer',
).select_related('work_record')
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return SpreadingSessionWriteSerializer
return SpreadingSessionSerializer
def perform_destroy(self, instance):
from apps.materials.models import StockTransaction
year = instance.year
affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
StockTransaction.objects.filter(spreading_item__session=instance).delete()
instance.delete()
sync_actual_bags_for_pairs(year, affected_pairs)
class SpreadingCandidatesView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
year = request.query_params.get('year')
session_id = request.query_params.get('session_id')
delivery_plan_id = request.query_params.get('delivery_plan_id')
plan_id = request.query_params.get('plan_id')
if not year:
return Response(
{'detail': 'year が必要です。'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
year = int(year)
except (TypeError, ValueError):
return Response(
{'detail': 'year は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
if delivery_plan_id:
try:
delivery_plan_id = int(delivery_plan_id)
except (TypeError, ValueError):
return Response(
{'detail': 'delivery_plan_id は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
if plan_id:
try:
plan_id = int(plan_id)
except (TypeError, ValueError):
return Response(
{'detail': 'plan_id は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
current_session = None
current_map = {}
if session_id:
try:
current_session = SpreadingSession.objects.prefetch_related('items').get(
pk=session_id,
year=year,
)
except SpreadingSession.DoesNotExist:
return Response(
{'detail': '散布実績が見つかりません。'},
status=status.HTTP_404_NOT_FOUND,
)
current_map = {
(item.field_id, item.fertilizer_id): {
'actual_bags': item.actual_bags,
'field_name': item.field.name,
'field_area_tan': str(item.field.area_tan),
'fertilizer_name': item.fertilizer.name,
}
for item in current_session.items.all()
}
candidates = {}
plan_queryset = FertilizationEntry.objects.filter(plan__year=year)
if plan_id:
plan_queryset = plan_queryset.filter(plan_id=plan_id)
plan_rows = (
plan_queryset
.values(
'field_id',
'field__name',
'field__area_tan',
'fertilizer_id',
'fertilizer__name',
)
.annotate(planned_bags=Sum('bags'))
)
for row in plan_rows:
key = (row['field_id'], row['fertilizer_id'])
candidates.setdefault(
key,
{
'field': row['field_id'],
'field_name': row['field__name'],
'field_area_tan': str(row['field__area_tan']),
'fertilizer': row['fertilizer_id'],
'fertilizer_name': row['fertilizer__name'],
'planned_bags': Decimal('0'),
'delivered_bags': Decimal('0'),
'spread_bags': Decimal('0'),
'current_session_bags': Decimal('0'),
},
)['planned_bags'] = row['planned_bags'] or Decimal('0')
delivery_queryset = DeliveryTripItem.objects.filter(trip__delivery_plan__year=year)
if delivery_plan_id:
delivery_queryset = delivery_queryset.filter(trip__delivery_plan_id=delivery_plan_id)
else:
delivery_queryset = delivery_queryset.filter(trip__date__isnull=False)
delivery_rows = delivery_queryset.values(
'field_id',
'field__name',
'field__area_tan',
'fertilizer_id',
'fertilizer__name',
).annotate(delivered_bags=Sum('bags'))
for row in delivery_rows:
key = (row['field_id'], row['fertilizer_id'])
candidates.setdefault(
key,
{
'field': row['field_id'],
'field_name': row['field__name'],
'field_area_tan': str(row['field__area_tan']),
'fertilizer': row['fertilizer_id'],
'fertilizer_name': row['fertilizer__name'],
'planned_bags': Decimal('0'),
'delivered_bags': Decimal('0'),
'spread_bags': Decimal('0'),
'current_session_bags': Decimal('0'),
},
)['delivered_bags'] = row['delivered_bags'] or Decimal('0')
spread_queryset = SpreadingSessionItem.objects.filter(session__year=year)
if current_session is not None:
spread_queryset = spread_queryset.exclude(session=current_session)
spread_rows = (
spread_queryset
.values(
'field_id',
'field__name',
'field__area_tan',
'fertilizer_id',
'fertilizer__name',
)
.annotate(spread_bags=Sum('actual_bags'))
)
for row in spread_rows:
key = (row['field_id'], row['fertilizer_id'])
candidates.setdefault(
key,
{
'field': row['field_id'],
'field_name': row['field__name'],
'field_area_tan': str(row['field__area_tan']),
'fertilizer': row['fertilizer_id'],
'fertilizer_name': row['fertilizer__name'],
'planned_bags': Decimal('0'),
'delivered_bags': Decimal('0'),
'spread_bags': Decimal('0'),
'current_session_bags': Decimal('0'),
},
)['spread_bags'] = row['spread_bags'] or Decimal('0')
for key, current_data in current_map.items():
candidates.setdefault(
key,
{
'field': key[0],
'field_name': current_data['field_name'],
'field_area_tan': current_data['field_area_tan'],
'fertilizer': key[1],
'fertilizer_name': current_data['fertilizer_name'],
'planned_bags': Decimal('0'),
'delivered_bags': Decimal('0'),
'spread_bags': Decimal('0'),
'current_session_bags': Decimal('0'),
},
)['current_session_bags'] = current_data['actual_bags'] or Decimal('0')
rows = []
for candidate in candidates.values():
delivered = candidate['delivered_bags']
planned = candidate['planned_bags']
current_bags = candidate['current_session_bags']
if delivery_plan_id:
include_row = delivered > 0 or current_bags > 0
elif plan_id:
include_row = planned > 0 or current_bags > 0
else:
include_row = delivered > 0 or current_bags > 0
if not include_row:
continue
remaining = delivered - candidate['spread_bags']
rows.append(
{
'field': candidate['field'],
'field_name': candidate['field_name'],
'field_area_tan': candidate['field_area_tan'],
'fertilizer': candidate['fertilizer'],
'fertilizer_name': candidate['fertilizer_name'],
'planned_bags': str(planned),
'delivered_bags': str(delivered),
'spread_bags': str(candidate['spread_bags'] + current_bags),
'spread_bags_other': str(candidate['spread_bags']),
'current_session_bags': str(current_bags),
'remaining_bags': str(remaining),
}
)
rows.sort(key=lambda row: (row['field_name'], row['fertilizer_name']))
return Response(rows)

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,25 @@
# Generated by Django 5.0 on 2026-03-17 08:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
('materials', '0002_stocktransaction_fertilization_plan'),
]
operations = [
migrations.AddField(
model_name='stocktransaction',
name='spreading_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
),
migrations.AlterField(
model_name='stocktransaction',
name='transaction_type',
field=models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('reserve', '引当'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.0 on 2026-03-17 10:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
('materials', '0003_stocktransaction_spreading_item_and_more'),
]
operations = [
migrations.AlterField(
model_name='stocktransaction',
name='spreading_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
),
]

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):
FERTILIZER = 'fertilizer', '肥料'
PESTICIDE = 'pesticide', '農薬'
SEED = 'seed', '種子'
SEEDLING = 'seedling', '種苗'
OTHER = 'other', 'その他'
@@ -205,6 +206,14 @@ class StockTransaction(models.Model):
related_name='stock_reservations',
verbose_name='施肥計画',
)
spreading_item = models.ForeignKey(
'fertilizer.SpreadingSessionItem',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='stock_transactions',
verbose_name='散布実績明細',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:

View File

@@ -112,11 +112,15 @@ class MaterialWriteSerializer(serializers.ModelSerializer):
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
)
if (
material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER}
material_type in {
Material.MaterialType.SEED,
Material.MaterialType.SEEDLING,
Material.MaterialType.OTHER,
}
and (fertilizer_profile or pesticide_profile)
):
raise serializers.ValidationError(
'種苗・その他には詳細プロファイルを設定できません。'
'子・種苗・その他には詳細プロファイルを設定できません。'
)
return attrs
@@ -179,6 +183,7 @@ class StockTransactionSerializer(serializers.ModelSerializer):
source='get_transaction_type_display',
read_only=True,
)
is_locked = serializers.SerializerMethodField()
class Meta:
model = StockTransaction
@@ -195,10 +200,15 @@ class StockTransactionSerializer(serializers.ModelSerializer):
'occurred_on',
'note',
'fertilization_plan',
'spreading_item',
'is_locked',
'created_at',
]
read_only_fields = ['created_at']
def get_is_locked(self, obj):
return bool(obj.fertilization_plan_id or obj.spreading_item_id)
class StockSummarySerializer(serializers.Serializer):
material_id = serializers.IntegerField()

View File

@@ -14,9 +14,6 @@ def create_reserves_for_plan(plan):
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
if plan.is_confirmed:
return
occurred_on = (
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
)

View File

@@ -54,7 +54,7 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
serializer_class = StockTransactionSerializer
permission_classes = [IsAuthenticated]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
queryset = StockTransaction.objects.select_related('material')
@@ -77,6 +77,33 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
return queryset
def update(self, request, *args, **kwargs):
instance = self.get_object()
if instance.fertilization_plan_id or instance.spreading_item_id:
return Response(
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
instance = self.get_object()
if instance.fertilization_plan_id or instance.spreading_item_id:
return Response(
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.fertilization_plan_id or instance.spreading_item_id:
return Response(
{'detail': '計画や実績に紐づく入出庫履歴は削除できません。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
class StockSummaryView(generics.ListAPIView):
"""在庫集計一覧"""

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):
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
seed_inventory_kg = models.DecimalField(
max_digits=10,
decimal_places=3,
default=0,
verbose_name="種もみ在庫(kg)",
)
class Meta:
verbose_name = "作物マスタ"
@@ -17,6 +23,21 @@ class Crop(models.Model):
class Variety(models.Model):
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
name = models.CharField(max_length=100, verbose_name="品種名")
default_seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
default=0,
verbose_name="反当苗箱枚数デフォルト",
)
seed_material = models.ForeignKey(
'materials.Material',
on_delete=models.SET_NULL,
related_name='varieties',
verbose_name='種子在庫資材',
blank=True,
null=True,
limit_choices_to={'material_type': 'seed'},
)
class Meta:
verbose_name = "品種マスタ"
@@ -42,3 +63,116 @@ class Plan(models.Model):
def __str__(self):
return f"{self.field.name} - {self.year} - {self.crop.name}"
class PlanVarietyChange(models.Model):
field = models.ForeignKey(
Field,
on_delete=models.PROTECT,
related_name='plan_variety_changes',
verbose_name='圃場',
)
year = models.IntegerField(verbose_name='作付年度')
plan = models.ForeignKey(
Plan,
on_delete=models.CASCADE,
related_name='variety_changes',
verbose_name='作付け計画',
)
changed_at = models.DateTimeField(auto_now_add=True, verbose_name='変更日時')
old_variety = models.ForeignKey(
Variety,
on_delete=models.SET_NULL,
related_name='old_plan_variety_changes',
verbose_name='変更前品種',
null=True,
blank=True,
)
new_variety = models.ForeignKey(
Variety,
on_delete=models.SET_NULL,
related_name='new_plan_variety_changes',
verbose_name='変更後品種',
null=True,
blank=True,
)
reason = models.TextField(blank=True, default='', verbose_name='変更理由')
fertilizer_moved_entry_count = models.IntegerField(default=0, verbose_name='施肥移動エントリ数')
class Meta:
verbose_name = '作付け計画品種変更履歴'
verbose_name_plural = '作付け計画品種変更履歴'
ordering = ['-changed_at', '-id']
def __str__(self):
old_name = self.old_variety.name if self.old_variety else '未設定'
new_name = self.new_variety.name if self.new_variety else '未設定'
return f'{self.field.name} {self.year}: {old_name} -> {new_name}'
class RiceTransplantPlan(models.Model):
name = models.CharField(max_length=200, verbose_name='計画名')
year = models.IntegerField(verbose_name='年度')
variety = models.ForeignKey(
Variety,
on_delete=models.PROTECT,
related_name='rice_transplant_plans',
verbose_name='品種',
)
default_seed_grams_per_box = models.DecimalField(
max_digits=8,
decimal_places=2,
default=0,
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
)
seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
default=0,
verbose_name='反当苗箱枚数',
)
notes = models.TextField(blank=True, default='', verbose_name='備考')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '田植え計画'
verbose_name_plural = '田植え計画'
ordering = ['-year', 'variety']
def __str__(self):
return f'{self.year} {self.name}'
class RiceTransplantEntry(models.Model):
plan = models.ForeignKey(
RiceTransplantPlan,
on_delete=models.CASCADE,
related_name='entries',
verbose_name='田植え計画',
)
field = models.ForeignKey(
Field,
on_delete=models.CASCADE,
related_name='rice_transplant_entries',
verbose_name='圃場',
)
installed_seedling_boxes = models.DecimalField(
max_digits=8,
decimal_places=2,
verbose_name='設置苗箱枚数',
)
seed_grams_per_box = models.DecimalField(
max_digits=8,
decimal_places=2,
verbose_name='苗箱1枚あたり種もみ(g)',
)
class Meta:
verbose_name = '田植え計画エントリ'
verbose_name_plural = '田植え計画エントリ'
unique_together = [['plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f'{self.plan} / {self.field} / {self.installed_seedling_boxes}'

View File

@@ -1,11 +1,26 @@
from decimal import Decimal
from rest_framework import serializers
from apps.fields.models import Field
from apps.materials.models import StockTransaction
from .models import Crop, Variety, Plan
from .models import RiceTransplantEntry, RiceTransplantPlan
from .services import NO_CHANGE, update_plan_with_variety_tracking
class VarietySerializer(serializers.ModelSerializer):
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
class Meta:
model = Variety
fields = '__all__'
fields = [
'id',
'crop',
'name',
'default_seedling_boxes_per_tan',
'seed_material',
'seed_material_name',
]
class CropSerializer(serializers.ModelSerializer):
@@ -20,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
crop_name = serializers.ReadOnlyField(source='crop.name')
variety_name = serializers.ReadOnlyField(source='variety.name')
field_name = serializers.ReadOnlyField(source='field.name')
variety_change_count = serializers.SerializerMethodField()
latest_variety_change = serializers.SerializerMethodField()
class Meta:
model = Plan
@@ -30,7 +47,215 @@ class PlanSerializer(serializers.ModelSerializer):
return Plan.objects.create(**validated_data)
def update(self, instance, validated_data):
return update_plan_with_variety_tracking(
instance,
crop=validated_data.get('crop', NO_CHANGE),
variety=validated_data.get('variety', NO_CHANGE),
notes=validated_data.get('notes', NO_CHANGE),
)
def get_variety_change_count(self, obj):
prefetched = getattr(obj, '_prefetched_objects_cache', {})
changes = prefetched.get('variety_changes')
if changes is not None:
return len(changes)
return obj.variety_changes.count()
def get_latest_variety_change(self, obj):
prefetched = getattr(obj, '_prefetched_objects_cache', {})
changes = prefetched.get('variety_changes')
if changes is not None:
latest = changes[0] if changes else None
else:
latest = obj.variety_changes.select_related('old_variety', 'new_variety').first()
if latest is None:
return None
return {
'id': latest.id,
'changed_at': latest.changed_at,
'old_variety_id': latest.old_variety_id,
'old_variety_name': latest.old_variety.name if latest.old_variety else None,
'new_variety_id': latest.new_variety_id,
'new_variety_name': latest.new_variety.name if latest.new_variety else None,
'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count,
}
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
field_area_tan = serializers.DecimalField(
source='field.area_tan',
max_digits=6,
decimal_places=4,
read_only=True,
)
planned_boxes = serializers.SerializerMethodField()
default_seedling_boxes = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantEntry
fields = [
'id',
'field',
'field_name',
'field_area_tan',
'installed_seedling_boxes',
'default_seedling_boxes',
'planned_boxes',
]
def get_default_seedling_boxes(self, obj):
area = Decimal(str(obj.field.area_tan))
default_boxes_per_tan = obj.plan.seedling_boxes_per_tan
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
def get_planned_boxes(self, obj):
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
variety_name = serializers.CharField(source='variety.name', read_only=True)
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
seed_material_name = serializers.CharField(source='variety.seed_material.name', read_only=True)
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField()
total_seedling_boxes = serializers.SerializerMethodField()
total_seed_kg = serializers.SerializerMethodField()
variety_seed_inventory_kg = serializers.SerializerMethodField()
remaining_seed_kg = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantPlan
fields = [
'id',
'name',
'year',
'variety',
'variety_name',
'crop_name',
'default_seed_grams_per_box',
'seedling_boxes_per_tan',
'notes',
'seed_material_name',
'entries',
'field_count',
'total_seedling_boxes',
'total_seed_kg',
'variety_seed_inventory_kg',
'remaining_seed_kg',
'created_at',
'updated_at',
]
def get_field_count(self, obj):
return obj.entries.count()
def get_total_seedling_boxes(self, obj):
total = sum(
(
entry.installed_seedling_boxes
for entry in obj.entries.all()
),
Decimal('0'),
)
return str(total.quantize(Decimal('0.01')))
def get_total_seed_kg(self, obj):
total = sum(
(
(
entry.installed_seedling_boxes
* obj.default_seed_grams_per_box
/ Decimal('1000')
)
for entry in obj.entries.all()
),
Decimal('0'),
)
return str(total.quantize(Decimal('0.001')))
def get_variety_seed_inventory_kg(self, obj):
return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001')))
def get_remaining_seed_kg(self, obj):
total_seed = Decimal(self.get_total_seed_kg(obj))
return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001')))
def _get_seed_inventory_kg(self, obj):
material = obj.variety.seed_material
if material is None:
return Decimal('0')
transactions = list(material.stock_transactions.all())
increase = sum(
(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
),
Decimal('0'),
)
decrease = sum(
(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
),
Decimal('0'),
)
return increase - decrease
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
model = RiceTransplantPlan
fields = [
'id',
'name',
'year',
'variety',
'default_seed_grams_per_box',
'seedling_boxes_per_tan',
'notes',
'entries',
]
def create(self, validated_data):
entries_data = validated_data.pop('entries', [])
plan = RiceTransplantPlan.objects.create(**validated_data)
self._save_entries(plan, entries_data)
return plan
def update(self, instance, validated_data):
entries_data = validated_data.pop('entries', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if entries_data is not None:
instance.entries.all().delete()
self._save_entries(instance, entries_data)
return instance
def validate(self, attrs):
entries_data = attrs.get('entries')
if entries_data is None:
return attrs
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
missing_ids = sorted(set(field_ids) - existing_ids)
if missing_ids:
raise serializers.ValidationError({
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
})
return attrs
def _save_entries(self, plan, entries_data):
for entry in entries_data:
RiceTransplantEntry.objects.create(
plan=plan,
field_id=entry['field_id'],
installed_seedling_boxes=entry['installed_seedling_boxes'],
seed_grams_per_box=plan.default_seed_grams_per_box,
)

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 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.register(r'crops', views.CropViewSet)
router.register(r'varieties', views.VarietyViewSet)
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
router.register(r'', views.PlanViewSet)
urlpatterns = [

View File

@@ -2,8 +2,15 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Sum
from .models import Crop, Variety, Plan
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
from .models import Crop, Variety, Plan, RiceTransplantPlan
from .serializers import (
CropSerializer,
VarietySerializer,
PlanSerializer,
RiceTransplantPlanSerializer,
RiceTransplantPlanWriteSerializer,
)
from .services import update_plan_with_variety_tracking
from apps.fields.models import Field
@@ -13,16 +20,20 @@ class CropViewSet(viewsets.ModelViewSet):
class VarietyViewSet(viewsets.ModelViewSet):
queryset = Variety.objects.all()
queryset = Variety.objects.select_related('seed_material', 'crop').all()
serializer_class = VarietySerializer
class PlanViewSet(viewsets.ModelViewSet):
queryset = Plan.objects.all()
queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related(
'variety_changes',
'variety_changes__old_variety',
'variety_changes__new_variety',
)
serializer_class = PlanSerializer
def get_queryset(self):
queryset = Plan.objects.all()
queryset = self.queryset
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
@@ -114,19 +125,78 @@ class PlanViewSet(viewsets.ModelViewSet):
updated = 0
created = 0
for field_id in field_ids:
plan, was_created = Plan.objects.update_or_create(
field_id=field_id,
year=year,
defaults={'crop': crop, 'variety': variety}
)
if was_created:
plan = Plan.objects.filter(field_id=field_id, year=year).first()
if plan is None:
Plan.objects.create(
field_id=field_id,
year=year,
crop=crop,
variety=variety,
)
created += 1
else:
updated += 1
continue
update_plan_with_variety_tracking(
plan,
crop=crop,
variety=variety,
)
updated += 1
return Response({'created': created, 'updated': updated, 'total': created + updated})
@action(detail=False, methods=['get'])
def get_crops_with_varieties(self, request):
crops = Crop.objects.prefetch_related('varieties').all()
crops = Crop.objects.prefetch_related('varieties__seed_material').all()
return Response(CropSerializer(crops, many=True).data)
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
queryset = RiceTransplantPlan.objects.select_related(
'variety',
'variety__crop',
'variety__seed_material',
).prefetch_related(
'variety__seed_material__stock_transactions',
'entries',
'entries__field',
)
def get_queryset(self):
queryset = self.queryset
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return RiceTransplantPlanWriteSerializer
return RiceTransplantPlanSerializer
@action(detail=False, methods=['get'])
def candidate_fields(self, request):
year = request.query_params.get('year')
variety_id = request.query_params.get('variety_id')
if not year or not variety_id:
return Response(
{'error': 'year と variety_id が必要です'},
status=status.HTTP_400_BAD_REQUEST,
)
field_ids = Plan.objects.filter(
year=year,
variety_id=variety_id,
).values_list('field_id', flat=True)
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
data = [
{
'id': field.id,
'name': field.name,
'area_tan': str(field.area_tan),
'area_m2': field.area_m2,
'group_name': field.group_name,
}
for field in fields
]
return Response(data)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,11 @@
from django.contrib import admin
from .models import WorkRecord
@admin.register(WorkRecord)
class WorkRecordAdmin(admin.ModelAdmin):
list_display = ['work_date', 'work_type', 'title', 'year', 'auto_created']
list_filter = ['work_type', 'year', 'auto_created']
search_fields = ['title']

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class WorkrecordsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.workrecords'
verbose_name = '作業記録'

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.0 on 2026-03-17 08:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
]
operations = [
migrations.CreateModel(
name='WorkRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('work_date', models.DateField(verbose_name='作業日')),
('work_type', models.CharField(choices=[('fertilizer_delivery', '肥料運搬'), ('fertilizer_spreading', '肥料散布')], max_length=40, verbose_name='作業種別')),
('title', models.CharField(max_length=200, verbose_name='タイトル')),
('year', models.IntegerField(verbose_name='年度')),
('auto_created', models.BooleanField(default=True, verbose_name='自動生成')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('delivery_trip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.deliverytrip', verbose_name='運搬回')),
('spreading_session', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.spreadingsession', verbose_name='散布実績')),
],
options={
'verbose_name': '作業記録',
'verbose_name_plural': '作業記録',
'ordering': ['-work_date', '-updated_at', '-id'],
},
),
]

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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,52 @@
from django.db import models
class WorkRecord(models.Model):
class WorkType(models.TextChoices):
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
LEVEE_WORK = 'levee_work', '畔塗'
work_date = models.DateField(verbose_name='作業日')
work_type = models.CharField(
max_length=40,
choices=WorkType.choices,
verbose_name='作業種別',
)
title = models.CharField(max_length=200, verbose_name='タイトル')
year = models.IntegerField(verbose_name='年度')
auto_created = models.BooleanField(default=True, verbose_name='自動生成')
delivery_trip = models.OneToOneField(
'fertilizer.DeliveryTrip',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='work_record',
verbose_name='運搬回',
)
spreading_session = models.OneToOneField(
'fertilizer.SpreadingSession',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='work_record',
verbose_name='散布実績',
)
levee_work_session = models.OneToOneField(
'levee_work.LeveeWorkSession',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='work_record',
verbose_name='畔塗記録',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-work_date', '-updated_at', '-id']
verbose_name = '作業記録'
verbose_name_plural = '作業記録'
def __str__(self):
return f'{self.work_date} {self.get_work_type_display()}'

View File

@@ -0,0 +1,38 @@
from rest_framework import serializers
from .models import WorkRecord
class WorkRecordSerializer(serializers.ModelSerializer):
work_type_display = serializers.CharField(source='get_work_type_display', read_only=True)
delivery_plan_id = serializers.SerializerMethodField()
delivery_plan_name = serializers.SerializerMethodField()
class Meta:
model = WorkRecord
fields = [
'id',
'work_date',
'work_type',
'work_type_display',
'title',
'year',
'auto_created',
'delivery_trip',
'delivery_plan_id',
'delivery_plan_name',
'spreading_session',
'levee_work_session',
'created_at',
'updated_at',
]
def get_delivery_plan_id(self, obj):
if obj.delivery_trip_id:
return obj.delivery_trip.delivery_plan_id
return None
def get_delivery_plan_name(self, obj):
if obj.delivery_trip_id:
return obj.delivery_trip.delivery_plan.name
return None

View File

@@ -0,0 +1,48 @@
from .models import WorkRecord
def sync_delivery_work_record(trip):
if trip.date is None:
WorkRecord.objects.filter(delivery_trip=trip).delete()
return
WorkRecord.objects.update_or_create(
delivery_trip=trip,
defaults={
'work_date': trip.date,
'work_type': WorkRecord.WorkType.FERTILIZER_DELIVERY,
'title': f'肥料運搬: {trip.delivery_plan.name} {trip.order + 1}回目',
'year': trip.delivery_plan.year,
'auto_created': True,
'spreading_session': None,
},
)
def sync_spreading_work_record(session):
WorkRecord.objects.update_or_create(
spreading_session=session,
defaults={
'work_date': session.date,
'work_type': WorkRecord.WorkType.FERTILIZER_SPREADING,
'title': f'肥料散布: {session.name.strip() or session.date}',
'year': session.year,
'auto_created': True,
'delivery_trip': None,
},
)
def sync_levee_work_record(session):
WorkRecord.objects.update_or_create(
levee_work_session=session,
defaults={
'work_date': session.date,
'work_type': WorkRecord.WorkType.LEVEE_WORK,
'title': session.title,
'year': session.year,
'auto_created': True,
'delivery_trip': None,
'spreading_session': None,
},
)

View File

@@ -0,0 +1,12 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import WorkRecordViewSet
router = DefaultRouter()
router.register(r'', WorkRecordViewSet, basename='workrecord')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,22 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import WorkRecord
from .serializers import WorkRecordSerializer
class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = WorkRecordSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = WorkRecord.objects.select_related(
'delivery_trip',
'delivery_trip__delivery_plan',
'spreading_session',
'levee_work_session',
)
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset

View File

@@ -45,6 +45,8 @@ INSTALLED_APPS = [
'apps.weather',
'apps.fertilizer',
'apps.materials',
'apps.workrecords',
'apps.levee_work',
]
MIDDLEWARE = [

View File

@@ -59,4 +59,6 @@ urlpatterns = [
path('api/weather/', include('apps.weather.urls')),
path('api/fertilizer/', include('apps.fertilizer.urls')),
path('api/materials/', include('apps.materials.urls')),
path('api/workrecords/', include('apps.workrecords.urls')),
path('api/levee-work/', include('apps.levee_work.urls')),
]

1
butler.pid Normal file
View File

@@ -0,0 +1 @@
3396

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

@@ -1,28 +1,31 @@
# マスタードキュメント:施肥計画機能
> **作成**: 2026-03-01
> **最終更新**: 2026-03-15
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布確定
> **実装状況**: 実装完了・本番稼働中
> **最終更新**: 2026-03-17
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布実績記録
> **実装状況**: 実装完了・本番稼働中(散布実績連携追加)
---
## 概要
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力に加えて在庫引当散布確定まで一連で扱う。
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力在庫引当散布実績記録・作業記録索引生成まで一連で扱う。
### 機能スコープIN / OUT
| IN実装済み | OUT対象外 |
|---|---|
| 肥料マスタ管理 | 肥料購入管理 |
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て |
| 3方式の自動計算 | 個別作業日報の詳細管理 |
| 作付け計画からの圃場自動取得 | |
| PDF出力圃場×肥料マトリクス表 | |
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照 |
| 3方式の自動計算 | 運搬便ごとの散布充当追跡 |
| 作付け計画からの圃場自動取得 | 相手先ごとのPDF様式実装 |
| PDF出力圃場×肥料マトリクス表 | 残肥返却・再入庫管理 |
| 在庫引当・引当解除 | |
| 散布確定(計画値確認 + 実績入力 | |
| 散布実績記録(日付単位・運搬済み肥料ベース | |
| 作業記録索引WorkRecord自動生成 | |
| 在庫USE連携散布実績保存時 | |
| 施肥計画進捗表示(未散布/一部散布/完了/計画超過) | |
---
@@ -49,11 +52,20 @@
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
| year | int | required | 年度 |
| variety | FK(plans.Variety) | PROTECT | 品種≠NULL |
| is_confirmed | bool | default=False | 散布確定済みフラグ |
| confirmed_at | datetime | nullable | 散布確定日時 |
| is_confirmed | bool | default=False | ~~散布確定済みフラグ~~deprecated: 新UIでは使用しない |
| confirmed_at | datetime | nullable | ~~散布確定日時~~deprecated: 新UIでは使用しない |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
#### 表示用計算項目APIレスポンスに含まれる
| 項目 | 型 | 説明 |
|---|---|---|
| spread_status | string | `unspread` / `partial` / `completed` / `over_applied` |
| planned_total_bags | decimal | 計画袋数合計全entries.bagsの合計 |
| spread_total_bags | decimal | 散布済み袋数合計全entries.actual_bagsの合計 |
| remaining_total_bags | decimal | 残袋数planned_total_bags - spread_total_bags |
### FertilizationEntry施肥エントリ圃場×肥料×袋数
| フィールド | 型 | 制約 | 説明 |
@@ -62,11 +74,60 @@
| plan | FK(FertilizationPlan) | CASCADE | |
| field | FK(fields.Field) | CASCADE | |
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
| bags | decimal(8,2) | required | 袋数 |
| bags | decimal(8,2) | required | 袋数(計画値) |
| actual_bags | decimal(10,4) | nullable | 散布実績集計値SpreadingSessionItemから自動集計 |
- `unique_together = ['plan', 'field', 'fertilizer']`
- 順序: `field__display_order, field__id, fertilizer__name`
### SpreadingSession散布実績セッション
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| year | int | required | 年度フィルタ用 |
| date | DateField | required | 散布日 |
| name | varchar(100) | required | セッション名(必須) |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
- `year + date` の一意制約は付けない(同日に午前・午後やエリア別で複数記録可能)
### SpreadingSessionItem散布実績明細
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| session | FK(SpreadingSession) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
| fertilizer | FK(Fertilizer) | PROTECT | |
| actual_bags | decimal(10,4) | required | 実散布袋数 |
| planned_bags_snapshot | decimal(10,4) | required | 表示時点の計画値 |
| delivered_bags_snapshot | decimal(10,4) | required | 表示時点の運搬済み合計 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
- `unique_together = ['session', 'field', 'fertilizer']`
### WorkRecord作業記録索引
別アプリ `apps/workrecords/` で管理。施肥・運搬の作業を日付順に一覧するための索引テーブル。
詳細の本体は各業務テーブル側DeliveryTrip / SpreadingSessionに持つ。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| work_date | DateField | required | 作業日 |
| work_type | varchar | required | `fertilizer_delivery` / `fertilizer_spreading` |
| title | varchar(200) | required | 一覧表示名 |
| year | int | required | 年度フィルタ補助 |
| auto_created | bool | default=True | 自動生成フラグ |
| delivery_trip | OneToOne FK(DeliveryTrip) | nullable | 運搬由来 |
| spreading_session | OneToOne FK(SpreadingSession) | nullable | 散布由来 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
---
## API エンドポイント
@@ -106,8 +167,8 @@
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得entries 含む) |
| PUT | `/api/fertilizer/plans/{id}/` | 更新entries 全置換) |
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定(引当 → 使用へ変換 |
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | 散布確定取消(使用 → 引当に戻す |
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | ~~散布確定~~deprecated: UI上で廃止、バックエンドは互換維持 |
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | ~~散布確定取消~~deprecated: UI上で廃止、バックエンドは互換維持 |
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力application/pdf |
一覧レスポンス例FertilizationPlan:
@@ -154,18 +215,60 @@ POST/PUT リクエスト例:
PUT 時は entries が全置換削除→再作成。entries を省略した場合は既存を維持。
散布確定 API リクエスト例:
### 散布実績(新規)
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/fertilizer/spreading/?year={year}` | 年度別一覧 |
| POST | `/api/fertilizer/spreading/` | 新規作成 |
| GET | `/api/fertilizer/spreading/{id}/` | 詳細 |
| PUT | `/api/fertilizer/spreading/{id}/` | 更新 |
| DELETE | `/api/fertilizer/spreading/{id}/` | 削除 |
| GET | `/api/fertilizer/spreading/candidates/?year={year}` | 散布候補一覧 |
散布候補一覧レスポンス例:
```json
[
{
"field": 5,
"field_name": "田中上",
"fertilizer": 1,
"fertilizer_name": "電気炉さい",
"planned_bags": "4.0000",
"delivered_bags": "4.0000",
"spread_bags": "1.5000",
"remaining_bags": "2.5000",
"remaining_plan_bags": "2.5000",
"delivery_gap": "0.0000"
}
]
```
散布実績 POST リクエスト例:
```json
{
"entries": [
{"field_id": 5, "fertilizer_id": 1, "actual_bags": 2.4},
{"field_id": 6, "fertilizer_id": 1, "actual_bags": 0}
"year": 2026,
"date": "2026-04-15",
"name": "午前・田中エリア",
"notes": "",
"items": [
{
"field": 5,
"fertilizer": 1,
"actual_bags": "2.5000",
"planned_bags_snapshot": "4.0000",
"delivered_bags_snapshot": "4.0000"
}
]
}
```
- `actual_bags > 0`: 対応する引当を使用実績へ変換
- `actual_bags = 0`: 未散布として引当解除
### 作業記録(新規・別アプリ)
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/workrecords/?year={year}` | 一覧 |
| GET | `/api/workrecords/{id}/` | 詳細(元レコードへのリンク情報を返す) |
### 圃場候補取得
@@ -290,9 +393,11 @@ GET /api/plans/crops/
### 施肥計画一覧(`/fertilizer`
- 年度セレクタlocalStorage `fertilizerYear` で保持)
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布確定状態
- 操作ボタン: PDF出力・編集・削除・散布確定
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布進捗
- 操作ボタン: PDF出力・編集・削除
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
- 進捗表示: `未散布` / `一部散布 3.5 / 8.0袋` / `散布完了` / `計画超過`
- 計画値と実績値を並べて表示
### 肥料マスタ(`/fertilizer/masters`
@@ -316,11 +421,11 @@ GET /api/plans/crops/
6. **手動調整**: マトリクス表のセルを直接編集
7. **保存**: 「保存」ボタンで entries を一括送信
#### 在庫連携・確定状態
#### 在庫連携・実績表示
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
- 散布確定済みの計画は情報バナーを表示し、編集操作をロック
- 「確定取消」で使用実績を引当に戻し、再編集できる
- マトリクス表で `bags`(計画値)を編集可能、`actual_bags`(実績値)は読み取り専用で参照表示
- 散布実績画面(`/fertilizer/spreading`)へのリンクを表示
#### マトリクスの表示仕様
@@ -329,16 +434,24 @@ GET /api/plans/crops/
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
- 編集中に計算を再実行すると、その肥料列の `adjusted``roundedColumns` がリセットされる
### 散布確定モーダル`/fertilizer` 一覧から起動
### 散布実績画面`/fertilizer/spreading`
- 全画面遷移ではなくモーダル表示
- 施肥計画編集と同じ視線移動になるよう、`圃場 = 行``肥料 = 列` のマトリクス表を採用
- 画面上部に計画名・年度・作物/品種・対象圃場数・肥料数を表示
- 各セルは「薄いグレーの計画値」+「実績入力欄」の2段表示
- 行末に圃場ごとの実績合計、表フッターに肥料別合計と総合計を表示
- `0` を入力したセルは未散布として扱い、対応する引当を解除する
- 年度セレクタlocalStorage `fertilizerYear` と連動)
- 散布日入力DateField
- セッション名入力(必須)
- 運搬済み・未散布候補一覧を表示(`candidates` APIから取得
- 圃場単位で選択可能(全部または一部)
- 実績袋数の編集
- 差異がある場合はインライン警告表示
- 保存時に在庫USE連携・WorkRecord自動生成・FertilizationEntry.actual_bags再集計を実行
#### State 構成
### 作業記録画面(`/workrecords`
- 年度セレクタ
- 日付・作業種別・タイトルの一覧表示
- 元データ(運搬回 / 散布セッション)への遷移リンク
#### State 構成(施肥計画編集画面)
```typescript
// 基本情報
@@ -374,13 +487,15 @@ backend/apps/fertilizer/
├── __init__.py
├── admin.py # Django admin 登録
├── apps.py # FertilizerConfig
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
├── urls.py # DefaultRouter + candidate_fields/ + calculate/
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry, SpreadingSession, SpreadingSessionItem
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer, SpreadingSessionSerializer
├── services.py # actual_bags再集計、WorkRecord自動生成、在庫USE連携
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, SpreadingSessionViewSet, CandidateFieldsView, CalculateView
├── urls.py # DefaultRouter + candidate_fields/ + calculate/ + spreading/
├── migrations/
│ ├── 0001_initial.py
── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
│ └── ... # SpreadingSession, SpreadingSessionItem, actual_bags 追加
└── templates/
└── fertilizer/
└── pdf.html # WeasyPrint テンプレートA4横向き
@@ -398,25 +513,131 @@ frontend/src/app/fertilizer/
│ └── page.tsx # 編集FertilizerEditPage をラップ)
├── masters/
│ └── page.tsx # 肥料マスタ管理
├── spreading/
│ └── ... # 散布実績画面(一覧・作成・編集)
└── _components/
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
frontend/src/app/workrecords/
└── ... # 作業記録画面(一覧・詳細)
```
### 変更されたファイル
| ファイル | 変更内容 |
|---|---|
| `backend/keinasystem/settings.py` | `INSTALLED_APPS``'apps.fertilizer'` を追加 |
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
| `backend/keinasystem/settings.py` | `INSTALLED_APPS``'apps.fertilizer'`, `'apps.workrecords'` を追加 |
| `backend/keinasystem/urls.py` | `api/fertilizer/`, `api/workrecords/` を追加 |
| `backend/apps/materials/models.py` | `StockTransaction.spreading_item` FK 追加(`on_delete=SET_NULL` |
| `backend/apps/workrecords/` | 作業記録索引アプリWorkRecord モデル・API・services |
| `frontend/src/types/index.ts` | 施肥・散布・作業記録の型を追加 |
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |
---
## 在庫連携
### RESERVE施肥計画保存時
- 従来どおり計画値 `bags` ベースで維持
- 施肥計画の entries 保存時に RESERVE トランザクションを作成
### USE散布実績保存時
- `SpreadingSessionItem` ごとに USE を1件作成
- `material`: `item.fertilizer.material`
- `quantity`: `actual_bags`
- `occurred_on`: `session.date`
- `note`: `散布実績「{session.name or session.date}」`
### StockTransaction 追加フィールド
- `spreading_item = FK(SpreadingSessionItem, null=True, blank=True, on_delete=SET_NULL)`
### 更新・削除
- 散布実績更新時: その session に紐づく USE を全置換で作り直す
- 散布実績削除時: 対応 USE を削除する
### RESERVE と USE の整合
- RESERVE は計画値 `bags` ベース
- USE は散布実績 `actual_bags` ベース
- 計画値と実績値は併存する
---
## 集計ルール
### planned_total圃場×肥料×年度
`FertilizationEntry.bags` の合計
### delivered_total圃場×肥料×年度
`DeliveryTrip.date != null``DeliveryTripItem.bags` 合計
### spread_total圃場×肥料×年度
`SpreadingSessionItem.actual_bags` の合計
### actual_bags 再集計ルール
- `SUM(SpreadingSessionItem.actual_bags)` を同一 year, field, fertilizer で集計
- 散布実績の保存・更新・削除時に該当する `FertilizationEntry.actual_bags` を即時再計算
- `SUM(...) = 0` の場合は `actual_bags = null`
### remaining_bags表示用の残量
`delivered_total - spread_total`
### remaining_plan_bags計画進捗用の残量
`planned_total - spread_total`
### 差異の扱い
- `remaining_bags < 0`: 運搬実績不足
- `remaining_plan_bags < 0`: 計画超過
- 圃場+肥料単位で差異が分かることを優先する
---
## WorkRecord 自動生成ルール
### 運搬fertilizer_delivery
- `DeliveryTrip.date` 保存時に upsert
- `title = 肥料運搬: {delivery_plan.name} {n}回目`
- 日付削除時は対応 WorkRecord を削除
### 散布fertilizer_spreading
- `SpreadingSession` 保存時に upsert
- `title = 肥料散布: {session.name or session.date}`
- 削除時は対応 WorkRecord を削除
### 実装方針
自動生成は view に直書きせず、サービス層(`services.py`)で idempotent に実装する。
---
## 前年度コピー
`copy_from_previous_year` で前年度の `FertilizationEntry` をコピーする際のルール:
- `actual_bags` がある場合: `actual_bags` を新年度の `bags` 初期値として使用
- `actual_bags``null` の場合: 従来どおり `bags` をコピー
前年度に実際に散布した量を次年度計画の初期値として再利用できる。
---
## 型定義TypeScript
```typescript
// frontend/src/types/index.ts
// frontend/src/types/index.ts(主要な型のみ抜粋)
export interface Fertilizer {
id: number;
@@ -437,6 +658,7 @@ export interface FertilizationEntry {
fertilizer: number;
fertilizer_name: string;
bags: string;
actual_bags: string | null; // 散布実績集計値
}
export interface FertilizationPlan {
@@ -449,6 +671,10 @@ export interface FertilizationPlan {
field_count: number;
fertilizer_count: number;
entries: FertilizationEntry[];
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
planned_total_bags: string;
spread_total_bags: string;
remaining_total_bags: string;
}
```
@@ -487,6 +713,25 @@ plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-v
PUT 時は entries を全削除→再作成する「全置換」方式。
部分更新は非対応PATCH でも entries がある場合は全置換)。
### 散布実績の在庫連携
- 施肥計画保存時: `RESERVE`(計画値 `bags` ベース)
- 散布実績保存時: `USE`(実績値 `actual_bags` ベース)
- `RESERVE``USE` は併存する(計画値と実績値は別管理)
- 散布実績更新時は `session` に紐づく `USE` を全置換で作り直す
- 散布実績削除時は対応 `USE` を削除する(`StockTransaction.spreading_item``SET_NULL`
- `perform_destroy` で明示的に `StockTransaction` を削除してから `session.delete()` を呼ぶ
### 散布セッション名は必須
`SpreadingSession.name` は必須フィールド。WorkRecord のタイトル生成や一覧表示に使用するため、
空文字での保存は許可しない。
### useSearchParams と SuspenseNext.js 14
散布実績画面(`/fertilizer/spreading`)では `useSearchParams()` を使用するため、
`Suspense` boundary でラップする必要がある(本番ビルドで必須)。
### Next.js ホットリロードが効かない問題Windows + Docker
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
@@ -499,6 +744,8 @@ Windows 環境では Docker ボリュームマウント経由のファイル変
## 将来の拡張(スコープ外)
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討
- **相手先別PDF様式**: 客先ごとの提出資料フォーマット(元データは散布実績から取得可能
- **残肥返却・再入庫管理**: 散布後の残りを在庫に戻す処理
- **SpreadingAllocation**: 運搬便単位の散布充当追跡(現状は集計ベースで十分)
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討

View File

@@ -1,65 +1,140 @@
# マスタードキュメント:分配計画機能
# マスタードキュメント:運搬計画機能(旧・分配計画)
> **作成**: 2026-03-02
> **最終更新**: 2026-03-02
> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計
> **実装状況**: 実装完了
> **最終更新**: 2026-03-16
> **対象機能**: 運搬計画(施肥計画の肥料を軽トラで運ぶ単位で計画・記録する
> **実装状況**: 本番稼働中
---
## 概要
施肥計画FertilizationPlanで決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。
「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。
施肥計画で決めた圃場ごとの肥料袋数を、**軽トラ1回分の積載単位**で運搬計画にまとめる機能。
実際の作業では一度に全部運べないため、「何回目にどのグループのどの肥料を何袋運ぶか」を計画・記録する。
### 旧設計(分配計画)からの変更理由
旧設計は「1つの施肥計画の圃場をグループ分けする」だけだった。
実運用で以下のギャップが判明2026-03-16
1. **複数の施肥計画が混在する** - 軽トラには品種をまたいで積む
2. **単一の施肥計画が分割される** - 1回で運びきれない
3. **全肥料を一度に運ぶわけではない** - 運ぶ肥料を指定する必要がある
4. **圃場単位の合計袋数は不要** - グループ×肥料の合計が重要
5. **同じグループの圃場を回ごとに分割する** - 載りきらないときは次の回に
6. **作業記録でもある** - 運搬した日付を記録したい
### 機能スコープ
| IN実装済み | OUT対象外 |
| IN実装対象 | OUT対象外 |
|---|---|
| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 |
| グループ×肥料の集計表(画面表示) | 実施記録 |
| PDF出力グループ合計行圃場サブ行 | |
| グループの順序変更・名前変更 | |
| 年度単位の運搬計画作成 | 購入管理 |
| 配送先グループへの圃場割り当て | 肥料の在庫管理 |
| 運搬回ごとの圃場×肥料割り当て | ルート最適化 |
| 回ごとの積載合計リアルタイム表示 | |
| 圃場を回の間で移動する操作 | |
| 「残り全部」一括割り当て | |
| 回ごとの運搬日記録 | |
| PDF出力回ごとに1ページ | |
---
## データモデル
### DistributionPlan分配計画
### 旧モデルからの移行
| 旧(削除) | 新(追加) | 備考 |
|---|---|---|
| DistributionPlan | DeliveryPlan | FK(FertilizationPlan) 廃止 → year ベース |
| DistributionGroup | DeliveryGroup | ほぼ同等 |
| DistributionGroupField | DeliveryGroupField | ほぼ同等 |
| (なし) | DeliveryTrip | 新規:運搬回 |
| (なし) | DeliveryTripItem | 新規:運搬明細(圃場×肥料単位) |
### DeliveryPlan運搬計画
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| fertilization_plan | FK(FertilizationPlan) | CASCADE | |
| year | int | required | 年度 |
| name | varchar(200) | required | 計画名 |
| created_at / updated_at | datetime | auto | |
- `ordering = ['-fertilization_plan__year', 'name']`
- 1つの施肥計画に対して複数の分配計画を作れるOneToOneではなくFK
- `ordering = ['-year', 'name']`
- 施肥計画への直接FK なし(年度ベースで全施肥計画を横断
### DistributionGroup配グループ)
### DeliveryGroup送先グループ)
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| distribution_plan | FK(DistributionPlan) | CASCADE | |
| name | varchar(100) | required | グループ名 |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| name | varchar(100) | required | グループ名(例: キウイ, 足川北) |
| order | PositiveIntegerField | default=0 | 表示順 |
- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可
- `unique_together = [['delivery_plan', 'name']]`
- `ordering = ['order', 'id']`
### DistributionGroupFieldグループ圃場割り当て
### DeliveryGroupFieldグループ圃場割り当て
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 |
| group | FK(DistributionGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | 圃場 |
| delivery_plan | FK(DeliveryPlan) | CASCADE | 一意制約 |
| group | FK(DeliveryGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画
- `ordering = ['field__display_order', 'field__id']`
- `unique_together = [['delivery_plan', 'field']]` → 1圃場=1グループ/1計画
### DeliveryTrip運搬回
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| order | PositiveIntegerField | default=0 | 何回目(表示順) |
| name | varchar(100) | blank | 任意の名前(例: "たちはるか電気炉さい" |
| date | DateField | nullable | 運搬日(デフォルト: 1回目の日付を引き継ぎ |
- `ordering = ['order', 'id']`
### DeliveryTripItem運搬明細
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| trip | FK(DeliveryTrip) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
| fertilizer | FK(Fertilizer) | PROTECT | |
| bags | Decimal(10,4) | required | 袋数 |
- `unique_together = [['trip', 'field', 'fertilizer']]`
- bags は施肥計画の FertilizationEntry から自動計算で初期値を設定するが、手動上書きも可能
### ER図概念
```
DeliveryPlan (運搬計画)
├── year, name
├── groups → DeliveryGroup (配送先グループ)
│ ├── name, order
│ └── fields → DeliveryGroupField → Field
└── trips → DeliveryTrip (運搬回)
├── order, name, date
└── items → DeliveryTripItem
├── field → Field
├── fertilizer → Fertilizer
└── bags
```
### 袋数の算出ルール
1. 運搬計画作成時、年度の全 FertilizationEntry を参照して「グループ×肥料→圃場×袋数」を自動算出
2. ユーザーが運搬回に圃場を割り当てると、該当する FertilizationEntry の bags が DeliveryTripItem.bags にコピーされる
3. 手動で bags を上書きすることも可能(施肥計画との差異は許容)
4. 「残り全部」操作: 施肥計画の合計 既に割り当て済みの回の合計 = 残り
---
@@ -69,143 +144,212 @@
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/fertilizer/distribution/?year={year}` | 一覧(年度フィルタ) |
| POST | `/api/fertilizer/distribution/` | 新規作成 |
| GET | `/api/fertilizer/distribution/{id}/` | 詳細groups/entries/unassigned込み) |
| PUT | `/api/fertilizer/distribution/{id}/` | 更新groups全置換 |
| DELETE | `/api/fertilizer/distribution/{id}/` | 削除 |
| GET | `/api/fertilizer/distribution/{id}/pdf/` | PDF出力application/pdf |
| GET | `/api/fertilizer/delivery/?year={year}` | 一覧(年度フィルタ) |
| POST | `/api/fertilizer/delivery/` | 新規作成 |
| GET | `/api/fertilizer/delivery/{id}/` | 詳細groups/trips/items 込み) |
| PUT | `/api/fertilizer/delivery/{id}/` | 更新groups・trips 全置換) |
| DELETE | `/api/fertilizer/delivery/{id}/` | 削除 |
| GET | `/api/fertilizer/delivery/{id}/pdf/` | PDF出力 |
### 一覧レスポンスDistributionPlanListSerializer
### 一覧レスポンス
```json
{
"id": 1,
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan_id": 3,
"fertilization_plan_name": "2025年コシヒカリ施肥計画",
"year": 2025,
"variety_name": "コシヒカリ",
"crop_name": "米",
"group_count": 3,
"field_count": 12,
"year": 2026,
"name": "2026春 肥料運搬",
"group_count": 5,
"trip_count": 3,
"created_at": "...",
"updated_at": "..."
}
```
### 詳細レスポンスDistributionPlanReadSerializer
### 詳細レスポンス
```json
{
"id": 1,
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan": {
"id": 3,
"name": "2025年コシヒカリ施肥計画",
"year": 2025,
"variety_name": "コシヒカリ",
"crop_name": "米",
"fertilizers": [{"id": 1, "name": "一発肥料"}],
"entries": [{"field": 5, "fertilizer": 1, "bags": "2.40"}]
},
"year": 2026,
"name": "2026春 肥料運搬",
"groups": [
{
"id": 10,
"name": "田中エリア",
"name": "キウイ",
"order": 0,
"fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}]
"fields": [
{"id": 5, "name": "キウイ畑1", "area_tan": "1.2000"}
]
}
],
"unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}]
"trips": [
{
"id": 1,
"order": 0,
"name": "1回目 たちはるか電気炉さい",
"date": "2026-03-16",
"items": [
{"field": 5, "fertilizer": 1, "bags": "4.00"}
]
}
],
"unassigned_fields": [],
"available_fertilizers": [
{"id": 1, "name": "電気炉さい"},
{"id": 2, "name": "ミネラルホウ素"}
]
}
```
- `available_fertilizers`: 該当年度の全施肥計画で使われている肥料の一覧
- `unassigned_fields`: グループに割り当てられていない圃場
### 書き込みリクエストPOST/PUT
```json
{
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan_id": 3,
"year": 2026,
"name": "2026春 肥料運搬",
"groups": [
{"name": "田中エリア", "order": 0, "field_ids": [5, 6]},
{"name": "奥地エリア", "order": 1, "field_ids": [7]}
{"name": "キウイ", "order": 0, "field_ids": [5, 6]}
],
"trips": [
{
"order": 0,
"name": "1回目",
"date": "2026-03-16",
"items": [
{"field_id": 5, "fertilizer_id": 1, "bags": "4.00"}
]
}
]
}
```
PUT は groups を全削除→再作成する全置換方式。
PUT は groups・trips を全削除→再作成する全置換方式。
---
## フロントエンド画面
### 分配計画一覧 `/distribution`
### 運搬計画一覧 `/distribution`
- 年度セレクタ(`localStorage distributionYear` で保持)
- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場
- テーブル: 計画名・グループ数・
- アクション: PDF・編集・削除
- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示)
### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit`
### 運搬計画編集 `/distribution/new` / `/distribution/[id]/edit`
**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx`
#### 画面レイアウト
#### State構成
```
[計画名: ________________] [年度: 2026]
```typescript
// 基本情報
const [name, setName] = useState('')
const [fertilizationPlanId, setFertilizationPlanId] = useState<number|''>('')
━━━ グループ定義 ━━━━━━━━━━━━━━━━━━━
(既存の方式: グループ追加・圃場割り当て・並び替え)
// 施肥計画詳細(施肥計画選択後に取得)
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null)
━━━ 対象肥料 ━━━━━━━━━━━━━━━━━━━━━
☑電気炉さい ☑ミネラルホウ素 ☐有機100号 ...
(年度の施肥計画に含まれる肥料をチェックボックスで選択)
// ローカルグループtempId で管理、保存時にサーバーへ送信)
const [groups, setGroups] = useState<LocalGroup[]>([])
// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string }
━━━ 未割り当て ━━━━━━━━━━━━━━━━━━━━
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
圃場A 電気炉さい:2 ミネラルホウ素:3 [→1回目 ▼]
圃場B 電気炉さい:2 ミネラルホウ素:2 [→1回目 ▼]
★ 足川北 (小計: 電気炉さい 12, ミネラルホウ素 6)
圃場D ...
━━━ 1回目 (2026-03-16) ━━━ 積載: 46袋 ━━━
日付: [2026-03-16] 名前: [たちはるか電気炉さい]
★ たちはるか (小計: 電気炉さい 46)
圃場X 電気炉さい:10 [←戻す]
圃場Y 電気炉さい:12 [←戻す]
...
━━━ 2回目 (2026-03-16) ━━━ 積載: 39袋 ━━━
日付: [2026-03-16] 名前: [____________]
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
...
[+回を追加] [残り全部→新しい回] [保存]
```
#### UI構成
#### 主要な操作
1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ
2. **グループ割り当て**:
- 新規グループ追加(名前入力 + 追加ボタン)
- グループカード(↑↓順序変更・鉛筆名前変更・×削除)
- グループ内圃場(×解除)+ 肥料別袋数をインライン表示
- 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て
3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし)
| 操作 | 方法 | 説明 |
|---|---|---|
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
| 圃場を別の回に移動 | 圃場行の「移動...」ドロップダウン | 回の間で移動 |
| グループを回に一括割り当て | 回内の「グループを追加...」ドロップダウン | グループの全圃場を一括割り当て |
| グループを別の回に移動 | グループ★行の「移動...」ドロップダウン | グループの全圃場を回の間で一括移動 |
| グループを未割り当てに戻す | グループ★行の「移動...」→「未割り当てに戻す」 | グループの全圃場を一括で未割り当てに戻す |
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て全圃場を新しい回に追加 |
| 回の追加 | 「+回を追加」ボタン | 空の回を追加 |
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
| 回の日付設定 | 日付入力フィールド | デフォルトは1回目の日付 |
| 対象肥料の絞り込み | チェックボックス | 選択した肥料だけ表示 |
#### 積載合計のリアルタイム表示
各回のヘッダーに、その回の肥料ごとの合計袋数と総袋数を表示。
圃場を追加・削除するたびに即時再計算(サーバー通信なし)。
---
## PDF 出力
`GET /api/fertilizer/distribution/{id}/pdf/`
`GET /api/fertilizer/delivery/{id}/pdf/`
- WeasyPrint既存施肥計画PDFと同じ仕組み
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html`
- フォーマット: A4横向き
- 内容:
- ★グループ合計行(太字・緑背景)
- 圃場サブ行(小フォント・灰色背景)
- 肥料列合計・総合計
- ファイル名: `distribution_{year}_{plan_id}.pdf`
### フォーマット
- WeasyPrint、A4横向き
- **回ごとに1ページ**1回目=1ページ目、2回目=2ページ目...
### 各ページの内容
```
━━━ 2回目 2026-03-16 ━━━━━━━━━━━━━━━
電気炉さい ミネラルホウ素
★ キウイ 4 5
圃場A 2 3
圃場B 2 2
★ 池田さんちの前 2 2
圃場C 2 2
★ 足川北 12 6
圃場D 4 2
圃場E 4 2
圃場F 4 2
★ 出祥邸 - 8
圃場G - 4
圃場H - 4
─────────────────────────────────────
合計 18 21
```
- ★行: グループ小計(肥料ごと)、太字・緑背景
- 圃場行: 各圃場の肥料ごとの袋数(**合計列なし**
- 最下行: 回全体の肥料ごと合計
- 日付を各ページのヘッダーに記載
- ファイル名: `delivery_{year}_{plan_id}.pdf`
---
## ファイル構成
## ファイル構成(予定)
### Backend
```
backend/apps/fertilizer/
├── models.py # DistributionPlan/Group/GroupField 追加migration 0003
├── serializers.py # Distribution* シリアライザ追加
├── views.py # DistributionPlanViewSet 追加
├── urls.py # router.register('distribution', ...) 追加
├── admin.py # DistributionPlan/Group の admin 登録
├── models.py # DeliveryPlan/Group/GroupField/Trip/TripItem
├── serializers.py # Delivery* シリアライザ
├── views.py # DeliveryPlanViewSet
├── urls.py # router.register('delivery', ...)
├── admin.py # DeliveryPlan 等の admin 登録
├── migrations/
│ └── 000X_delivery_*.py # 旧Distribution → 新Delivery マイグレーション
└── templates/fertilizer/
└── distribution_pdf.html # A4横 PDF テンプレート
└── delivery_pdf.html # 回ごと1ページ PDF テンプレート
```
### Frontend
@@ -215,27 +359,54 @@ frontend/src/app/distribution/
├── page.tsx # 一覧ページ
├── new/page.tsx # 新規作成(ラッパー)
├── [id]/edit/page.tsx # 編集(ラッパー)
└── _components/DistributionEditPage.tsx # 編集共通コンポーネント
└── _components/DeliveryEditPage.tsx # 編集共通コンポーネント
```
---
## マイグレーション方針
### 旧モデルDistribution*)の扱い
1. 新モデルDelivery*)を追加するマイグレーションを作成
2. 旧モデルDistribution*)は削除マイグレーションで除去
3. 旧データは少量のため、データ移行は行わない(手動で再作成)
### マイグレーション順序
1. `000X_add_delivery_models.py` - DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem を追加
2. `000Y_remove_distribution_models.py` - DistributionPlan, DistributionGroup, DistributionGroupField を削除
---
## 注意点
### 集計は全クライアントサイド計算
### 施肥計画との関係
集計プレビューは API を呼ばず、`fertPlanDetail.entries``groups.fieldIds` からクライアントで計算する。
- 運搬計画は施肥計画への直接FKを持たない
- 年度ベースで、その年度の全 FertilizationEntry を参照して圃場×肥料の袋数を取得する
- 施肥計画を変更すると、未割り当ての圃場の袋数は自動で反映される
- 既に運搬回に割り当て済みの DeliveryTripItem.bags は変わらない(コピー済み)
### 集計はクライアントサイド計算
画面上の集計(グループ小計・回の積載合計)は API を呼ばずクライアントで計算。
PDF生成時のみサーバーサイドで同じ計算を実施。
### PUT の全置換方式
PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。
### 未割り当て圃場の扱い
- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示
- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし)
### エラー表示方針
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
### 散布実績との連携
- 運搬計画の `DeliveryTripItem` が散布実績画面(`/fertilizer/spreading`)の候補データソースとなる
- `DeliveryTrip.date != null` の明細のみを「運搬済み」とみなし、散布候補に含める
- 散布実績画面から運搬計画を指定して遷移する場合(`?delivery_plan_id=N`)、日付フィルタは適用されない(その計画の全明細が候補になる)
- 散布実績の保存時に在庫 `USE` が作成される(運搬時点では在庫変動なし)
### WorkRecord 自動生成
- `DeliveryTrip` に日付が保存されると、`WorkRecord``work_type=fertilizer_delivery`)が自動生成される
- 実装: `apps/workrecords/services.py``sync_delivery_work_record()`
- `DeliveryTrip` の日付が削除されると、対応する `WorkRecord` も削除される
- `WorkRecord` は索引として機能し、明細データは `DeliveryTrip` / `DeliveryTripItem` 側が保持する

View File

@@ -0,0 +1,411 @@
# マスタードキュメント:トラクター作業記録機能
> **作成**: 2026-04-04
> **最終更新**: 2026-04-10
> **対象機能**: トラクター作業記録(畔塗・荒代掻き・植代掻き・耕耘)
> **実装状況**: 畔塗のみ実装済み。荒代掻き・植代掻き・耕耘は設計中Issue #21
> **対象 Issue**: `akira/keinasystem#21`
---
## 概要
農業生産者が、水稲作付け圃場に対して実施したトラクター作業を日付単位で記録する機能。
対象圃場をまとめて選択し、保存時に作業記録一覧へ自動反映する。
対象作業種別:
| 種別 | 日本語名 | 説明 |
|---|---|---|
| `levee_work` | 畔塗 | 畦畔の補修・造成 |
| `rough_harrowing` | 荒代掻き | 田植え前の粗い代掻き |
| `transplant_harrowing` | 植代掻き | 田植え直前の仕上げ代掻き |
| `cultivation` | 耕耘 | 土起こし・耕起 |
これらはいずれも**トラクターを用いた資材なし作業**であり、同一のデータモデルで管理する。
本機能は、施肥計画の散布実績と同様に
「作業本体を専用テーブルで持ち、作業記録一覧には索引を自動生成する」
という設計方針を採用する。
### 機能スコープIN / OUT
| IN本機能で扱う | OUT本機能では扱わない |
|---|---|
| 日付単位の作業記録作成全4種別 | 作業の工程管理 |
| 水稲作付け圃場の候補抽出 | 作業者別の工数集計 |
| 複数圃場の一括選択・保存 | 機械・資材の在庫管理 |
| 作業記録一覧WorkRecordへの自動反映 | 写真添付 |
| 記録の編集・削除 | GPS軌跡連携 |
| 対象圃場一覧の参照画面 | 汎用作業日誌への完全統合 |
---
## 背景と目的
現状システムには畔塗の記録機能があるが、同じトラクター作業である荒代掻き・植代掻き・耕耘は登録できない。
これらは以下の共通点を持つため、統一モデルで管理する。
- 1日で複数圃場をまとめて実施することが多い
- 対象圃場は当年の作付け計画と密接に関係する
- 後から「いつ、どの圃場を実施したか」を一覧で見返したい
- 使用資材がない(施肥・農薬とは区別される)
---
## データモデル
### TractorWorkSessionトラクター作業記録本体
日付単位のトラクター作業記録。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| work_type | varchar(30) | required | 作業種別(下記参照) |
| year | int | required | 年度フィルタ用。原則 `date.year` と一致させる |
| date | DateField | required | 作業日 |
| title | varchar(100) | required | 一覧表示タイトル。未指定時はサーバー側で work_type に応じたデフォルト値を補完する |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
#### work_type の値とデフォルトタイトル
| work_type | 表示名 | デフォルトタイトル |
|---|---|---|
| `levee_work` | 畔塗 | 水稲畔塗 |
| `rough_harrowing` | 荒代掻き | 水稲荒代掻き |
| `transplant_harrowing` | 植代掻き | 水稲植代掻き |
| `cultivation` | 耕耘 | 水稲耕耘 |
- `year + date` の一意制約は付けない
- 同日に種別違い・地区違いで複数記録を持てるようにする
### TractorWorkSessionItem対象圃場明細
トラクター作業記録に紐づく対象圃場一覧。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| session | FK(TractorWorkSession) | 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` enum | `TRACTOR_WORK = 'tractor_work'` を追加(`LEVEE_WORK` を置換) |
| FK | `tractor_work_session = OneToOneField('tractor_work.TractorWorkSession', ...)` に改名 |
制約:
- `on_delete=CASCADE`
- `null=True`, `blank=True`
- `related_name='work_record'`
一覧表示時の想定値:
| 項目 | 値 |
|---|---|
| 作業日 | 作業記録の日付 |
| 種別 | トラクター作業work_type の日本語表示) |
| タイトル | session.title |
| 参照先 | 対象圃場一覧画面 |
---
## 候補圃場抽出ルール
候補は作付け計画 `Plan` から抽出する。
### 基本条件
- 指定年度の `Plan` であること
- `crop.name = "水稲"` の圃場であること
### 補足
- 品種未設定でも `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` |
---
## 画面仕様
### 画面の位置づけ
日付と作業種別を先に決めて対象圃場を選ぶ「日報型UI」。
1回の保存で複数圃場をまとめて記録する。
### 主要画面
#### 1. トラクター作業記録一覧画面(`/tractor-work`
- 年度内の記録を一覧する
- 作業種別でフィルター可能
- 新規作成・既存記録の編集・削除
表示項目: 作業日 / 作業種別 / タイトル / 対象圃場数 / 面積合計 / 備考
#### 2. 作成・編集画面
入力項目:
- **作業種別**(畔塗 / 荒代掻き / 植代掻き / 耕耘)← 新規追加
- 日付
- タイトルwork_type に連動したデフォルト値を自動セット)
- 備考
- 対象圃場一覧(チェックボックス)
### 推奨UIイメージ
```text
トラクター作業記録作成
[作業種別 荒代掻き ▼]
[日付 2026-04-20]
[タイトル 水稲荒代掻き]
[備考 __________________ ]
対象圃場一覧
[全選択] [全解除]
☑ 田中上 1.2反 上エリア コシヒカリ
☑ 田中下 0.8反 上エリア あきたこまち
☐ 山の前 1.5反 南エリア (未設定)
[保存]
```
---
## API エンドポイント
すべて JWT 認証必須。
### トラクター作業記録
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/tractor-work/sessions/?year={year}` | 年度別一覧 |
| POST | `/api/tractor-work/sessions/` | 新規作成 |
| GET | `/api/tractor-work/sessions/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/tractor-work/sessions/{id}/` | 更新 |
| DELETE | `/api/tractor-work/sessions/{id}/` | 削除 |
### 候補圃場取得
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/tractor-work/candidates/?year={year}` | 水稲作付け圃場候補を返す |
### リクエスト例(新規作成)
```json
{
"work_type": "rough_harrowing",
"year": 2026,
"date": "2026-04-20",
"title": "水稲荒代掻き",
"notes": "",
"items": [
{ "field": 5, "plan": 12 },
{ "field": 6, "plan": 13 }
]
}
```
- `crop_name_snapshot` / `variety_name_snapshot` はクライアント送信不要。サーバーが `plan` から自動設定する
- `plan``null` の場合は `field` に対応する当年 `Plan` から補完を試みる
### レスポンス例(詳細)
```json
{
"id": 3,
"work_type": "rough_harrowing",
"year": 2026,
"date": "2026-04-20",
"title": "水稲荒代掻き",
"notes": "",
"work_record_id": 15,
"item_count": 2,
"total_area_tan": "2.0000",
"items": [
{
"id": 11,
"field": 5,
"field_name": "田中上",
"plan": 12,
"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. 保存時に `TractorWorkSession` を作成する
5. 明細として `TractorWorkSessionItem` を一括作成する
6. 各明細の `crop_name_snapshot` / `variety_name_snapshot` をサーバー側で自動設定する
7. `WorkRecord` を自動生成する(`update_or_create`
### 2. 編集
1. ユーザーが既存の作業記録を開く
2. 作業種別・日付・タイトル・備考・対象圃場を変更する
3. 保存時に明細を再構成する
4. `WorkRecord` 側の作業日・タイトルも同期更新する
### 3. 削除
1. ユーザーが作業記録を削除する
2. 紐づく `TractorWorkSessionItem``CASCADE` で削除される
3. 紐づく `WorkRecord``tractor_work_session``on_delete=CASCADE` により削除される
---
## 作業記録連携仕様
### 追加する種別
| enum値 | 表示名 |
|---|---|
| `tractor_work` | トラクター作業 |
### 自動生成ルール
- `work_date` = `session.date`
- `work_type` = `tractor_work`
- `title` = `session.title`work_type 別デフォルトで補完済み)
- `year` = `session.year`
- `auto_created` = `True`
- `tractor_work_session` = 対応する作業記録
### 同期タイミング
- 作成時・更新時: `update_or_create`
- 削除時: `on_delete=CASCADE` により自動削除
---
## バリデーションルール
### 必須
- `work_type`
- `year`
- `date`
- `items`1件以上
### 保存時チェック
- 選択圃場が0件の保存を禁止する
- 同一セッション内で同じ圃場を重複登録しない
- `year` は原則 `date.year` と一致しなければならない
- `plan` が指定されている場合、`plan.field``field` は一致しなければならない
- `plan.year``session.year` と一致しなければならない
### 業務上の許容
- 品種未設定の水稲圃場は保存可
- 同日に別種別・別地区で複数記録を持てる
- 一度作業した圃場を別日に再度記録することは可
---
## 実装方針
### 移行方針levee_work → tractor_work
既存 `apps/levee_work``apps/tractor_work` にアプリごと改名する。
- Django の `RenameModel` migration でテーブルを改名する
- `work_type` フィールドを追加し、既存レコードは `levee_work` で埋める
- `workrecords` の FK名・enum値も migration で更新する
- API パスを `/levee-work/``/tractor-work/` に変更する
- フロントエンドの `app/levee-work/``app/tractor-work/` に移動する
### バックエンド
- `Session` / `SessionItem` 構成を維持する
- Serializer は `read``write` を分離する
- 候補取得 API は `Plan` を起点に組み立てる
- `sync_tractor_work_record(session)``WorkRecord` と同期する
### フロントエンド
- 既存の levee-work ページを tractor-work に移植する
- 作業種別セレクタを追加し、選択に応じてデフォルトタイトルを自動セットする
---
## ソースファイル構成
### バックエンド
```
backend/apps/tractor_work/
├── models.py # TractorWorkSession, TractorWorkSessionItem
├── serializers.py
├── views.py
├── urls.py
├── admin.py
└── migrations/
├── 0001_initial.py # levee_work から移行)
└── 0002_rename_and_add_work_type.py
```
変更ファイル:
- `backend/apps/workrecords/models.py` — FK名・enum更新
- `backend/apps/workrecords/services.py` — sync関数改名
- `backend/keinasystem/settings.py` — INSTALLED_APPS更新
- `backend/keinasystem/urls.py` — URLパス更新
### フロントエンド
```
frontend/src/app/tractor-work/
└── page.tsx
```
変更ファイル:
- `frontend/src/types/index.ts` — 型定義更新
- `frontend/src/components/Navbar.tsx` — リンク更新
- `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,392 @@
# マスタードキュメントTODO管理機能
> **作成**: 2026-04-10
> **最終更新**: 2026-04-10tractor_work 対応追記)
> **対象機能**: TODO管理作業指示・優先順位管理・実績連携導線
> **実装状況**: 設計完了・実装前
> **対象 Issue**: `akira/keinasystem#17`
---
## 概要
繁忙期に「どれから手を付けるか」を管理するための TODO 機能。
計画(施肥・田植え・運搬など)と実績の間に位置する「作業指示」レイヤー。
### 機能スコープIN / OUT
| INMVP対象 | OUT対象外 |
|---|---|
| TODO の作成・編集・削除 | 期日通知・リマインダー |
| ステータス管理todo / doing / done / canceled | 複数ユーザー割り当て |
| 優先順位管理(ドラッグ&ドロップ / 矢印移動) | コメント・添付ファイル |
| 圃場単位の対象紐づけ | 工数見積・実績時間記録 |
| 計画との紐づけ(施肥・田植え・運搬) | 完全な汎用ワークフローエンジン化 |
| 計画画面からの TODO 生成 | 実績アプリ未実装領域の詳細実績入力 UI |
| 完了時の実績入力画面への導線生成 | |
| 完了済み・キャンセル済みの表示切り替え | |
### TODO の位置づけ
```
計画(年間設計情報)
↓ 生成
TODO実際に動く作業単位
↓ 完了
実績(完了した事実)
```
---
## データモデル
### Todo本体
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| year | integer | ✓ | 年度 |
| title | varchar(200) | ✓ | タイトル |
| description | text | | 説明 |
| status | enum | ✓ | `todo / doing / done / canceled` |
| priority | integer | ✓ | 小さいほど上位1000刻み |
| due_date | date | | 期日 |
| work_type | enum | ✓ | 作業種別(下記参照) |
| work_subtype | varchar(30) | | トラクター作業の細別。`work_type=tractor_work` のときのみ必須(下記参照) |
| should_link_record | boolean | ✓ | 完了時に実績連携導線を有効にするか |
| completed_at | datetime | | 完了日時(差し戻し後も保持) |
| canceled_at | datetime | | キャンセル日時 |
| created_at | datetime | ✓ | |
| updated_at | datetime | ✓ | |
#### ステータス遷移
- `todo``doing``done`complete/ 専用エンドポイント経由)
- `done``todo / doing`差し戻し許可。completed_at は履歴として保持)
- `canceled` への遷移は任意のタイミングで可
#### 作業種別work_type
MVP で採用する種別:
| 値 | 意味 |
|---|---|
| `general` | 一般(どれにも当てはまらない作業) |
| `fertilization` | 施肥 |
| `rice_transplant` | 田植え |
| `delivery` | 運搬 |
| `tractor_work` | トラクター作業(畔塗・荒代掻き・植代掻き・耕耘) |
将来追加(農薬散布管理アプリ実装時):
| 値 | 意味 |
|---|---|
| `pesticide` | 防除 |
#### work_subtypeトラクター作業の細別
`work_type = tractor_work` のときのみ使用する。それ以外は `null`
| 値 | 意味 |
|---|---|
| `levee_work` | 畔塗 |
| `rough_harrowing` | 荒代掻き |
| `transplant_harrowing` | 植代掻き |
| `cultivation` | 耕耘 |
バリデーションルール:
- `work_type = tractor_work``work_subtype` 必須
- `work_type ≠ tractor_work``work_subtype` は null のみ許可
#### 並び順
- 基本は FILO新規作成時は最上位へ
- 初回作成時:最上位 TODO の `priority - 1000` を割り当て
- 一覧表示:`priority` 昇順
- 並び替え後:表示対象全体を 1000, 2000, 3000... と再採番して保存
- 完了・キャンセル済みも `priority` を保持
### TodoTargetField対象圃場
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| 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']`
- 圃場グループは独立モデル化しない(`Field.group_name` を参照するのみ)
- 計画に含まれる圃場の一部だけを対象にすることを許可する
### 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']`
- 圃場が 0 件でも Crop / Variety だけの紐づけは許可(圃場未確定の準備作業など)
### TodoPlanLink計画との紐づけ
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | CASCADE |
| plan_type | enum | ✓ | `fertilization / rice_transplant / delivery` |
| fertilization_plan | FK(fertilizer.FertilizationPlan) | | |
| rice_transplant_plan | FK(plans.RiceTransplantPlan) | | |
| delivery_plan | FK(分配計画モデル) | | |
| created_at | datetime | ✓ | |
- 1 行に 1 種別のリンクのみ保持
- `plan_type` に応じて対応 FK だけを埋める
- `levee_work` は MVP では「計画リンクなしで持てる work_type」として扱う
- 作付け計画Planは TODO 生成元としては対象外
### TodoCompletionLink完了時の実績連携索引
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | |
| record_type | enum | ✓ | 実績種別(`fertilization` / `tractor_work` など) |
| work_record | FK(workrecords.WorkRecord) | | 共通索引 |
| spreading_session | FK(fertilizer.SpreadingSession) | | 施肥実績 |
| tractor_work_session | FK(tractor_work.TractorWorkSession) | | トラクター作業実績 |
| created_at | datetime | ✓ | |
- `todo` は OneToOne ではなく FK1 TODO から複数実績への分割を許容)
- 実績アプリが未実装の種別は空でよい
---
## API 仕様
### エンドポイント一覧
| メソッド | パス | 説明 |
|---|---|---|
| GET | `/api/todos/` | 一覧取得 |
| POST | `/api/todos/` | 作成 |
| GET | `/api/todos/{id}/` | 詳細取得 |
| PATCH | `/api/todos/{id}/` | 更新status=done への変更は不可) |
| DELETE | `/api/todos/{id}/` | 削除 |
| PATCH | `/api/todos/reorder/` | 並び替え |
| POST | `/api/todos/from-plan/` | 計画から TODO 生成 |
| POST | `/api/todos/{id}/complete/` | 完了処理(実績連携導線を返す) |
### 重要な設計ルール
**完了処理の一本化**
- `PATCH` での `status=done` 変更はバックエンドが拒否する
- 完了は必ず `POST /api/todos/{id}/complete/` を通る
- 理由実績連携導線の生成を確実にするため。AI 実装者がセッションをまたいで実装する際のブレを防ぐ
**差し戻し時の挙動**
- `done → todo/doing` は許可
- `TodoCompletionLink` が存在する場合は、差し戻しを許可しつつ API レスポンスに警告と各実績レコードへの直リンクを返す
- 実績レコード自体の削除は行わない(各実績アプリ側の責務)
**削除時の挙動**
- `TodoCompletionLink` が存在する TODO を削除しようとした場合、警告と各実績レコードへの直リンクを返す
- ユーザーが確認した上で削除を実行した場合は物理削除を許可する
- `TodoCompletionLink` は TODO と一緒に削除CASCADE
- 実績レコード自体は削除しない
### 一覧 GET `/api/todos/`
主なクエリパラメータ:
| パラメータ | デフォルト | 説明 |
|---|---|---|
| `status` | `todo,doing` | カンマ区切りで複数指定可 |
| `include_closed` | `false` | true で完了・キャンセルも含む |
| `work_type` | - | 作業種別フィルター |
| `due` | - | `overdue / today / upcoming` |
| `year` | - | 年度フィルター |
### 作成 POST `/api/todos/`
```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 で対応 FK へ変換する。
### 並び替え PATCH `/api/todos/reorder/`
```json
{
"items": [
{"id": 31, "priority": 1000},
{"id": 27, "priority": 2000},
{"id": 42, "priority": 3000}
]
}
```
### 計画から TODO 生成 POST `/api/todos/from-plan/`
```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` から自動補完する
### 完了処理 POST `/api/todos/{id}/complete/`
- `status=done` にする
- `should_link_record=true` かつ対応実績アプリがある場合、関連画面へ遷移するための情報を返す
- MVP では実績レコードの自動生成は行わず、導線情報の返却にとどめる
---
## バリデーション
- `done` 遷移時に `completed_at` を自動設定
- `canceled` 遷移時に `canceled_at` を自動設定
- `PATCH``status=done` を指定した場合は 400 エラーを返す
- `field_ids` が計画外圃場を含む場合は `plan_links` が 1 件以上あるときのみエラーにする
- `should_link_record=true` でも対応実績アプリが無い場合は保存を許可する
- `TodoTargetField.field``PROTECT`(過去 TODO の対象圃場履歴を保全するため)
- `work_type = tractor_work` の場合は `work_subtype` が必須(未指定時は 400 エラー)
- `work_type ≠ tractor_work` の場合は `work_subtype` に値を指定した場合は 400 エラー
---
## UI 仕様
### 一覧画面 `/todos`
- デフォルト表示todo / doing を priority 昇順で表示
- 完了済み・キャンセル済みはフィルターで表示切り替え
- 期限超過は赤系で強調、当日期限も強調表示
- ドラッグ&ドロップで並び替え(難しければ矢印ボタンで代替)
表示カラム:タイトル / ステータス / 期日 / 作業種別 / 対象圃場数 / 紐づき計画
### 詳細画面 `/todos/{id}`
表示・編集:タイトル / 説明 / ステータス / 期日 / 作業種別 / 実績連携フラグ / 対象圃場 / 分類作物・品種 / 計画リンク
下部表示:実績連携先 / 完了日時 / 更新日時
### 作成導線
1. TODO 一覧から新規作成
2. 計画詳細または一覧から TODO 生成(施肥・田植え・運搬の各計画画面)
---
## 実装ファイル構成
### Backend
```
apps/todos/
├── models.py # Todo, TodoTargetField, TodoCrop, TodoVariety, TodoPlanLink, TodoCompletionLink
├── admin.py
├── serializers.py
├── views.py
├── urls.py
└── migrations/
```
- `keinasystem/settings.py``apps.todos` を追加
- `keinasystem/urls.py``/api/todos/` を追加
### Frontend
```
frontend/src/app/todos/
├── page.tsx # 一覧
├── [id]/page.tsx # 詳細
└── new/page.tsx # 作成
```
### 実装順
1. モデル・admin・migration
2. TODO CRUD API一覧・詳細・作成・更新・削除
3. TODO 一覧・詳細 UI
4. 並び替え API と UI
5. 計画から TODO 生成from-plan API + 各計画画面への導線)
6. 完了処理 API と実績連携導線 UI
---
## 実績連携の考え方
### 施肥
`施肥計画 → 施肥TODO → 施肥実績`SpreadingSessionの流れ。
完了時は `SpreadingSession` 作成画面への導線を返す。対象圃場は `TodoTargetField` を初期値として渡す。
### トラクター作業
`tractor_work` 種別の TODO 完了時は `TractorWorkSession` 作成画面への導線を返す。
`work_subtype` をクエリパラメータで渡し、作業種別セレクタの初期値として使う。
対象圃場は `TodoTargetField` を初期値として渡す。
### 田植え
田植え実績アプリは今後実装予定。MVP では:
- `rice_transplant` 種別の TODO を持てる
- 完了時は「完了済みだが実績アプリ未接続」の状態も許容する
- 将来の田植え実績導入時に `TodoCompletionLink` を拡張する
### 実績アプリが無い作業
`general` など、実績アプリに紐づかない TODO は `status=done` のみで完了とする。
---
## 未決定(実装時に判断)
以下は MVP 着手後に実装者が判断しながら決めてよい事項。
| 事項 | 方針 |
|---|---|
| 複数計画リンクの初回 UI | 内部構造は複数可。UI はまず 1 件中心で実装し、必要なら拡張する |
| 並び替え対象の範囲 | フィルター中todo/doing のみ)を再採番対象とするのが自然 |
| 施肥完了時に渡す初期値の粒度 | SpreadingSession 作成画面の実装時に具体的な受け渡し仕様を決める |

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 { Field, Crop, Plan } from '@/types';
import Navbar from '@/components/Navbar';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search, History } from 'lucide-react';
interface SummaryItem {
cropId: number;
@@ -48,6 +48,13 @@ export default function AllocationPage() {
const [searchText, setSearchText] = useState('');
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 4000);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
localStorage.setItem('allocationYear', String(year));
@@ -233,17 +240,46 @@ export default function AllocationPage() {
const existingPlan = getPlanForField(fieldId);
if (!existingPlan || !existingPlan.crop) return;
if ((existingPlan.variety || null) === variety) return;
const nextVarietyName =
variety === null
? '(品種未選択)'
: getVarietiesForCrop(existingPlan.crop).find((item) => item.id === variety)?.name || '不明';
const currentVarietyName = existingPlan.variety_name || '(品種未選択)';
const shouldProceed = confirm(
[
`品種を「${currentVarietyName}」から「${nextVarietyName}」へ変更します。`,
'施肥計画・田植え計画の関連エントリが自動で移動する場合があります。',
'実行しますか?',
].join('\n')
);
if (!shouldProceed) return;
setSaving(fieldId);
try {
await api.patch(`/plans/${existingPlan.id}/`, {
const res = await api.patch(`/plans/${existingPlan.id}/`, {
variety,
notes: existingPlan.notes,
});
const updatedPlan: Plan = res.data;
const movedCount = updatedPlan.latest_variety_change?.fertilizer_moved_entry_count ?? 0;
setToast({
type: 'success',
message:
movedCount > 0
? `品種を変更し、施肥計画 ${movedCount} 件を移動しました。`
: '品種を変更しました。関連する施肥計画の移動はありませんでした。',
});
await fetchData(true);
} catch (error) {
console.error('Failed to save variety:', error);
setToast({
type: 'error',
message: '品種変更に失敗しました。',
});
} finally {
setSaving(null);
}
@@ -367,6 +403,20 @@ export default function AllocationPage() {
}
};
const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => {
try {
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
if (!variety) return;
await api.patch(`/plans/varieties/${varietyId}/`, {
default_seedling_boxes_per_tan: defaultBoxes,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update variety default boxes:', error);
alert('品種デフォルトの更新に失敗しました');
}
};
const toggleFieldSelection = (fieldId: number) => {
setSelectedFields((prev) => {
const next = new Set(prev);
@@ -549,6 +599,17 @@ export default function AllocationPage() {
{/* メインコンテンツ */}
<div className="flex-1 min-w-0 p-4 lg:p-0">
<div className="max-w-7xl mx-auto">
{toast && (
<div
className={`mb-4 rounded-md border px-4 py-3 text-sm ${
toast.type === 'success'
? 'border-green-300 bg-green-50 text-green-800'
: 'border-red-300 bg-red-50 text-red-800'
}`}
>
{toast.message}
</div>
)}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-gray-900">
<span className="text-green-700">{year}</span>
@@ -873,27 +934,43 @@ export default function AllocationPage() {
</button>
</div>
) : (
<select
value={selectedVarietyId || ''}
onChange={(e) => {
if (e.target.value === '__add__') {
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
setNewVarietyName('');
} else {
handleVarietyChange(field.id, e.target.value);
}
}}
disabled={saving === field.id || !selectedCropId}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
>
<option value=""></option>
{getVarietiesForCrop(selectedCropId).map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
{selectedCropId > 0 && <option value="__add__">+ ...</option>}
</select>
<div className="flex items-center gap-2">
<select
value={selectedVarietyId || ''}
onChange={(e) => {
if (e.target.value === '__add__') {
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
setNewVarietyName('');
} else {
handleVarietyChange(field.id, e.target.value);
}
}}
disabled={saving === field.id || !selectedCropId}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
>
<option value=""></option>
{getVarietiesForCrop(selectedCropId).map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
{selectedCropId > 0 && <option value="__add__">+ ...</option>}
</select>
{plan?.latest_variety_change && (
<div
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
title={[
`変更日時: ${new Date(plan.latest_variety_change.changed_at).toLocaleString('ja-JP')}`,
`変更前: ${plan.latest_variety_change.old_variety_name || '未設定'}`,
`変更後: ${plan.latest_variety_change.new_variety_name || '未設定'}`,
`施肥移動件数: ${plan.latest_variety_change.fertilizer_moved_entry_count}`,
].join('\n')}
>
<History className="h-3 w-3" />
</div>
)}
</div>
)}
</td>
<td className="px-6 py-4">
@@ -1032,15 +1109,22 @@ export default function AllocationPage() {
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
<ul className="space-y-2">
{getVarietiesForCrop(managerCropId).map((v) => (
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
<span className="text-sm text-gray-900">{v.name}</span>
<button
onClick={() => handleDeleteVariety(v.id, v.name)}
className="text-red-400 hover:text-red-600 p-1"
title="削除"
>
<Trash2 className="h-4 w-4" />
</button>
<li key={v.id} className="rounded border border-gray-200 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{v.name}</span>
<button
onClick={() => handleDeleteVariety(v.id, v.name)}
className="text-red-400 hover:text-red-600 p-1"
title="削除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<VarietyDefaultBoxesForm
varietyId={v.id}
initialValue={v.default_seedling_boxes_per_tan}
onSave={handleUpdateVarietyDefaultBoxes}
/>
</li>
))}
</ul>
@@ -1105,3 +1189,47 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
</div>
);
}
function VarietyDefaultBoxesForm({
varietyId,
initialValue,
onSave,
}: {
varietyId: number;
initialValue: string;
onSave: (varietyId: number, defaultBoxes: string) => Promise<void>;
}) {
const [value, setValue] = useState(initialValue);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const handleSave = async () => {
setSaving(true);
await onSave(varietyId, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600"></label>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
<button
onClick={handleSave}
disabled={saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import DistributionEditPage from '../../_components/DistributionEditPage';
import DeliveryEditPage from '../../_components/DeliveryEditPage';
export default function DistributionEditRoute({ params }: { params: { id: string } }) {
return <DistributionEditPage planId={Number(params.id)} />;
export default function DeliveryEditRoute({ params }: { params: { id: string } }) {
return <DeliveryEditPage planId={Number(params.id)} />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,651 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { DistributionPlan, FertilizationPlan } from '@/types';
import { api } from '@/lib/api';
const CURRENT_YEAR = new Date().getFullYear();
// ローカル管理用のグループ型ID未採番の新規グループも持てる
interface LocalGroup {
tempId: string;
name: string;
order: number;
fieldIds: number[];
isRenamingName?: string; // 名前変更中の一時値
}
interface FieldInfo {
id: number;
name: string;
area_tan: string;
}
interface Props {
planId?: number; // 編集時のみ
}
export default function DistributionEditPage({ planId }: Props) {
const router = useRouter();
const isEdit = planId !== undefined;
// 基本情報
const [name, setName] = useState('');
const [fertilizationPlanId, setFertilizationPlanId] = useState<number | ''>('');
const [year] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
// 施肥計画一覧(セレクタ用)
const [fertilizationPlans, setFertilizationPlans] = useState<FertilizationPlan[]>([]);
// 選択中の施肥計画の詳細肥料・entries
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null);
// ローカルグループ状態
const [groups, setGroups] = useState<LocalGroup[]>([]);
const [newGroupName, setNewGroupName] = useState('');
// UI状態
const [saveError, setSaveError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
// ── 初期データ読み込み ──────────────────────────────────
useEffect(() => {
const init = async () => {
try {
// 施肥計画一覧を全年度取得(分配計画のベースになる)
const res = await api.get('/fertilizer/plans/');
setFertilizationPlans(res.data);
} catch (e) {
console.error(e);
}
if (isEdit && planId) {
try {
// 既存の分配計画を読み込む
const detailRes = await api.get(`/fertilizer/distribution/${planId}/`);
const detail: DistributionPlan = detailRes.data;
setName(detail.name);
setFertilizationPlanId(detail.fertilization_plan.id);
setFertPlanDetail(detail.fertilization_plan);
// グループを LocalGroup 形式に変換
setGroups(
detail.groups.map((g, i) => ({
tempId: String(g.id),
name: g.name,
order: g.order ?? i,
fieldIds: g.fields.map(f => f.id),
}))
);
} catch (e) {
console.error(e);
}
}
setLoading(false);
};
init();
}, [planId]);
// 施肥計画が変わったら詳細を取得
useEffect(() => {
if (!fertilizationPlanId) {
setFertPlanDetail(null);
if (!isEdit) setGroups([]);
return;
}
if (isEdit && fertPlanDetail?.id === fertilizationPlanId) return;
const fetchDetail = async () => {
try {
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
const data: FertilizationPlan = res.data;
// FertilizationPlanForDistributionSerializer と同じ構造に合わせる
const ferts = Array.from(
new Map(
data.entries.map(e => [e.fertilizer, { id: e.fertilizer, name: e.fertilizer_name || '' }])
).values()
).sort((a, b) => a.name.localeCompare(b.name));
setFertPlanDetail({
id: data.id,
name: data.name,
year: data.year,
variety_name: data.variety_name,
crop_name: data.crop_name,
fertilizers: ferts,
entries: data.entries.map(e => ({
field: e.field,
fertilizer: e.fertilizer,
bags: String(e.bags),
})),
});
if (!isEdit) setGroups([]);
} catch (e) {
console.error(e);
}
};
fetchDetail();
}, [fertilizationPlanId]);
// ── 計算ヘルパー ──────────────────────────────────────
// 全圃場一覧施肥計画のentries に含まれる圃場)
const allPlanFields: FieldInfo[] = (() => {
if (!fertPlanDetail) return [];
const seen = new Map<number, FieldInfo>();
for (const e of fertPlanDetail.entries) {
if (!seen.has(e.field)) {
// field名は後述の fertilizationPlans から取る
seen.set(e.field, { id: e.field, name: String(e.field), area_tan: '0' });
}
}
return Array.from(seen.values());
})();
// fertilizationPlans から field情報を取得FertilizationPlanSerializer の entries に field_name が含まれる)
const fieldInfoMap = (() => {
const map = new Map<number, FieldInfo>();
if (!fertPlanDetail) return map;
const plan = fertilizationPlans.find(p => p.id === fertPlanDetail.id);
if (plan) {
for (const e of plan.entries) {
if (e.field && !map.has(e.field)) {
map.set(e.field, {
id: e.field,
name: e.field_name || String(e.field),
area_tan: e.field_area_tan || '0',
});
}
}
}
return map;
})();
const getFieldInfo = (fieldId: number): FieldInfo =>
fieldInfoMap.get(fieldId) ?? { id: fieldId, name: `圃場#${fieldId}`, area_tan: '0' };
// 割り当て済みフィールドIDセット
const assignedFieldIds = new Set(groups.flatMap(g => g.fieldIds));
// 未割り当て圃場
const unassignedFields = fertPlanDetail
? Array.from(
new Map(
fertPlanDetail.entries
.map(e => e.field)
.filter(id => !assignedFieldIds.has(id))
.map(id => [id, getFieldInfo(id)])
).values()
)
: [];
// bags取得
const getBags = (fieldId: number, fertilizerId: number): number => {
if (!fertPlanDetail) return 0;
const entry = fertPlanDetail.entries.find(
e => e.field === fieldId && e.fertilizer === fertilizerId
);
return entry ? parseFloat(entry.bags) : 0;
};
// グループごとの集計
const groupSummaries = groups.map(g => {
const fertTotals = (fertPlanDetail?.fertilizers || []).map(fert => ({
fertilizerId: fert.id,
fertilizerName: fert.name,
total: g.fieldIds.reduce((sum, fId) => sum + getBags(fId, fert.id), 0),
}));
const rowTotal = fertTotals.reduce((s, f) => s + f.total, 0);
return { ...g, fertTotals, rowTotal };
});
// 未割り当てグループの集計
const unassignedSummary = {
fertTotals: (fertPlanDetail?.fertilizers || []).map(fert => ({
fertilizerId: fert.id,
fertilizerName: fert.name,
total: unassignedFields.reduce((sum, f) => sum + getBags(f.id, fert.id), 0),
})),
rowTotal: 0 as number,
};
unassignedSummary.rowTotal = unassignedSummary.fertTotals.reduce((s, f) => s + f.total, 0);
// 肥料合計行
const fertColumnTotals = (fertPlanDetail?.fertilizers || []).map(fert => {
const groupTotal = groupSummaries.reduce(
(sum, g) => sum + (g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0),
0
);
const unassignedTotal = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return { id: fert.id, total: groupTotal + unassignedTotal };
});
const grandTotal = fertColumnTotals.reduce((s, f) => s + f.total, 0);
// ── グループ操作 ──────────────────────────────────────
const addGroup = () => {
const n = newGroupName.trim();
if (!n) return;
if (groups.some(g => g.name === n)) {
setSaveError(`グループ名「${n}」はすでに存在します`);
return;
}
setSaveError(null);
setGroups(prev => [
...prev,
{ tempId: crypto.randomUUID(), name: n, order: prev.length, fieldIds: [] },
]);
setNewGroupName('');
};
const removeGroup = (tempId: string) => {
setGroups(prev => prev.filter(g => g.tempId !== tempId));
};
const moveGroup = (tempId: string, dir: -1 | 1) => {
setGroups(prev => {
const idx = prev.findIndex(g => g.tempId === tempId);
if (idx < 0 || idx + dir < 0 || idx + dir >= prev.length) return prev;
const next = [...prev];
[next[idx], next[idx + dir]] = [next[idx + dir], next[idx]];
return next.map((g, i) => ({ ...g, order: i }));
});
};
const startRename = (tempId: string) => {
setGroups(prev =>
prev.map(g => (g.tempId === tempId ? { ...g, isRenamingName: g.name } : g))
);
};
const commitRename = (tempId: string) => {
setGroups(prev =>
prev.map(g => {
if (g.tempId !== tempId) return g;
const newName = (g.isRenamingName || '').trim();
if (!newName || newName === g.name) return { ...g, isRenamingName: undefined };
if (prev.some(other => other.tempId !== tempId && other.name === newName)) {
setSaveError(`グループ名「${newName}」はすでに存在します`);
return { ...g, isRenamingName: undefined };
}
return { ...g, name: newName, isRenamingName: undefined };
})
);
};
const assignFieldToGroup = (fieldId: number, groupTempId: string) => {
setGroups(prev =>
prev.map(g => {
if (g.tempId === groupTempId) {
return { ...g, fieldIds: [...g.fieldIds, fieldId] };
}
return { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) };
})
);
};
const removeFieldFromGroup = (fieldId: number, groupTempId: string) => {
setGroups(prev =>
prev.map(g =>
g.tempId === groupTempId ? { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) } : g
)
);
};
// ── 保存 ──────────────────────────────────────────────
const handleSave = async () => {
setSaveError(null);
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
if (!fertilizationPlanId) { setSaveError('施肥計画を選択してください'); return; }
setSaving(true);
const payload = {
name: name.trim(),
fertilization_plan_id: fertilizationPlanId,
groups: groups.map((g, i) => ({
name: g.name,
order: i,
field_ids: g.fieldIds,
})),
};
try {
if (isEdit) {
await api.put(`/fertilizer/distribution/${planId}/`, payload);
} else {
await api.post('/fertilizer/distribution/', payload);
}
setSaving(false);
router.push('/distribution');
} catch (e: unknown) {
setSaving(false);
const axiosErr = e as { response?: { data?: unknown } };
const errData = axiosErr?.response?.data;
setSaveError(errData ? JSON.stringify(errData) : '保存に失敗しました');
}
};
// ── レンダリング ──────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-5xl mx-auto px-4 py-8 text-gray-500 text-sm">...</main>
</div>
);
}
const fertilizers = fertPlanDetail?.fertilizers || [];
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-5xl mx-auto px-4 py-8">
{/* ヘッダー */}
<div className="flex items-center gap-2 mb-6">
<button onClick={() => router.push('/distribution')} className="text-sm text-gray-500 hover:text-gray-700">
</button>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-6">
{isEdit ? '分配計画を編集' : '分配計画を新規作成'}
</h1>
{saveError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
<span className="flex-1">{saveError}</span>
<button onClick={() => setSaveError(null)}><X className="h-4 w-4" /></button>
</div>
)}
{/* 基本情報 */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2 flex-1 min-w-48">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"></label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="例: 2025年コシヒカリ 分配計画"
className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div className="flex items-center gap-2 flex-1 min-w-64">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"></label>
<select
value={fertilizationPlanId}
onChange={e => setFertilizationPlanId(e.target.value ? Number(e.target.value) : '')}
className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="">-- --</option>
{fertilizationPlans.map(p => (
<option key={p.id} value={p.id}>
{p.year} {p.name}{p.crop_name}/{p.variety_name}
</option>
))}
</select>
</div>
</div>
</div>
{!fertPlanDetail ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-400 text-sm">
</div>
) : (
<>
{/* グループ割り当て */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4"></h2>
{/* 新規グループ追加 */}
<div className="flex items-center gap-2 mb-4">
<input
type="text"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addGroup()}
placeholder="新規グループ名"
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-48"
/>
<button
onClick={addGroup}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* グループ一覧 */}
<div className="space-y-3">
{groups.map((group, idx) => (
<div key={group.tempId} className="border border-gray-200 rounded-md overflow-hidden">
{/* グループヘッダー */}
<div className="flex items-center gap-2 bg-green-50 px-3 py-2">
{group.isRenamingName !== undefined ? (
<>
<input
type="text"
value={group.isRenamingName}
onChange={e =>
setGroups(prev =>
prev.map(g =>
g.tempId === group.tempId ? { ...g, isRenamingName: e.target.value } : g
)
)
}
onKeyDown={e => e.key === 'Enter' && commitRename(group.tempId)}
className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
autoFocus
/>
<button
onClick={() => commitRename(group.tempId)}
className="p-1 text-green-700 hover:text-green-900"
>
<Check className="h-4 w-4" />
</button>
</>
) : (
<>
<span className="font-medium text-sm text-gray-800 flex-1">{group.name}</span>
<button
onClick={() => moveGroup(group.tempId, -1)}
disabled={idx === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={() => moveGroup(group.tempId, 1)}
disabled={idx === groups.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
onClick={() => startRename(group.tempId)}
className="p-1 text-gray-400 hover:text-gray-600"
title="名前変更"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => removeGroup(group.tempId)}
className="p-1 text-gray-400 hover:text-red-600"
title="グループを削除"
>
<X className="h-4 w-4" />
</button>
</>
)}
</div>
{/* グループ内圃場 */}
<div className="px-3 py-2 space-y-1">
{group.fieldIds.length === 0 ? (
<p className="text-xs text-gray-400 italic"></p>
) : (
group.fieldIds.map(fId => {
const fi = getFieldInfo(fId);
const bags = fertilizers.map(fert => getBags(fId, fert.id));
return (
<div key={fId} className="flex items-center gap-2 text-sm">
<button
onClick={() => removeFieldFromGroup(fId, group.tempId)}
className="text-gray-400 hover:text-red-500 flex-shrink-0"
title="グループから外す"
>
<X className="h-3.5 w-3.5" />
</button>
<span className="text-gray-800 font-medium w-32 truncate">{fi.name}</span>
<span className="text-gray-400 text-xs w-16 text-right">{fi.area_tan}</span>
<span className="text-gray-400 text-xs">
{fertilizers.map((fert, i) => (
<span key={fert.id}>
{i > 0 && ' / '}
{fert.name}: {bags[i].toFixed(2)}
</span>
))}
</span>
</div>
);
})
)}
</div>
</div>
))}
</div>
{/* 未割り当て圃場 */}
{unassignedFields.length > 0 && (
<div className="mt-4 border-t border-gray-200 pt-4">
<p className="text-xs font-medium text-gray-500 uppercase mb-2"></p>
<div className="rounded border border-gray-200 overflow-hidden">
{unassignedFields.map((fi, idx) => (
<div key={fi.id} className={`flex items-center gap-2 text-sm px-3 py-1.5 ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}>
<span className="text-gray-800 font-medium flex-1 min-w-0 truncate" title={fi.name}>{fi.name}</span>
<span className="text-gray-400 text-xs w-16 shrink-0 text-right">{fi.area_tan}</span>
<select
defaultValue=""
onChange={e => {
if (e.target.value) {
assignFieldToGroup(fi.id, e.target.value);
e.target.value = '';
}
}}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500"
>
<option value="">...</option>
{groups.map(g => (
<option key={g.tempId} value={g.tempId}>{g.name}</option>
))}
</select>
</div>
))}
</div>
</div>
)}
</div>
{/* 集計プレビュー */}
{(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-3"></h2>
<div className="overflow-x-auto">
<table className="min-w-full text-sm border-collapse">
<thead>
<tr>
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-medium text-gray-600 text-xs"></th>
{fertilizers.map(fert => (
<th key={fert.id} className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs">
{fert.name}
</th>
))}
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs"></th>
</tr>
</thead>
<tbody>
{groupSummaries.map(g => (
<tr key={g.tempId} className="hover:bg-green-50">
<td className="border border-gray-200 px-3 py-2 font-medium text-gray-800">{g.name}</td>
{fertilizers.map(fert => {
const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return (
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-700">
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-800">
{g.rowTotal.toFixed(2)}
</td>
</tr>
))}
{unassignedSummary.rowTotal > 0 && (
<tr className="bg-yellow-50">
<td className="border border-gray-200 px-3 py-2 text-gray-500 italic"></td>
{fertilizers.map(fert => {
const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return (
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{unassignedSummary.rowTotal.toFixed(2)}
</td>
</tr>
)}
</tbody>
<tfoot>
<tr className="font-bold bg-gray-50">
<td className="border border-gray-200 px-3 py-2 text-gray-800"></td>
{fertColumnTotals.map(f => (
<td key={f.id} className="border border-gray-200 px-3 py-2 text-right text-gray-800">
{f.total.toFixed(2)}
</td>
))}
<td className="border border-gray-200 px-3 py-2 text-right text-gray-800">
{grandTotal.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
</>
)}
{/* フッターボタン */}
<div className="flex justify-end gap-3">
<button
onClick={() => router.push('/distribution')}
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 text-gray-700"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</main>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import DistributionEditPage from '../_components/DistributionEditPage';
import DeliveryEditPage from '../_components/DeliveryEditPage';
export default function DistributionNewPage() {
return <DistributionEditPage />;
export default function DeliveryNewPage() {
return <DeliveryEditPage />;
}

View File

@@ -2,15 +2,15 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
import { Truck, Plus, FileDown, Pencil, Trash2, X, Sprout } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { DistributionPlanListItem } from '@/types';
import { DeliveryPlanListItem } from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'distributionYear';
export default function DistributionListPage() {
export default function DeliveryListPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
@@ -18,7 +18,7 @@ export default function DistributionListPage() {
}
return CURRENT_YEAR;
});
const [plans, setPlans] = useState<DistributionPlanListItem[]>([]);
const [plans, setPlans] = useState<DeliveryPlanListItem[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -32,7 +32,7 @@ export default function DistributionListPage() {
const fetchPlans = async () => {
setLoading(true);
try {
const res = await api.get(`/fertilizer/distribution/?year=${year}`);
const res = await api.get(`/fertilizer/delivery/?year=${year}`);
setPlans(res.data);
} catch (e) {
console.error(e);
@@ -44,7 +44,7 @@ export default function DistributionListPage() {
const handleDelete = async (id: number) => {
setDeleteError(null);
try {
await api.delete(`/fertilizer/distribution/${id}/`);
await api.delete(`/fertilizer/delivery/${id}/`);
setPlans(prev => prev.filter(p => p.id !== id));
} catch (e) {
console.error(e);
@@ -54,11 +54,11 @@ export default function DistributionListPage() {
const handlePdf = async (id: number, planName: string) => {
try {
const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' });
const res = await api.get(`/fertilizer/delivery/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `distribution_${planName}.pdf`;
a.download = `delivery_${planName}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
@@ -72,16 +72,25 @@ export default function DistributionListPage() {
<main className="max-w-6xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<FlaskConical className="h-7 w-7 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
<Truck className="h-7 w-7 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/fertilizer/spreading')}
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors"
>
<Sprout className="h-4 w-4" />
</button>
<button
onClick={() => router.push('/distribution/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4" />
</button>
</div>
<button
onClick={() => router.push('/distribution/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 年度セレクタ */}
@@ -109,9 +118,9 @@ export default function DistributionListPage() {
<p className="text-gray-500 text-sm">...</p>
) : plans.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<FlaskConical className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{year}</p>
<p className="text-sm mb-6"></p>
<Truck className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{year}</p>
<p className="text-sm mb-6"></p>
<button
onClick={() => router.push('/distribution/new')}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
@@ -125,10 +134,8 @@ export default function DistributionListPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">/</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
@@ -136,10 +143,8 @@ export default function DistributionListPage() {
{plans.map(plan => (
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{plan.fertilization_plan_name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.group_count}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.field_count}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.trip_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button

View File

@@ -1,308 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Loader2, X } from 'lucide-react';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
interface ConfirmSpreadingModalProps {
plan: FertilizationPlan | null;
isOpen: boolean;
onClose: () => void;
onConfirmed: () => Promise<void> | void;
}
type ActualMap = Record<string, string>;
const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`;
type EntryMatrix = Record<number, Record<number, string>>;
export default function ConfirmSpreadingModal({
plan,
isOpen,
onClose,
onConfirmed,
}: ConfirmSpreadingModalProps) {
const [actuals, setActuals] = useState<ActualMap>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen || !plan) {
return;
}
const nextActuals: ActualMap = {};
plan.entries.forEach((entry) => {
nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags);
});
setActuals(nextActuals);
setError(null);
}, [isOpen, plan]);
const layout = useMemo(() => {
if (!plan) {
return {
fields: [] as { id: number; name: string; areaTan: string | undefined }[],
fertilizers: [] as { id: number; name: string }[],
planned: {} as EntryMatrix,
};
}
const fieldMap = new Map<number, { id: number; name: string; areaTan: string | undefined }>();
const fertilizerMap = new Map<number, { id: number; name: string }>();
const planned: EntryMatrix = {};
plan.entries.forEach((entry) => {
if (!fieldMap.has(entry.field)) {
fieldMap.set(entry.field, {
id: entry.field,
name: entry.field_name ?? `圃場ID:${entry.field}`,
areaTan: entry.field_area_tan,
});
}
if (!fertilizerMap.has(entry.fertilizer)) {
fertilizerMap.set(entry.fertilizer, {
id: entry.fertilizer,
name: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
});
}
if (!planned[entry.field]) {
planned[entry.field] = {};
}
planned[entry.field][entry.fertilizer] = String(entry.bags);
});
return {
fields: Array.from(fieldMap.values()),
fertilizers: Array.from(fertilizerMap.values()),
planned,
};
}, [plan]);
if (!isOpen || !plan) {
return null;
}
const handleConfirm = async () => {
setSaving(true);
setError(null);
try {
await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, {
entries: plan.entries.map((entry) => ({
field_id: entry.field,
fertilizer_id: entry.fertilizer,
actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0),
})),
});
await onConfirmed();
onClose();
} catch (e: unknown) {
console.error(e);
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: '散布確定に失敗しました。';
setError(detail);
} finally {
setSaving(false);
}
};
const numericValue = (value: string | undefined) => {
const parsed = parseFloat(value ?? '0');
return isNaN(parsed) ? 0 : parsed;
};
const actualTotalByField = (fieldId: number) =>
layout.fertilizers.reduce(
(sum, fertilizer) => sum + numericValue(actuals[entryKey(fieldId, fertilizer.id)]),
0
);
const actualTotalByFertilizer = (fertilizerId: number) =>
layout.fields.reduce(
(sum, field) => sum + numericValue(actuals[entryKey(field.id, fertilizerId)]),
0
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
<div className="max-h-[92vh] w-full max-w-[95vw] overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
: {plan.name}
</h2>
<p className="text-sm text-gray-500">
</p>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(92vh-144px)] overflow-y-auto bg-gray-50 px-6 py-5">
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="mb-4 rounded-lg bg-white p-4 shadow">
<div className="grid gap-3 text-sm text-gray-700 sm:grid-cols-4">
<div>
<div className="text-xs text-gray-500"></div>
<div className="font-medium">{plan.year}</div>
</div>
<div>
<div className="text-xs text-gray-500"> / </div>
<div className="font-medium">
{plan.crop_name} / {plan.variety_name}
</div>
</div>
<div>
<div className="text-xs text-gray-500"></div>
<div className="font-medium">{plan.field_count}</div>
</div>
<div>
<div className="text-xs text-gray-500"></div>
<div className="font-medium">{plan.fertilizer_count}</div>
</div>
</div>
</div>
<div className="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-xs text-sky-800">
0
</div>
<div className="overflow-x-auto rounded-lg bg-white shadow">
<table className="min-w-full text-sm border-collapse">
<thead className="bg-gray-50">
<tr>
<th className="border border-gray-200 px-4 py-3 text-left font-medium text-gray-700 whitespace-nowrap">
</th>
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
()
</th>
{layout.fertilizers.map((fertilizer) => (
<th
key={fertilizer.id}
className="border border-gray-200 px-3 py-2 text-center font-medium text-gray-700 whitespace-nowrap"
>
<div>{fertilizer.name}</div>
<div className="mt-0.5 text-[11px] font-normal text-gray-400">
/
</div>
</th>
))}
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
</th>
</tr>
</thead>
<tbody>
{layout.fields.map((field) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="border border-gray-200 px-4 py-2 whitespace-nowrap text-gray-800">
{field.name}
</td>
<td className="border border-gray-200 px-3 py-2 text-right text-gray-600 whitespace-nowrap">
{field.areaTan ?? '-'}
</td>
{layout.fertilizers.map((fertilizer) => {
const key = entryKey(field.id, fertilizer.id);
const planned = layout.planned[field.id]?.[fertilizer.id];
const hasEntry = planned !== undefined;
return (
<td key={key} className="border border-gray-200 px-2 py-2">
{hasEntry ? (
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-gray-400">
{planned}
</span>
<input
type="number"
min="0"
step="0.1"
value={actuals[key] ?? ''}
onChange={(e) =>
setActuals((prev) => ({
...prev,
[key]: e.target.value,
}))
}
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
/>
</div>
) : (
<div className="text-center text-gray-300">-</div>
)}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-700">
{actualTotalByField(field.id).toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 font-semibold">
<tr>
<td className="border border-gray-200 px-4 py-2"></td>
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{layout.fields
.reduce((sum, field) => sum + (parseFloat(field.areaTan ?? '0') || 0), 0)
.toFixed(2)}
</td>
{layout.fertilizers.map((fertilizer) => (
<td
key={fertilizer.id}
className="border border-gray-200 px-3 py-2 text-right text-gray-700"
>
{actualTotalByFertilizer(fertilizer.id).toFixed(2)}
</td>
))}
<td className="border border-gray-200 px-3 py-2 text-right text-green-700">
{layout.fields
.reduce((sum, field) => sum + actualTotalByField(field.id), 0)
.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button
onClick={onClose}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
>
</button>
<button
onClick={handleConfirm}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Sprout } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
@@ -62,11 +62,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// roundedColumns: 四捨五入済みの肥料列ID↩ トグル用)
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
const [adjusted, setAdjusted] = useState<Matrix>({});
const [actualMatrix, setActualMatrix] = useState<Matrix>({});
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
const [isConfirmed, setIsConfirmed] = useState(false);
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
@@ -102,9 +101,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setIsConfirmed(plan.is_confirmed);
setConfirmedAt(plan.confirmed_at);
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
setPlanFertilizers(ferts);
@@ -122,11 +118,17 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// 保存済みの値は adjusted に復元
const newAdjusted: Matrix = {};
const newActualMatrix: Matrix = {};
plan.entries.forEach((e) => {
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
newAdjusted[e.field][e.fertilizer] = String(e.bags);
if (e.actual_bags !== null && e.actual_bags !== undefined) {
if (!newActualMatrix[e.field]) newActualMatrix[e.field] = {};
newActualMatrix[e.field][e.fertilizer] = String(e.actual_bags);
}
});
setAdjusted(newAdjusted);
setActualMatrix(newActualMatrix);
setInitialPlanTotals(
plan.entries.reduce((acc: Record<number, number>, entry) => {
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
@@ -195,7 +197,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 肥料追加・削除
const addFertilizer = (fert: Fertilizer) => {
if (isConfirmed) return;
if (planFertilizers.find((f) => f.id === fert.id)) return;
setPlanFertilizers((prev) => [...prev, fert]);
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
@@ -203,7 +205,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
};
const removeFertilizer = (id: number) => {
if (isConfirmed) return;
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
const dropCol = (m: Matrix): Matrix => {
@@ -222,14 +224,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 圃場追加・削除
const addField = (field: Field) => {
if (isConfirmed) return;
if (selectedFields.find((f) => f.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
setShowFieldPicker(false);
};
const removeField = (id: number) => {
if (isConfirmed) return;
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
@@ -239,7 +241,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 自動計算
const runCalc = async (setting: CalcSetting) => {
if (isConfirmed) return;
if (!setting.param) {
setSaveError('パラメータを入力してください');
return;
@@ -298,7 +300,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── セル更新adjusted を更新)
const updateCell = (fieldId: number, fertId: number, value: string) => {
if (isConfirmed) return;
setAdjusted((prev) => {
const next = { ...prev };
if (!next[fieldId]) next[fieldId] = {};
@@ -309,7 +311,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 列単位で四捨五入 / 元に戻す(トグル)
const roundColumn = (fertId: number) => {
if (isConfirmed) return;
if (roundedColumns.has(fertId)) {
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
setAdjusted((prev) => {
@@ -383,10 +385,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 保存adjusted 優先、なければ calc 値を使用)
const handleSave = async () => {
setSaveError(null);
if (isConfirmed) {
setSaveError('確定済みの施肥計画は編集できません。');
return;
}
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
if (!varietyId) { setSaveError('品種を選択してください'); return; }
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
@@ -427,31 +425,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}
};
// ─── 確定取消
const handleUnconfirm = async () => {
if (!planId) return;
setSaveError(null);
try {
await api.post(`/fertilizer/plans/${planId}/unconfirm/`);
setIsConfirmed(false);
setConfirmedAt(null);
// 引当が再作成されるので在庫情報を再取得
const stockRes = await api.get('/materials/fertilizer-stock/');
setStockByMaterialId(
stockRes.data.reduce(
(acc: Record<number, StockSummary>, summary: StockSummary) => {
acc[summary.material_id] = summary;
return acc;
},
{}
)
);
} catch (e) {
console.error(e);
setSaveError('確定取消に失敗しました');
}
};
// ─── PDF出力
const handlePdf = async () => {
if (!planId) return;
@@ -498,13 +471,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h1>
</div>
<div className="flex items-center gap-2">
{!isNew && isConfirmed && (
{!isNew && planId && (
<button
onClick={handleUnconfirm}
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
onClick={() => router.push(`/fertilizer/spreading?year=${year}&plan=${planId}`)}
className="flex items-center gap-2 px-4 py-2 border border-emerald-300 rounded-lg text-sm text-emerald-700 hover:bg-emerald-50"
>
<Undo2 className="h-4 w-4" />
<Sprout className="h-4 w-4" />
</button>
)}
{!isNew && (
@@ -518,11 +491,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
)}
<button
onClick={handleSave}
disabled={saving || isConfirmed}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="h-4 w-4" />
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
@@ -536,16 +509,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</div>
)}
{isConfirmed && (
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0">i</span>
<span>
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
</span>
</div>
)}
{/* 基本情報 */}
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-48">
@@ -555,7 +518,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例: 2025年度 コシヒカリ 元肥"
disabled={isConfirmed}
/>
</div>
<div>
@@ -564,7 +527,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
disabled={isConfirmed}
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
@@ -575,7 +538,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
value={varietyId}
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
disabled={isConfirmed}
>
<option value=""></option>
{crops.map((crop) => (
@@ -601,7 +564,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h2>
<button
onClick={() => setShowFieldPicker(true)}
disabled={isConfirmed}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
@@ -621,7 +584,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
{f.name}{f.area_tan}
<button
onClick={() => removeField(f.id)}
disabled={isConfirmed}
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-3 w-3" />
@@ -641,14 +604,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
type="checkbox"
checked={calcNewOnly}
onChange={(e) => setCalcNewOnly(e.target.checked)}
disabled={isConfirmed}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<button
onClick={() => setShowFertPicker(true)}
disabled={isConfirmed}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
@@ -671,7 +634,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="border border-gray-300 rounded px-2 py-1 text-xs"
value={setting.method}
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
disabled={isConfirmed}
>
{Object.entries(METHOD_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
@@ -684,19 +647,19 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={setting.param}
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
placeholder="値"
disabled={isConfirmed}
/>
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
<button
onClick={() => runCalc(setting)}
disabled={isConfirmed}
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Calculator className="h-3 w-3" />
</button>
<button
onClick={() => removeFertilizer(fert.id)}
disabled={isConfirmed}
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-4 w-4" />
@@ -750,7 +713,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
onClick={() => roundColumn(f.id)}
disabled={isConfirmed}
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
isRounded
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
@@ -775,25 +738,33 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
{planFertilizers.map((fert) => {
const calcVal = calcMatrix[field.id]?.[fert.id];
const adjVal = adjusted[field.id]?.[fert.id];
const actualVal = actualMatrix[field.id]?.[fert.id];
// 計算結果があればラベルを表示adjusted が上書きされた場合は参照値として)
const showRef = calcVal !== undefined;
// 入力欄: adjusted → calc値 → 空
const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
return (
<td key={fert.id} className="px-2 py-1 border border-gray-200">
<div className="flex items-center justify-end gap-1.5">
{showRef && (
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
<div className="space-y-1">
<div className="flex items-center justify-end gap-1.5">
{showRef && (
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
)}
<input
type="number"
step="0.1"
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
/>
</div>
{actualVal !== undefined && (
<div className="text-right text-[11px] text-sky-700">
{actualVal}
</div>
)}
<input
type="number"
step="0.1"
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
disabled={isConfirmed}
/>
</div>
</td>
);
@@ -841,7 +812,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addField(f)}
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>{f.name}</span>
@@ -856,7 +827,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addField(f)}
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>{f.name}</span>
@@ -884,7 +855,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addFertilizer(f)}
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="font-medium">{f.name}</span>

View File

@@ -1,30 +1,49 @@
'use client';
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
import { FileDown, GitMerge, NotebookText, Pencil, Plus, Sprout, Trash2, Truck, X } from 'lucide-react';
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
const currentYear = new Date().getFullYear();
const STATUS_LABELS: Record<FertilizationPlan['spread_status'], string> = {
unspread: '未散布',
partial: '一部散布',
completed: '散布済み',
over_applied: '超過散布',
};
const STATUS_CLASSES: Record<FertilizationPlan['spread_status'], string> = {
unspread: 'bg-gray-100 text-gray-700',
partial: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-800',
over_applied: 'bg-rose-100 text-rose-800',
};
export default function FertilizerPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('fertilizerYear');
if (saved) return parseInt(saved);
if (saved) return parseInt(saved, 10);
}
return currentYear;
});
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [mergeSourcePlan, setMergeSourcePlan] = useState<FertilizationPlan | null>(null);
const [mergeTargets, setMergeTargets] = useState<
{ id: number; name: string; field_count: number; planned_total_bags: string; is_confirmed: boolean }[]
>([]);
const [mergeTargetId, setMergeTargetId] = useState<number | ''>('');
const [mergeLoading, setMergeLoading] = useState(false);
const [mergeSubmitting, setMergeSubmitting] = useState(false);
const [mergeError, setMergeError] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem('fertilizerYear', String(year));
@@ -33,41 +52,31 @@ export default function FertilizerPage() {
const fetchPlans = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/fertilizer/plans/?year=${year}`);
setPlans(res.data);
} catch (e) {
console.error(e);
setError('施肥計画の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number, name: string) => {
setDeleteError(null);
setActionError(null);
setError(null);
try {
await api.delete(`/fertilizer/plans/${id}/`);
await fetchPlans();
} catch (e) {
console.error(e);
setDeleteError(`${name}」の削除に失敗しました`);
}
};
const handleUnconfirm = async (id: number, name: string) => {
setActionError(null);
try {
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
await fetchPlans();
} catch (e) {
console.error(e);
setActionError(`${name}」の確定取消に失敗しました`);
setError(`${name}」の削除に失敗しました`);
}
};
const handlePdf = async (id: number, name: string) => {
setActionError(null);
setError(null);
try {
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
@@ -78,7 +87,69 @@ export default function FertilizerPage() {
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
setActionError('PDF出力に失敗しました');
setError('PDF出力に失敗しました');
}
};
const openMergeDialog = async (plan: FertilizationPlan) => {
setMergeSourcePlan(plan);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(true);
try {
const res = await api.get(`/fertilizer/plans/${plan.id}/merge_targets/`);
setMergeTargets(res.data);
} catch (e) {
console.error(e);
setMergeError('マージ先候補の読み込みに失敗しました。');
} finally {
setMergeLoading(false);
}
};
const closeMergeDialog = () => {
if (mergeSubmitting) return;
setMergeSourcePlan(null);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(false);
};
const handleMerge = async () => {
if (!mergeSourcePlan || !mergeTargetId) {
setMergeError('マージ先の施肥計画を選択してください。');
return;
}
setMergeSubmitting(true);
setMergeError(null);
try {
await api.post(`/fertilizer/plans/${mergeSourcePlan.id}/merge_into/`, {
target_plan_id: mergeTargetId,
});
closeMergeDialog();
await fetchPlans();
} catch (e: unknown) {
const err = e as {
response?: {
data?: {
error?: string;
conflicts?: { field_name: string; fertilizer_name: string }[];
};
};
};
const conflicts = err.response?.data?.conflicts ?? [];
if (conflicts.length > 0) {
const details = conflicts
.map((conflict) => `${conflict.field_name} × ${conflict.fertilizer_name}`)
.join('、');
setMergeError(`${err.response?.data?.error ?? '競合があるためマージできません。'} ${details}`);
} else {
setMergeError(err.response?.data?.error ?? 'マージに失敗しました。');
}
} finally {
setMergeSubmitting(false);
}
};
@@ -87,22 +158,36 @@ export default function FertilizerPage() {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Sprout className="h-6 w-6 text-green-600" />
<h1 className="text-2xl font-bold text-gray-800"></h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/workrecords')}
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<NotebookText className="h-4 w-4" />
</button>
<button
onClick={() => router.push('/fertilizer/spreading')}
className="flex items-center gap-2 rounded-lg border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
>
<Truck className="h-4 w-4" />
</button>
<button
onClick={() => router.push('/fertilizer/masters')}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={() => router.push('/fertilizer/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
<Plus className="h-4 w-4" />
@@ -110,113 +195,76 @@ export default function FertilizerPage() {
</div>
</div>
{/* 年度セレクタ */}
<div className="flex items-center gap-3 mb-6">
<div className="mb-6 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
onChange={(e) => setYear(parseInt(e.target.value, 10))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{deleteError && (
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0"></span>
<span>{deleteError}</span>
<button onClick={() => setDeleteError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600"></button>
</div>
)}
{actionError && (
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0"></span>
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600"></button>
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<p className="text-gray-500">...</p>
) : plans.length === 0 ? (
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
<p>{year}</p>
<button
onClick={() => router.push('/fertilizer/new')}
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
className="mt-4 rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-700"
>
</button>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<thead className="border-b bg-gray-50">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-left px-4 py-3 font-medium text-gray-700"> / </th>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"> / </th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{plans.map((plan) => (
<tr
key={plan.id}
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
>
<td className="px-4 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{plan.name}</span>
{plan.is_confirmed && (
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
<BadgeCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-gray-600">
{plan.crop_name} / {plan.variety_name}
</td>
<td className="px-4 py-3 text-gray-600">
{plan.is_confirmed
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
: '未確定'}
</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
{!plan.is_confirmed ? (
<button
onClick={() => setConfirmTarget(plan)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
title="散布確定"
>
<BadgeCheck className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => handleUnconfirm(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
title="確定取消"
>
<Undo2 className="h-3.5 w-3.5" />
</button>
)}
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_CLASSES[plan.spread_status]}`}>
{STATUS_LABELS[plan.spread_status]}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.planned_total_bags}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.spread_total_bags}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.remaining_total_bags}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handlePdf(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
className="flex items-center gap-1 rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
title="PDF出力"
>
<FileDown className="h-3.5 w-3.5" />
@@ -224,15 +272,25 @@ export default function FertilizerPage() {
</button>
<button
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-blue-300 rounded hover:bg-blue-50 text-blue-700"
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
title="編集"
>
<Pencil className="h-3.5 w-3.5" />
</button>
{plan.is_variety_change_plan && (
<button
onClick={() => openMergeDialog(plan)}
className="flex items-center gap-1 rounded border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 hover:bg-emerald-50"
title="既存計画へマージ"
>
<GitMerge className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => handleDelete(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600"
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
title="削除"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -248,12 +306,84 @@ export default function FertilizerPage() {
)}
</div>
<ConfirmSpreadingModal
isOpen={confirmTarget !== null}
plan={confirmTarget}
onClose={() => setConfirmTarget(null)}
onConfirmed={fetchPlans}
/>
{mergeSourcePlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-xl rounded-lg bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">{mergeSourcePlan.name}</p>
</div>
<button onClick={closeMergeDialog} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<p className="text-sm text-gray-600">
×
</p>
{mergeError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{mergeError}
</div>
)}
{mergeLoading ? (
<p className="text-sm text-gray-500">...</p>
) : mergeTargets.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="space-y-2">
{mergeTargets.map((target) => (
<label
key={target.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 ${
target.is_confirmed ? 'border-gray-200 bg-gray-50 text-gray-400' : 'border-gray-300'
}`}
>
<input
type="radio"
name="merge-target"
value={target.id}
checked={mergeTargetId === target.id}
onChange={() => setMergeTargetId(target.id)}
disabled={target.is_confirmed}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-800">{target.name}</div>
<div className="mt-1 text-xs text-gray-500">
{target.field_count} / {target.planned_total_bags}
{target.is_confirmed ? ' / 散布確定済みのため選択不可' : ''}
</div>
</div>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t px-5 py-4">
<button
onClick={closeMergeDialog}
disabled={mergeSubmitting}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
</button>
<button
onClick={handleMerge}
disabled={mergeSubmitting || mergeLoading || !mergeTargetId}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-50"
>
{mergeSubmitting ? 'マージ中...' : 'マージ実行'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,759 @@
'use client';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import {
DeliveryPlan,
FertilizationPlan,
SpreadingCandidate,
SpreadingSession,
} from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'spreadingYear';
type SourceType = 'delivery' | 'plan' | 'year';
type FormState = {
date: string;
name: string;
notes: string;
itemValues: Record<string, string>;
};
type MatrixField = {
id: number;
name: string;
area_tan: string;
};
type MatrixFertilizer = {
id: number;
name: string;
};
const candidateKey = (fieldId: number, fertilizerId: number) => `${fieldId}:${fertilizerId}`;
const toNumber = (value: string | number | null | undefined) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const formatDisplay = (value: string | number | null | undefined) => {
const num = toNumber(value);
if (Number.isInteger(num)) {
return String(num);
}
return num.toFixed(4).replace(/\.?0+$/, '');
};
const formatInputValue = (value: number) => {
if (value <= 0) return '0';
return value.toFixed(2).replace(/\.?0+$/, '');
};
const getDefaultDate = (year: number) => {
const today = new Date();
if (today.getFullYear() !== year) {
return `${year}-01-01`;
}
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getSourceType = (deliveryPlanId: number | null, fertilizationPlanId: number | null): SourceType => {
if (deliveryPlanId) return 'delivery';
if (fertilizationPlanId) return 'plan';
return 'year';
};
const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: SourceType) => {
const values: Record<string, string> = {};
rows.forEach((candidate) => {
let base = 0;
if (sourceType === 'delivery') {
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
} else if (sourceType === 'plan') {
base = toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other);
} else {
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
}
values[candidateKey(candidate.field, candidate.fertilizer)] = formatInputValue(Math.max(base, 0));
});
return values;
};
export default function SpreadingPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-gray-50"><Navbar /><div className="max-w-7xl mx-auto px-4 py-8 text-gray-500">...</div></div>}>
<SpreadingPageContent />
</Suspense>
);
}
function SpreadingPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryYear = Number(searchParams.get('year') || '0') || null;
const deliveryPlanId = Number(searchParams.get('delivery_plan') || '0') || null;
const fertilizationPlanId = Number(searchParams.get('plan') || '0') || null;
const sourceType = getSourceType(deliveryPlanId, fertilizationPlanId);
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
const [sessions, setSessions] = useState<SpreadingSession[]>([]);
const [candidates, setCandidates] = useState<SpreadingCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [formLoading, setFormLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [openedFromQuery, setOpenedFromQuery] = useState(false);
const [openedFromSource, setOpenedFromSource] = useState(false);
const [sourceName, setSourceName] = useState<string | null>(null);
useEffect(() => {
if (queryYear && queryYear !== year) {
setYear(queryYear);
}
}, [queryYear, year]);
useEffect(() => {
localStorage.setItem(YEAR_KEY, String(year));
void fetchSessions();
setForm(null);
setEditingSessionId(null);
setOpenedFromQuery(false);
setOpenedFromSource(false);
}, [year]);
useEffect(() => {
const loadSource = async () => {
if (deliveryPlanId) {
try {
const res = await api.get(`/fertilizer/delivery/${deliveryPlanId}/`);
const plan: DeliveryPlan = res.data;
setSourceName(plan.name);
return;
} catch (e) {
console.error(e);
setSourceName(`運搬計画 #${deliveryPlanId}`);
return;
}
}
if (fertilizationPlanId) {
try {
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
const plan: FertilizationPlan = res.data;
setSourceName(plan.name);
return;
} catch (e) {
console.error(e);
setSourceName(`施肥計画 #${fertilizationPlanId}`);
return;
}
}
setSourceName(null);
};
void loadSource();
}, [deliveryPlanId, fertilizationPlanId]);
useEffect(() => {
const sessionParam = searchParams.get('session');
if (!sessionParam || openedFromQuery || sessions.length === 0) {
return;
}
const targetId = Number(sessionParam);
if (!targetId) {
return;
}
const target = sessions.find((session) => session.id === targetId);
if (target) {
void openEditor(target);
setOpenedFromQuery(true);
}
}, [openedFromQuery, searchParams, sessions]);
useEffect(() => {
const sessionParam = searchParams.get('session');
if (sessionParam || sourceType === 'year' || openedFromSource || form || formLoading) {
return;
}
void startCreate();
setOpenedFromSource(true);
}, [form, formLoading, openedFromSource, searchParams, sourceType]);
const fetchSessions = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/fertilizer/spreading/?year=${year}`);
setSessions(res.data);
} catch (e) {
console.error(e);
setError('散布実績の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const loadCandidates = async (sessionId?: number) => {
const params = new URLSearchParams({ year: String(year) });
if (sessionId) {
params.set('session_id', String(sessionId));
}
if (deliveryPlanId) {
params.set('delivery_plan_id', String(deliveryPlanId));
}
if (fertilizationPlanId) {
params.set('plan_id', String(fertilizationPlanId));
}
const res = await api.get(`/fertilizer/spreading/candidates/?${params.toString()}`);
setCandidates(res.data);
return res.data as SpreadingCandidate[];
};
const startCreate = async () => {
setFormLoading(true);
setError(null);
try {
const loaded = await loadCandidates();
setEditingSessionId(null);
setForm({
date: getDefaultDate(year),
name: '',
notes: '',
itemValues: buildCreateInitialValues(loaded, sourceType),
});
} catch (e) {
console.error(e);
setError('散布候補の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const openEditor = async (session: SpreadingSession) => {
setFormLoading(true);
setError(null);
try {
await loadCandidates(session.id);
const itemValues = session.items.reduce<Record<string, string>>((acc, item) => {
acc[candidateKey(item.field, item.fertilizer)] = String(item.actual_bags);
return acc;
}, {});
setEditingSessionId(session.id);
setForm({
date: session.date,
name: session.name,
notes: session.notes,
itemValues,
});
} catch (e) {
console.error(e);
setError('散布候補の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const closeEditor = () => {
setEditingSessionId(null);
setForm(null);
setCandidates([]);
};
const candidateMap = useMemo(() => {
const map = new Map<string, SpreadingCandidate>();
candidates.forEach((candidate) => {
map.set(candidateKey(candidate.field, candidate.fertilizer), candidate);
});
return map;
}, [candidates]);
const matrixFields = useMemo<MatrixField[]>(() => {
const map = new Map<number, MatrixField>();
candidates.forEach((candidate) => {
if (!map.has(candidate.field)) {
map.set(candidate.field, {
id: candidate.field,
name: candidate.field_name,
area_tan: candidate.field_area_tan,
});
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
}, [candidates]);
const matrixFertilizers = useMemo<MatrixFertilizer[]>(() => {
const map = new Map<number, MatrixFertilizer>();
candidates.forEach((candidate) => {
if (!map.has(candidate.fertilizer)) {
map.set(candidate.fertilizer, {
id: candidate.fertilizer,
name: candidate.fertilizer_name,
});
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
}, [candidates]);
const handleItemChange = (fieldId: number, fertilizerId: number, value: string) => {
if (!form) return;
const key = candidateKey(fieldId, fertilizerId);
setForm({
...form,
itemValues: {
...form.itemValues,
[key]: value,
},
});
};
const getCellValue = (fieldId: number, fertilizerId: number) => {
if (!form) return '';
return form.itemValues[candidateKey(fieldId, fertilizerId)] ?? '0';
};
const selectedRows = useMemo(() => {
if (!form) return [];
return candidates.filter((candidate) => {
const value = toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
return value > 0;
});
}, [candidates, form]);
const getRowTotal = (fieldId: number) => {
if (!form) return 0;
return matrixFertilizers.reduce((sum, fertilizer) => {
const candidate = candidateMap.get(candidateKey(fieldId, fertilizer.id));
if (!candidate) return sum;
return sum + toNumber(getCellValue(fieldId, fertilizer.id));
}, 0);
};
const getColumnTotal = (fertilizerId: number) => {
if (!form) return 0;
return matrixFields.reduce((sum, field) => {
const candidate = candidateMap.get(candidateKey(field.id, fertilizerId));
if (!candidate) return sum;
return sum + toNumber(getCellValue(field.id, fertilizerId));
}, 0);
};
const totalInputBags = selectedRows.reduce((sum, candidate) => {
return sum + toNumber(form?.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
}, 0);
const handleSave = async () => {
if (!form) return;
setError(null);
if (!form.name.trim()) {
setError('名称を入力してください。');
return;
}
if (!form.date) {
setError('散布日を入力してください。');
return;
}
const items = selectedRows.map((candidate) => ({
field_id: candidate.field,
fertilizer_id: candidate.fertilizer,
actual_bags: toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'),
planned_bags_snapshot: toNumber(candidate.planned_bags),
delivered_bags_snapshot: toNumber(candidate.delivered_bags),
}));
if (items.length === 0) {
setError('散布実績を1件以上入力してください。');
return;
}
setSaving(true);
try {
const payload = {
year,
date: form.date,
name: form.name,
notes: form.notes,
items,
};
if (editingSessionId) {
await api.put(`/fertilizer/spreading/${editingSessionId}/`, payload);
} else {
await api.post('/fertilizer/spreading/', payload);
}
await fetchSessions();
closeEditor();
} catch (e) {
console.error(e);
setError('散布実績の保存に失敗しました。');
} finally {
setSaving(false);
}
};
const handleDelete = async (sessionId: number) => {
setError(null);
try {
await api.delete(`/fertilizer/spreading/${sessionId}/`);
await fetchSessions();
if (editingSessionId === sessionId) {
closeEditor();
}
} catch (e) {
console.error(e);
setError('散布実績の削除に失敗しました。');
}
};
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
const sourceSummary =
sourceType === 'delivery'
? '初期値は運搬計画値から散布済を引いた値です。'
: sourceType === 'plan'
? '初期値は施肥計画値から散布済を引いた値です。'
: '初期値は運搬済みから散布済を引いた値です。';
const sourceLabel =
sourceType === 'delivery'
? '運搬計画を選択した状態です'
: sourceType === 'plan'
? '施肥計画を選択した状態です'
: null;
const clearFilterHref = `/fertilizer/spreading?year=${year}`;
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<Sprout className="h-6 w-6 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<button
onClick={() => void startCreate()}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="mb-6 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{sourceLabel && (
<div className="mb-6 flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3">
<div>
<div className="text-sm font-medium text-emerald-900">{sourceLabel}</div>
<div className="mt-1 text-sm text-emerald-700">
{sourceName ?? (sourceType === 'delivery' ? `運搬計画 #${deliveryPlanId}` : `施肥計画 #${fertilizationPlanId}`)}
{' '}
</div>
<div className="mt-1 text-xs text-emerald-700">{sourceSummary}</div>
</div>
<button
onClick={() => router.push(clearFilterHref)}
className="flex items-center gap-1 rounded border border-emerald-300 px-3 py-1.5 text-xs text-emerald-700 hover:bg-emerald-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{(form || formLoading) && (
<section className="mb-8 rounded-lg border border-emerald-200 bg-white shadow-sm">
<div className="border-b border-emerald-100 px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">
{editingSessionId ? '散布実績を編集' : '散布実績を登録'}
</h2>
<p className="mt-1 text-sm text-gray-500">
×
</p>
<p className="mt-1 text-xs text-gray-500">{sourceSummary}</p>
</div>
{formLoading || !form ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : (
<div className="space-y-5 px-5 py-5">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例: 3/17 元肥散布"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="任意"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-gray-50">
<tr>
<th className="w-48 border border-gray-200 px-4 py-3 text-left font-medium text-gray-700">
</th>
{matrixFertilizers.map((fertilizer) => (
<th
key={fertilizer.id}
className="min-w-[220px] border border-gray-200 px-3 py-3 text-center font-medium text-gray-700"
>
<div>{fertilizer.name}</div>
<div className="mt-1 text-[11px] font-normal text-gray-400">
{formatDisplay(getColumnTotal(fertilizer.id))}
</div>
</th>
))}
<th className="w-28 border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
</th>
</tr>
</thead>
<tbody>
{matrixFields.length === 0 ? (
<tr>
<td
colSpan={matrixFertilizers.length + 2}
className="border border-gray-200 px-4 py-8 text-center text-gray-400"
>
</td>
</tr>
) : (
matrixFields.map((field) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="border border-gray-200 px-4 py-3 align-top">
<div className="font-medium text-gray-900">{field.name}</div>
<div className="text-xs text-gray-400">{field.area_tan}</div>
</td>
{matrixFertilizers.map((fertilizer) => {
const candidate = candidateMap.get(candidateKey(field.id, fertilizer.id));
if (!candidate) {
return (
<td
key={fertilizer.id}
className="border border-gray-200 bg-gray-50 px-3 py-3 text-center text-xs text-gray-300"
>
-
</td>
);
}
return (
<td key={fertilizer.id} className="border border-gray-200 px-3 py-3 align-top">
<div className="flex items-center justify-between gap-3">
<div className="grid flex-1 grid-cols-2 gap-x-3 gap-y-1 text-[11px] leading-5 text-gray-500">
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.planned_bags)}</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400">
{sourceType === 'plan' ? '計画残' : '未散布'}
</span>
<span>
{formatDisplay(
sourceType === 'plan'
? Math.max(toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other), 0)
: Math.max(toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other), 0)
)}
</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.delivered_bags)}</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.spread_bags_other)}</span>
</div>
</div>
<input
type="number"
step="0.1"
value={getCellValue(field.id, fertilizer.id)}
onChange={(e) => handleItemChange(field.id, fertilizer.id, e.target.value)}
className="w-20 shrink-0 rounded border border-gray-300 px-2 py-1.5 text-right text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</td>
);
})}
<td className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
{formatDisplay(getRowTotal(field.id))}
</td>
</tr>
))
)}
</tbody>
{matrixFields.length > 0 && (
<tfoot className="bg-gray-50">
<tr>
<td className="border border-gray-200 px-4 py-3 font-medium text-gray-700"></td>
{matrixFertilizers.map((fertilizer) => (
<td
key={fertilizer.id}
className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700"
>
{formatDisplay(getColumnTotal(fertilizer.id))}
</td>
))}
<td className="border border-gray-200 px-3 py-3 text-right font-bold text-green-700">
{formatDisplay(totalInputBags)}
</td>
</tr>
</tfoot>
)}
</table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{selectedRows.length} / {formatDisplay(totalInputBags)}
</p>
<div className="flex items-center gap-3">
<button
onClick={closeEditor}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</section>
)}
<section className="rounded-lg bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
</div>
{loading ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : sessions.length === 0 ? (
<div className="px-5 py-8 text-sm text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sessions.map((session) => {
const totalBags = session.items.reduce((sum, item) => sum + toNumber(item.actual_bags), 0);
return (
<tr key={session.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-700">{session.date}</td>
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{session.name || '名称なし'}</div>
{session.notes && <div className="text-xs text-gray-400">{session.notes}</div>}
</td>
<td className="px-4 py-3 text-right text-gray-600">{session.items.length}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{formatDisplay(totalBags)}</td>
<td className="px-4 py-3 text-right text-gray-600">
{session.work_record_id ? `#${session.work_record_id}` : '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => void openEditor(session)}
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => void handleDelete(session.id)}
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
</div>
);
}

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';
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc';
export interface MaterialFormState {
name: string;
material_type: Material['material_type'];
seed_variety_id: string;
maker: string;
stock_unit: Material['stock_unit'];
is_active: boolean;
@@ -33,6 +34,7 @@ interface MaterialFormProps {
tab: MaterialTab;
form: MaterialFormState;
saving: boolean;
seedVarietyOptions?: { id: number; label: string }[];
onBaseFieldChange: (
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
value: string | boolean
@@ -56,6 +58,7 @@ export default function MaterialForm({
tab,
form,
saving,
seedVarietyOptions = [],
onBaseFieldChange,
onFertilizerFieldChange,
onPesticideFieldChange,
@@ -244,14 +247,29 @@ export default function MaterialForm({
/>
</td>
<td className="px-2 py-2">
<select
className={inputClassName}
value={form.material_type}
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
>
<option value="other"></option>
<option value="seedling"></option>
</select>
{tab === 'seed' ? (
<select
className={inputClassName}
value={form.seed_variety_id}
onChange={(e) => onBaseFieldChange('seed_variety_id', e.target.value)}
>
<option value=""></option>
{seedVarietyOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
) : (
<select
className={inputClassName}
value={form.material_type}
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
>
<option value="other"></option>
<option value="seedling"></option>
</select>
)}
</td>
<td className="px-2 py-2">
<input

View File

@@ -1,7 +1,7 @@
'use client';
import { Fragment } from 'react';
import { Clock3, Download, Upload } from 'lucide-react';
import { Clock3, Download, Pencil, Trash2, Upload } from 'lucide-react';
import { StockSummary, StockTransaction } from '@/types';
@@ -15,6 +15,8 @@ interface StockOverviewProps {
materialId: number,
transactionType: StockTransaction['transaction_type']
) => void;
onEditTransaction: (transaction: StockTransaction) => void;
onDeleteTransaction: (transaction: StockTransaction) => void;
onToggleHistory: (materialId: number) => void;
}
@@ -25,6 +27,8 @@ export default function StockOverview({
historyLoadingId,
histories,
onOpenTransaction,
onEditTransaction,
onDeleteTransaction,
onToggleHistory,
}: StockOverviewProps) {
if (loading) {
@@ -149,6 +153,24 @@ export default function StockOverview({
<span className="text-gray-500">
{transaction.note || '備考なし'}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => onEditTransaction(transaction)}
disabled={transaction.is_locked}
className="inline-flex items-center gap-1 rounded border border-blue-300 px-2 py-1 text-xs text-blue-700 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={() => onDeleteTransaction(transaction)}
disabled={transaction.is_locked}
className="inline-flex items-center gap-1 rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>

View File

@@ -13,6 +13,7 @@ interface StockTransactionFormProps {
materials: Material[];
presetMaterialId?: number | null;
presetTransactionType?: TransactionType | null;
editingTransaction?: StockTransaction | null;
onClose: () => void;
onSaved: () => Promise<void> | void;
}
@@ -32,6 +33,7 @@ export default function StockTransactionForm({
materials,
presetMaterialId = null,
presetTransactionType = null,
editingTransaction = null,
onClose,
onSaved,
}: StockTransactionFormProps) {
@@ -47,13 +49,21 @@ export default function StockTransactionForm({
if (!isOpen) {
return;
}
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
if (editingTransaction) {
setMaterialId(String(editingTransaction.material));
setTransactionType(editingTransaction.transaction_type);
setQuantity(editingTransaction.quantity);
setOccurredOn(editingTransaction.occurred_on);
setNote(editingTransaction.note || '');
} else {
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
}
setError(null);
}, [isOpen, presetMaterialId, presetTransactionType]);
}, [isOpen, presetMaterialId, presetTransactionType, editingTransaction]);
if (!isOpen) {
return null;
@@ -73,13 +83,18 @@ export default function StockTransactionForm({
setSaving(true);
try {
await api.post('/materials/stock-transactions/', {
const payload = {
material: Number(materialId),
transaction_type: transactionType,
quantity,
occurred_on: occurredOn,
note,
});
};
if (editingTransaction) {
await api.put(`/materials/stock-transactions/${editingTransaction.id}/`, payload);
} else {
await api.post('/materials/stock-transactions/', payload);
}
await onSaved();
onClose();
} catch (e: unknown) {
@@ -104,7 +119,9 @@ export default function StockTransactionForm({
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
<p className="text-sm text-gray-500">
{editingTransaction ? '入出庫履歴を修正します。' : '在庫の増減を記録します。'}
</p>
</div>
<button
onClick={onClose}

View File

@@ -10,20 +10,35 @@ import MaterialForm, {
} from '../_components/MaterialForm';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Material } from '@/types';
import { Crop, Material } from '@/types';
const tabs: { key: MaterialTab; label: string }[] = [
{ key: 'fertilizer', label: '肥料' },
{ key: 'pesticide', label: '農薬' },
{ key: 'seed', label: '種子' },
{ key: 'misc', label: 'その他' },
];
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
name: '',
material_type:
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
tab === 'fertilizer'
? 'fertilizer'
: tab === 'pesticide'
? 'pesticide'
: tab === 'seed'
? 'seed'
: 'other',
seed_variety_id: '',
maker: '',
stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece',
stock_unit:
tab === 'fertilizer'
? 'bag'
: tab === 'pesticide'
? 'bottle'
: tab === 'seed'
? 'kg'
: 'piece',
is_active: true,
notes: '',
fertilizer_profile: {
@@ -42,10 +57,13 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
},
});
type VarietyOption = { id: number; label: string };
export default function MaterialMastersPage() {
const router = useRouter();
const [tab, setTab] = useState<MaterialTab>('fertilizer');
const [materials, setMaterials] = useState<Material[]>([]);
const [crops, setCrops] = useState<Crop[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
@@ -53,7 +71,7 @@ export default function MaterialMastersPage() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchMaterials();
fetchData();
}, []);
useEffect(() => {
@@ -62,11 +80,15 @@ export default function MaterialMastersPage() {
}
}, [tab, editingId]);
const fetchMaterials = async () => {
const fetchData = async () => {
setLoading(true);
try {
const res = await api.get('/materials/materials/');
setMaterials(res.data);
const [materialsRes, cropsRes] = await Promise.all([
api.get('/materials/materials/'),
api.get('/plans/crops/'),
]);
setMaterials(materialsRes.data);
setCrops(cropsRes.data);
} catch (e) {
console.error(e);
setError('資材マスタの取得に失敗しました。');
@@ -75,6 +97,26 @@ export default function MaterialMastersPage() {
}
};
const allVarieties = crops.flatMap((crop) =>
crop.varieties.map((variety) => ({
...variety,
crop_name: crop.name,
}))
);
const seedVarietyOptions: VarietyOption[] = allVarieties.map((variety) => ({
id: variety.id,
label: `${variety.crop_name} / ${variety.name}`,
}));
const getLinkedVariety = (materialId: number) =>
allVarieties.find((variety) => variety.seed_material === materialId) ?? null;
const getLinkedVarietyLabel = (materialId: number) => {
const variety = getLinkedVariety(materialId);
return variety ? `${variety.crop_name} / ${variety.name}` : '-';
};
const visibleMaterials = materials.filter((material) => {
if (tab === 'misc') {
return material.material_type === 'other' || material.material_type === 'seedling';
@@ -90,9 +132,11 @@ export default function MaterialMastersPage() {
const startEdit = (material: Material) => {
setError(null);
const linkedVariety = getLinkedVariety(material.id);
setForm({
name: material.name,
material_type: material.material_type,
seed_variety_id: linkedVariety ? String(linkedVariety.id) : '',
maker: material.maker,
stock_unit: material.stock_unit,
is_active: material.is_active,
@@ -120,6 +164,23 @@ export default function MaterialMastersPage() {
setForm(emptyForm(tab));
};
const syncSeedVariety = async (materialId: number, seedVarietyId: string) => {
const currentlyLinked = getLinkedVariety(materialId);
const selectedVarietyId = seedVarietyId ? parseInt(seedVarietyId, 10) : null;
if (currentlyLinked && currentlyLinked.id !== selectedVarietyId) {
await api.patch(`/plans/varieties/${currentlyLinked.id}/`, {
seed_material: null,
});
}
if (selectedVarietyId) {
await api.patch(`/plans/varieties/${selectedVarietyId}/`, {
seed_material: materialId,
});
}
};
const handleSave = async () => {
setError(null);
@@ -159,13 +220,27 @@ export default function MaterialMastersPage() {
: undefined,
};
let savedMaterial: Material;
if (editingId === 'new') {
await api.post('/materials/materials/', payload);
const res = await api.post('/materials/materials/', payload);
savedMaterial = res.data;
} else {
await api.put(`/materials/materials/${editingId}/`, payload);
const res = await api.put(`/materials/materials/${editingId}/`, payload);
savedMaterial = res.data;
}
await fetchMaterials();
if (form.material_type === 'seed') {
await syncSeedVariety(savedMaterial.id, form.seed_variety_id);
} else {
const linkedVariety = getLinkedVariety(savedMaterial.id);
if (linkedVariety) {
await api.patch(`/plans/varieties/${linkedVariety.id}/`, {
seed_material: null,
});
}
}
await fetchData();
setEditingId(null);
setForm(emptyForm(tab));
} catch (e: unknown) {
@@ -189,7 +264,7 @@ export default function MaterialMastersPage() {
setError(null);
try {
await api.delete(`/materials/materials/${material.id}/`);
await fetchMaterials();
await fetchData();
} catch (e: unknown) {
console.error(e);
const detail =
@@ -241,6 +316,22 @@ export default function MaterialMastersPage() {
}));
};
const tableProps = {
materials: visibleMaterials,
editingId,
form,
saving,
seedVarietyOptions,
getLinkedVarietyLabel,
onEdit: startEdit,
onDelete: handleDelete,
onBaseFieldChange: handleBaseFieldChange,
onFertilizerFieldChange: handleFertilizerFieldChange,
onPesticideFieldChange: handlePesticideFieldChange,
onSave: handleSave,
onCancel: cancelEdit,
};
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
@@ -304,51 +395,10 @@ export default function MaterialMastersPage() {
<p className="text-sm text-gray-500">...</p>
) : (
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
{tab === 'fertilizer' && (
<FertilizerTable
materials={visibleMaterials}
editingId={editingId}
form={form}
saving={saving}
onEdit={startEdit}
onDelete={handleDelete}
onBaseFieldChange={handleBaseFieldChange}
onFertilizerFieldChange={handleFertilizerFieldChange}
onPesticideFieldChange={handlePesticideFieldChange}
onSave={handleSave}
onCancel={cancelEdit}
/>
)}
{tab === 'pesticide' && (
<PesticideTable
materials={visibleMaterials}
editingId={editingId}
form={form}
saving={saving}
onEdit={startEdit}
onDelete={handleDelete}
onBaseFieldChange={handleBaseFieldChange}
onFertilizerFieldChange={handleFertilizerFieldChange}
onPesticideFieldChange={handlePesticideFieldChange}
onSave={handleSave}
onCancel={cancelEdit}
/>
)}
{tab === 'misc' && (
<MiscTable
materials={visibleMaterials}
editingId={editingId}
form={form}
saving={saving}
onEdit={startEdit}
onDelete={handleDelete}
onBaseFieldChange={handleBaseFieldChange}
onFertilizerFieldChange={handleFertilizerFieldChange}
onPesticideFieldChange={handlePesticideFieldChange}
onSave={handleSave}
onCancel={cancelEdit}
/>
)}
{tab === 'fertilizer' && <FertilizerTable {...tableProps} />}
{tab === 'pesticide' && <PesticideTable {...tableProps} />}
{tab === 'seed' && <SeedTable {...tableProps} />}
{tab === 'misc' && <MiscTable {...tableProps} />}
</div>
)}
</div>
@@ -361,6 +411,8 @@ interface TableProps {
editingId: number | 'new' | null;
form: MaterialFormState;
saving: boolean;
seedVarietyOptions: VarietyOption[];
getLinkedVarietyLabel: (materialId: number) => string;
onEdit: (material: Material) => void;
onDelete: (material: Material) => void;
onBaseFieldChange: (
@@ -509,6 +561,59 @@ function PesticideTable(props: TableProps) {
);
}
function SeedTable(props: TableProps) {
return (
<table className="min-w-full text-sm">
<thead className="bg-gray-50">
<tr className="border-b border-gray-200">
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-center font-medium text-gray-700">使</th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{props.editingId === 'new' && <MaterialForm tab="seed" {...props} />}
{props.materials.map((material) =>
props.editingId === material.id ? (
<MaterialForm key={material.id} tab="seed" {...props} />
) : (
<tr key={material.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
<td className="px-4 py-3 text-gray-600">
{props.getLinkedVarietyLabel(material.id)}
</td>
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
<td className="px-4 py-3 text-center text-gray-600">
{material.is_active ? '○' : '-'}
</td>
<td className="px-4 py-3">
<RowActions
disabled={props.editingId !== null}
onEdit={() => props.onEdit(material)}
onDelete={() => props.onDelete(material)}
/>
</td>
</tr>
)
)}
{props.materials.length === 0 && props.editingId === null && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
);
}
function MiscTable(props: TableProps) {
return (
<table className="min-w-full text-sm">

View File

@@ -10,12 +10,13 @@ import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Material, StockSummary, StockTransaction } from '@/types';
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc';
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'seed' | 'misc';
const tabs: { key: FilterTab; label: string }[] = [
{ key: 'all', label: '全て' },
{ key: 'fertilizer', label: '肥料' },
{ key: 'pesticide', label: '農薬' },
{ key: 'seed', label: '種子' },
{ key: 'misc', label: 'その他' },
];
@@ -33,6 +34,7 @@ export default function MaterialsPage() {
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
const [presetTransactionType, setPresetTransactionType] =
useState<StockTransaction['transaction_type'] | null>(null);
const [editingTransaction, setEditingTransaction] = useState<StockTransaction | null>(null);
useEffect(() => {
fetchInitialData();
@@ -93,11 +95,41 @@ export default function MaterialsPage() {
materialId: number | null,
transactionType: StockTransaction['transaction_type'] | null
) => {
setEditingTransaction(null);
setPresetMaterialId(materialId);
setPresetTransactionType(transactionType);
setIsTransactionOpen(true);
};
const handleEditTransaction = (transaction: StockTransaction) => {
setPresetMaterialId(null);
setPresetTransactionType(null);
setEditingTransaction(transaction);
setIsTransactionOpen(true);
};
const handleDeleteTransaction = async (transaction: StockTransaction) => {
if (!confirm(`この入出庫履歴を削除しますか?\n${transaction.transaction_type_display} ${transaction.quantity}${transaction.stock_unit_display}`)) {
return;
}
try {
await api.delete(`/materials/stock-transactions/${transaction.id}/`);
await handleSavedTransaction();
} catch (e: unknown) {
console.error(e);
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: '入出庫履歴の削除に失敗しました。';
setError(detail);
}
};
const handleSavedTransaction = async () => {
await fetchSummaryOnly();
@@ -191,6 +223,8 @@ export default function MaterialsPage() {
historyLoadingId={historyLoadingId}
histories={histories}
onOpenTransaction={handleOpenTransaction}
onEditTransaction={handleEditTransaction}
onDeleteTransaction={handleDeleteTransaction}
onToggleHistory={handleToggleHistory}
/>
</div>
@@ -200,7 +234,11 @@ export default function MaterialsPage() {
materials={materials}
presetMaterialId={presetMaterialId}
presetTransactionType={presetTransactionType}
onClose={() => setIsTransactionOpen(false)}
editingTransaction={editingTransaction}
onClose={() => {
setIsTransactionOpen(false);
setEditingTransaction(null);
}}
onSaved={handleSavedTransaction}
/>
</div>

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

@@ -0,0 +1,144 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, NotebookText } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { WorkRecord } from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'workRecordYear';
export default function WorkRecordsPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
const [records, setRecords] = useState<WorkRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem(YEAR_KEY, String(year));
void fetchRecords();
}, [year]);
const fetchRecords = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/workrecords/?year=${year}`);
setRecords(res.data);
} catch (e) {
console.error(e);
setError('作業記録の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const moveToSource = (record: WorkRecord) => {
if (record.spreading_session) {
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
return;
}
if (record.levee_work_session) {
router.push(`/levee-work?session=${record.levee_work_session}`);
return;
}
if (record.delivery_plan_id) {
router.push(`/distribution/${record.delivery_plan_id}/edit`);
}
};
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center gap-3">
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<NotebookText className="h-6 w-6 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<div className="mb-6 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="overflow-hidden rounded-lg bg-white shadow-sm">
{loading ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : records.length === 0 ? (
<div className="px-5 py-8 text-sm text-gray-400"></div>
) : (
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{records.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-700">{record.work_date}</td>
<td className="px-4 py-3 text-gray-700">{record.work_type_display}</td>
<td className="px-4 py-3 font-medium text-gray-900">{record.title}</td>
<td className="px-4 py-3 text-gray-600">
{record.spreading_session
? `散布実績 #${record.spreading_session}`
: record.levee_work_session
? `畔塗記録 #${record.levee_work_session}`
: record.delivery_plan_name
? `${record.delivery_plan_name}`
: '-'}
</td>
<td className="px-4 py-3 text-right">
{(record.spreading_session || record.levee_work_session || record.delivery_plan_id) && (
<button
onClick={() => moveToSource(record)}
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</main>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More