Compare commits

..

87 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
Akira
d9a4bd19eb 施肥計画の「利用可能」表示を修正: 在庫の実残数を正しく表示
- getPlanAvailableStock: 自計画の引当を足し戻す計算を廃止し、
  サーバー側available_stock + 初期引当 - 現在計画量でリアルタイム算出
- getPlanShortage: available_stockベースの不足判定に変更
- 編集中の計画変更が即座に利用可能数に反映されるように

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:13:41 +09:00
Akira
89ab9b7b83 これで:
利用可能 = available_stock = 在庫 - 全計画の引当合計(マイナスならマイナス表示)
不足 = available_stock がマイナスのとき、その絶対値を赤字表示
どの計画画面でも同じ「利用可能」の値が表示される
例(仁井田米有機 55袋、計画A 47袋 + 計画B 5袋):

利用可能: 3.00袋(どちらの計画でも同じ)
不足: 表示なし(まだ余裕あり)
2026-03-16 09:57:43 +09:00
Akira
d5d78a2b14 deploy.sh にマイグレーション自動実行を追加
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:59:53 +09:00
Akira
391b0b265e for serena 2026-03-15 15:44:58 +09:00
Akira
736b9c824e Docker Compose 構成をシンプル化: 本番=docker-compose.yml、開発=docker-compose.develop.yml
- docker-compose.yml を本番用に変更(旧 docker-compose.prod.yml の内容)
- docker-compose.develop.yml を新規追加(開発用)
- deploy.sh を追加(本番デプロイスクリプト)
- develop.bat を追加(ローカル開発起動スクリプト)
- docker-compose.prod.yml を削除
- 本番サーバーに .env → .env.production シンボリックリンク設置済み
- CLAUDE.md のデプロイコマンドを更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:43:50 +09:00
Akira
e3c21d6e81 ConfirmSpreadingModal の改善点:
groupedEntries(肥料別リスト表示)→ layout(圃場×肥料のマトリクス表)に変更 
施肥計画編集画面と同じ「圃場名 / 面積(反) / 肥料列... / 合計」のテーブル構造に統一 
各セルに計画値ラベル + 実績入力欄を縦並び 
列合計(肥料別)・行合計(圃場別)・総合計を追加 
計画情報サマリーカード(年度・品種・圃場数・肥料数)を追加 
操作ガイド(sky色バナー)を追加 
モーダル幅を max-w-4xl → max-w-[95vw] に拡大(マトリクス表に合わせて) 
ドキュメント更新:

document/13_マスタードキュメント_施肥計画編.md — 在庫引当・散布確定・確定取消 API を追記 
改善案/在庫管理機能実装案.md — 微修正 
2026-03-15 13:48:48 +09:00
Akira
72b4d670fe 完璧に動作しています。
テスト	結果
確定取消 API	 is_confirmed: false, confirmed_at: null
USE トランザクション削除	 current_stock が 27.5→32 に復帰
引当再作成	 reserved_stock = 5.000 に復帰
追加した変更:

stock_service.py:81-93 — unconfirm_spreading(): USE削除→確定フラグリセット→引当再作成
fertilizer/views.py — unconfirm アクション(POST /api/fertilizer/plans/{id}/unconfirm/)
fertilizer/page.tsx — 一覧に「確定取消」ボタン(確定済み計画のみ表示)
FertilizerEditPage.tsx — 編集画面ヘッダーに「確定取消」ボタン + 在庫情報再取得
2026-03-15 13:28:02 +09:00
Akira
42b11a5df8 在庫管理 Phase 1.5(引当・散布確定)の設計を追記し、CODEX指示書を更新
- 在庫管理機能実装案.md: セクション23(引当・散布確定ワークフロー)を追加
- CODEX.md: Phase 1完了を受け、Phase 1.5実装指示に全面書き換え

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:29:05 +09:00
Akira
497bc87c24 在庫管理機能 Phase 1 実装(apps/materials + フロントエンド)
Backend:
- apps/materials 新規作成(Material, FertilizerProfile, PesticideProfile, StockTransaction)
- 資材マスタ CRUD API(/api/materials/materials/)
- 入出庫履歴 API(/api/materials/stock-transactions/)
- 在庫集計 API(/api/materials/stock-summary/)
- 既存 Fertilizer に material OneToOneField 追加(0005マイグレーション、データ移行込み)

Frontend:
- /materials: 在庫一覧画面(タブフィルタ、履歴展開、入出庫モーダル)
- /materials/masters: 資材マスタ管理(肥料/農薬/その他タブ、インライン編集)
- Navbar に「在庫管理」メニュー追加
- Material/StockTransaction/StockSummary 型定義追加

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:42:47 +09:00
Akira
67d4197b7f 在庫管理機能実装案をレビュー反映し、CODEX実装指示書を追加
- StockTransaction から冗長フィールド除外(unit, reference_type/id, created_by, inventory_count)
- フロントエンド画面構成を変更(入出庫登録をモーダル化、マスタ管理をタブ統合)
- レビュー記録セクション22を追加
- CODEX.md: Phase 1 実装指示書を作成(モデル・API・画面の詳細仕様)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:03:09 +09:00
Akira
1b619c44a0 在庫管理機能実装案.md に ## 21. 補足: 外部サブスクLLM活用案 を追記しました。
内容は、恵菜システム自身は LLM API を呼ばず、調査用プロンプトを提供する 調査手順を表示する LLM回答を貼り付けてフォームへ反映する という実装案です。肥料用・農薬用のプロンプト例、画面イメージ、貼り付け結果の取り込み方式、メリットと注意点まで入れています。

今回は文書更新のみで、コード変更やテストはしていません。必要なら次に、この 貼り付け結果をフォームへ反映 するための入力フォーマット仕様や、画面ワイヤー案まで具体化できます。
2026-03-13 18:29:03 +09:00
Akira
f1512febde 在庫管理機能実装案.md に、電気炉さい ミネラルホウ素 リン酸グアノ も追記しました。
追加した内容は、各資材についての袋表示・保証票ベースの情報、実装上の扱い、alias の例です。あわせて最後のまとめも更新して、5件を次の3パターンに整理しました。

現場名と保証票名が違う: 仁井田米有機, 土佐勤農党, 電気炉さい
現場名と表示名がほぼ一致する: リン酸グアノ
公式マスタよりローカル資材管理向き: ミネラルホウ素
今回は文書更新のみで、コード変更やテストはしていません。必要なら次に、この5件を前提にした MaterialAlias の Django モデル案まで具体化します。
2026-03-13 13:50:24 +09:00
Akira
776a269d6d 在庫管理機能実装案.md に ## 18. 補足: 肥料公式データ同期・あいまい検索案 を追記しました。
内容は、普通肥料を主対象に公式データをローカルDB化し、検索時はローカル検索、24時間超過時だけ裏で差分同期する という方針です。FertilizerOfficialMaster のテーブル案、既存 Fertilizer との紐づけ、検索API、同期ジョブ、特殊肥料は手入力併用にする考え方まで入れています。

今回は文書更新のみで、コード変更やテスト実行はしていません。必要なら次は、この18章をもとに models.py レベルの実装草案まで起こせます。
2026-03-13 13:26:18 +09:00
Akira
1425094107 在庫管理機能実装案.md に、## 17. 補足: 農薬公式データ同期・あいまい検索案 を追記しました。
今回追加したのは、ローカルDBで即検索しつつ、24時間以上経過時だけ裏で差分同期する 方式の具体化です。PesticideOfficialMaster と OfficialDataSyncStatus のテーブル案、検索API、同期ジョブ、差分更新ルール、フロントの再読込挙動、失敗時フォールバックまで入れてあります。

文書更新のみで、コード変更やテスト実行はしていません。必要なら次に、この章をそのまま実装に落として、Django モデル案と API 仕様書を作れます。
2026-03-13 13:22:05 +09:00
Akira
f74dc4c4b7 在庫管理機能実装案 2026-03-13 13:13:40 +09:00
Akira
7825f0eb30 docs: sync mail notification account mapping updates 2026-03-05 15:16:12 +09:00
Akira
422a6781c5 mail: distinguish xserver mailboxes in account labels 2026-03-05 14:17:25 +09:00
Akira
0e809ebb99 施肥計画編集: ページ開時に自動計算・≈を入力値にも適用
- 編集画面を開いた際、保存済みcalc_settingsで自動計算しcalcMatrixを生成
  → 計算ボタンを押さなくてもラベルが表示されるようになる
- roundColumn(≈)がcalcMatrixにない場合はadjusted値を丸めるよう修正
  → 計算ボタンを押さなくても≈で整数丸めが効くようになる

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 11:34:59 +09:00
Akira
ff67a6bf26 施肥計画: 計算設定の復元とラベル表示バグを修正
- calcNewOnly フィルターを hasAdjusted/hasCalc チェックから inputValue === '' ベースに変更
  (既存プランを開いた際に全フィールドが「計算済み」と判定されて計算が動かないバグを修正)
- runCalc で adjusted をクリアしないよう変更
  (計算ボタン押下後にラベル=計算結果、テキストボックス=DB/確定値が同時表示されるよう修正)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 11:23:38 +09:00
Akira
5145217481 施肥計画の計算設定を保存・復元し、未入力圃場のみ計算オプションを追加
- FertilizationPlanにcalc_settings JSONFieldを追加(migration 0004)
- 編集画面を開くと前回の計算方式・パラメータが復元される
- 「未入力圃場のみ」チェックで既存値を保持したまま新規圃場だけ計算可能

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 10:50:56 +09:00
Akira
21d1dc355d 施肥計画一覧のボタンを分配計画に合わせて統一
アイコンのみ→アイコン+テキスト+ボーダー付きボタンに変更。
PDF(グレー)/ 編集(青)/ 削除(赤)のスタイルを両ページで統一。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 10:00:51 +09:00
Akira
8c47217003 未割り当て圃場に交互背景色を追加
行の対応が分かりにくい問題を解消するため、
偶数行/奇数行で白/グレーの交互背景色を適用。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:54:04 +09:00
Akira
a331f8b30a 未割り当て圃場の圃場名が切れる問題を修正
w-32 truncate(128px固定)を flex-1 min-w-0 truncate に変更し、
利用可能な幅いっぱいに伸びるようにした。
ホバーで全文確認できるよう title 属性も追加。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:50:42 +09:00
Akira
466eef128c 分配計画機能を実装
施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を
表示・PDF出力できる機能を追加。

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:43:20 +09:00
Akira
0d321df1c4 ブラウザテスト後のクリーンアップ 2026-03-01 18:25:58 +09:00
138 changed files with 202937 additions and 867 deletions

View File

@@ -0,0 +1,78 @@
---
description: ブラウザテスト後のクリーンアップ手順
---
# ブラウザテスト後のクリーンアップ
ブラウザサブエージェントを使ったテスト実施後、必ず以下のクリーンアップを行うこと。
## ルール
1. **テスト関連ファイルはすべて `testing/` フォルダ以下に保存する**
- スクリーンショット → `testing/screenshots/<カテゴリ>/`
- 録画(.webp`testing/recordings/`
- テスト結果レポート → `testing/test_report.md`
- サブエージェントが自動生成した一時ファイル → `testing/subagent_generated/`
2. **プロジェクトの既存ファイルを変更しない**
- ブラウザサブエージェントは内部的に Playwright を使っており、以下のファイルを勝手に生成・変更することがある:
- `frontend/playwright_*.mjs`(テストスクリプト)
- `frontend/e2e/`(テストディレクトリ)
- `frontend/test-results/`(テスト結果)
- `.gitignore`(追記されることがある)
- `docker-compose.yml``WATCHPACK_POLLING` が追加されることがある)
- `frontend/src/` 内のソースコード(稀に変更されることがある)
## テスト終了後のクリーンアップ手順
// turbo-all
### 1. git で変更されたファイルを確認する
```powershell
git diff --name-only
```
### 2. サブエージェントによる既存ファイルの変更を元に戻す
```powershell
# 典型的な復元対象
git checkout .gitignore
git checkout docker-compose.yml
git checkout frontend/src/ 2>$null
git checkout frontend/tsconfig.tsbuildinfo 2>$null
```
### 3. サブエージェントが生成した一時ファイルを `testing/subagent_generated/` に移動する
```powershell
$project = "C:\Users\akira\Develop\keinasystem_t02"
$dest = "$project\testing\subagent_generated"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
# playwright系ファイル
Get-ChildItem "$project\frontend\playwright_*.mjs" -ErrorAction SilentlyContinue | Move-Item -Destination $dest -Force
# e2eフォルダ
if (Test-Path "$project\frontend\e2e") { Move-Item "$project\frontend\e2e" "$dest\e2e" -Force }
# test-resultsフォルダ
if (Test-Path "$project\frontend\test-results") { Move-Item "$project\frontend\test-results" "$dest\test-results" -Force }
```
### 4. git管理に追加されてしまったファイルを除外する
```powershell
git rm --cached "frontend/e2e/*" 2>$null
git rm --cached "frontend/test-results/*" 2>$null
git rm --cached "frontend/playwright_*.mjs" 2>$null
```
### 5. 最終確認
```powershell
# testing/ 以外に未追跡・変更ファイルがないことを確認
git status --short | Where-Object { $_ -notmatch "testing/" }
```
上記の結果が空であればクリーンアップ完了。

View File

@@ -56,10 +56,33 @@
"Bash(BASE=\"http://localhost/api/w/admins\")", "Bash(BASE=\"http://localhost/api/w/admins\")",
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")", "Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
"Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)", "Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)",
"Bash(git add:*)" "Bash(git add:*)",
"Bash(xargs cat:*)",
"Bash(xargs grep:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_dd08c8854e486d12__ echo \"=== Fertilization Plans \\(check is_confirmed/confirmed_at\\) ===\" curl -s http://localhost:8000/api/fertilizer/plans/?year=2026 -H \"Authorization: Bearer $TOKEN\")",
"Bash(python -m json.tool)",
"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:*)",
"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": [ "additionalDirectories": [
"C:\\Users\\akira\\AppData\\Local\\Temp" "C:\\Users\\akira\\AppData\\Local\\Temp",
"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行で
## ブロック要因
(なければ「なし」)
---
## 概要
## 背景・目的
## 完了条件
## 関連

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@ out/
db.sqlite3 db.sqlite3
postgres_data/ postgres_data/
nul nul
*.tsbuildinfo
.mcp.json
.codex

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

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` を利用する。

149
.serena/project.yml Normal file
View File

@@ -0,0 +1,149 @@
# the name by which the project can be referenced within Serena
project_name: "keinasystem_t02"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
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",
"."
]
}
}
}

524
CLAUDE.md
View File

@@ -1,486 +1,128 @@
# Keina System - Claude 向けガイド # Keina System - Claude 向けガイド
> **最終更新**: 2026-02-28 ## プロジェクト概要
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
## 📌 このファイルの目的
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
## ⚠️ Claude への重要な指示
**このファイルは、セッションごとに必ず最初に読んでください。**
さらに、以下のルールを厳守してください:
### 📝 更新義務
**ドキュメントドリブンの徹底**
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
**機能追加・変更時は、必ずこのファイルを更新すること。**
- ✅ 新機能実装時 → 「実装状況」セクションを更新
- ✅ データモデル変更時 → 「データモデル概要」を更新
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
- ✅ 問題解決時 → 「トラブルシューティング」に追加
- ✅ 更新時は必ず「更新履歴」セクションに記録
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
---
## 🎯 プロジェクト概要30秒で理解
**何を作っているか:**
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。 農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
ユーザーは65歳の農家元プログラマー、シングルユーザー、39筆の圃場を管理。
**ユーザー:** **技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
65歳の農家元プログラマー、シングルユーザー、39筆の圃場を管理
**技術スタック:** **開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
- Backend: Django 5.2 + DRF + PostGIS
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
- Database: PostgreSQL 16 + PostGIS 3.4
**開発方針:**
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
--- ---
## 📂 プロジェクト構造 ## 絶対に守るべき制約
1. **Field ↔ OfficialKyosaiField / OfficialChusankanField は M:N** — 決してFK (1:N) に戻さない
2. **年度+圃場の組み合わせは1つの Plan のみ** (`unique_together`)
3. **面積**: 表示=反(tan)、計算・保存=m2、変換: 1反=1000m2
4. **FertilizationEntry.fertilizer は PROTECT** — 使用中の肥料は削除不可
5. **3回同じコードを書くまでは抽象化しない**
6. **ドキュメントドリブン**: 仕様変更時はまず関連ドキュメントから更新する
## コーディング規約
- **Backend**: Django ベストプラクティス、日本語フィールドは `verbose_name` で対応
- **Frontend**: TypeScript strict mode、ESLint に従う
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
---
## プロジェクト構造
``` ```
keinasystem_t02/ keinasystem_t02/
├── CLAUDE.md # このファイルClaude向けガイド ├── CLAUDE.md # このファイル
├── .cursor/ ├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
│ └── rules/ ├── document/ # 設計書・マスタードキュメント
│ └── 30_Cursorガイド.md # Cursor専用ガイド
├── document/ # 詳細設計書(人間向け)
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
│ ├── 01_プロダクトビジョン.md
│ ├── 02_ユーザーストーリー.md
│ ├── 03_データ仕様書.md
│ ├── 04_画面設計書.md
│ └── 05_実装優先順位.md
├── backend/ ├── backend/
│ ├── keinasystem/ # Django設定 │ ├── keinasystem/ # Django設定 (settings.py, urls.py)
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
│ │ └── urls.py # ルートURL設定
│ └── apps/ │ └── apps/
│ ├── fields/ # 圃場管理アプリ │ ├── fields/ # 圃場管理Field, OfficialKyosaiField, OfficialChusankanField
│ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField ├── plans/ # 作付け計画Plan, Crop, Variety
│ ├── views.py # インポート機能、CRUD API ├── weather/ # 気象データWeatherRecord
│ └── urls.py ├── reports/ # 申請書PDF生成
│ ├── plans/ # 作付け計画アプリ │ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
│ ├── models.py # Plan, Crop(+base_temp), Variety ├── workrecords/ # 作業記録索引
│ └── views.py # 作付け計画API、集計API └── mail/ # メールフィルタリングWindmill連携
│ ├── weather/ # 気象データアプリ └── frontend/src/app/
├── models.py # WeatherRecord (1日1行) ├── allocation/ # 作付け計画編集(メイン画面)
├── views.py # sync(APIキー), records, summary, gdd, similarity ├── fields/ # 圃場一覧・詳細
│ ├── urls.py ├── fertilizer/ # 施肥計画・散布実績
│ └── management/commands/fetch_weather.py # 初回一括取得・差分取得 ├── distribution/ # 運搬計画
── reports/ # 申請書生成アプリ ── weather/ # 気象データ
├── views.py # PDF生成API ├── reports/ # 申請書DL
└── templates/ # PDF用HTMLテンプレート ├── import/ # データ取込
└── frontend/ ├── mail/ # メール管理
└── src/app/ └── settings/ # パスワード変更
├── allocation/ # 作付け計画編集画面(メイン)
├── fields/ # 圃場一覧・詳細
├── reports/ # 申請書ダウンロード
├── import/ # データ取込画面
├── mail/
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
│ ├── history/ # メール処理履歴
│ └── rules/ # 送信者ルール管理
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
└── settings/
└── password/ # パスワード変更
``` ```
--- ---
## 🗄️ データモデル概要 ## よくある作業パターン
### コアエンティティ
```
Field (実圃場)
├── 39筆の実際の農地
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
├── group_name, display_order (グループ分け・表示順)
└── ManyToMany関係
├── kyosai_fields (共済マスタ、M:N)
└── chusankan_fields (中山間マスタ、M:N)
OfficialKyosaiField (共済マスタ)
└── 31区画水稲共済細目書用
OfficialChusankanField (中山間マスタ)
├── 71区画中山間地域等直接支払交付金用
└── 17フィールド: c_id, chusankan_flag, oaza, aza, chiban,
branch_num, land_type, area, planting_area,
original_crop, manager, owner, slope,
base_amount, steep_slope_addition, smart_agri_addition,
payment_amount
Plan (作付け計画)
├── field (FK to Field)
├── year (年度)
├── crop (FK to Crop)
├── variety (FK to Variety, nullable)
└── unique_together = ['field', 'year']
Crop (作物マスタ)
├── name米、トウモロコシ、エンドウ、野菜、その他
└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加
Variety (品種マスタ)
├── crop (FK to Crop)
├── name (品種名)
└── unique_together = ['crop', 'name']
MailSender (送信者ルール)
├── email (EmailField, nullable)
├── domain (CharField, nullable)
├── rule ('never_notify' | 'always_notify')
└── ConstraintCheck: email/domain どちらか一方のみ
MailEmail (受信メール記録)
├── account (xserver/gmail/hotmail等)
├── 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']
```
### 重要な設計判断
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本番稼働中
- Windmill フロー: `f/mail/mail_filter`(本番: windmill.keinafarm.net にデプロイ済み、10分間隔スケジュール
- マスタードキュメント: `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/`(肥料マスタ)
- スコープ外(将来): 購入管理、配置計画
### 🚧 既知の課題・技術的負債
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
3. **テスト**: 自動テストが未実装Phase 2で追加予定
4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要
### 🔜 次の実装タスク(優先順)
差異レポートの全タスクA-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2は全件完了。
Phase 2 のタスクに進む段階。
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
### 📅 次のマイルストーンPhase 2
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
- 作業予定のカレンダー表示
- モバイル対応の改善(スマホでの記録入力)
---
## 🛠️ よくある作業パターン
### 新しいモデルを追加する場合 ### 新しいモデルを追加する場合
1. `apps/<app_name>/models.py` にモデルクラスを追加 1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
2. `python manage.py makemigrations` 5. Serializer → 6. ViewSet → 7. URL登録
3. `python manage.py migrate`
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
6. ViewSet 作成 (`apps/<app_name>/views.py`)
7. URL登録 (`apps/<app_name>/urls.py`)
### 新しいAPI エンドポイントを追加する場合 ### 新しいAPI / 画面を追加する場合
1. `apps/<app_name>/views.py` にビューを追加 - API: `views.py``urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
2. `apps/<app_name>/urls.py` にパスを追加 - 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
### 新しい画面を追加する場合
1. `frontend/src/app/<page_name>/page.tsx` を作成
2. 必要に応じてレイアウト調整 (`layout.tsx`)
3. API呼び出しは `useEffect` + `fetch` で実装
4. ローディング状態、エラー状態を適切に処理
--- ---
## 🔍 トラブルシューティング ## デプロイ・トラブルシューティング
### 本番デプロイコマンド(必須)
```bash ```bash
# ⚠️ --env-file .env.production を必ず付けること省略するとSECRET_KEYが空でbackendが起動しない # 本番デプロイgit pull → build → up -d を一括実行
# ⚠️ 本番ファイルは keinasystem ユーザー所有。git pull は sudo -u keinasystem で実行 ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
ssh keinafarm-claude 'sudo -u keinasystem git -C /home/keinasystem/keinasystem_t02 pull origin main && \
cd /home/keinasystem/keinasystem_t02 && \
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production build && \
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production up -d'
```
### 本番確認手順(デプロイ後の必須チェック) # 本番ヘルスチェック9項目、curlベース
**⚠️ Playwrightビジュアルテストを使う前に、必ずcurlで先に確認すること。**
curlはキャッシュの影響を受けず、偽装不可能な確認手段。
```bash
# ステップ1: curlヘルスチェック全9項目、所要約10秒
bash scripts/check_prod.sh claude keina1234 bash scripts/check_prod.sh claude keina1234
# → 全 9 項目 PASS が出れば本番が正常稼働中
# ステップ2任意: Playwrightでビジュアル確認する場合のプロンプト原則 # 本番マイグレーション(バックエンド変更時のみ)
# - 「認証できなければ即中止して報告せよ」を必ず明記
# - 「スクリーンショットには今日の日付が画面内に見えること」を要求
# - 「成功の証跡HTTP レスポンスの実テキスト)を必ず添付すること」を要求
```
**本番バックエンドのマイグレーション適用(バックエンド変更時のみ):**
```bash
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \ ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production build backend && \ sudo -u keinasystem docker compose build backend && \
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production up -d && \ sudo -u keinasystem docker compose up -d && sleep 5 && \
sleep 5 && \ sudo -u keinasystem docker compose exec backend python manage.py migrate'
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production exec backend python manage.py migrate'
``` ```
### マイグレーションエラー - **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
- **CORS**: `settings.py``CORS_ALLOWED_ORIGINS`localhost:3000 許可済み)
```bash - **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
# マイグレーションをリセット(開発環境のみ!)
docker-compose exec backend python manage.py migrate <app_name> zero
docker-compose exec backend python manage.py makemigrations
docker-compose exec backend python manage.py migrate
```
### CORS エラー
- `backend/keinasystem/settings.py``CORS_ALLOWED_ORIGINS` を確認
- 現在は `http://localhost:3000``http://127.0.0.1:3000` を許可
### JWT トークンエラー
- トークンの有効期限を確認(アクセストークン: 24時間
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`
### PDF 生成エラー
- WeasyPrint のインストールを確認
- 日本語フォントの設定を確認HTMLテンプレートのCSS
--- ---
## 📚 詳細情報へのリンク ## マスタードキュメント(機能別リファレンス)
### マスタードキュメント(機能別の網羅的リファレンス) 特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。** | 機能 | ドキュメント |
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が |------|------------|
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。 | 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md` | 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md` | 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md` | 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md` | 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
### 設計ドキュメント(プロジェクト横断) | TODO管理 | `document/19_マスタードキュメント_TODO管理編.md` |
| データモデル全体 | `document/03_データ仕様書.md` |
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
- **データモデル詳細**: `document/03_データ仕様書.md`
- **画面設計**: `document/04_画面設計書.md`
- **実装手順**: `document/00_Gemini向け統合指示書.md`
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
--- ---
## 💡 新しいセッションでの推奨フロー ## セッション開始・終了フロー
### 開始時
1. この `CLAUDE.md` を読む 1. この `CLAUDE.md` を読む
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md` 2. `HANDOVER.md` で前回の引き継ぎを確認する
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照 3. `TASK_CONTEXT.md` で現在の状況を把握する
4. 実装・修正を行う 4. タスク対象の**マスタードキュメント**を読む
5. 重要な設計判断があれば、この `CLAUDE.md` と該当マスタードキュメントを更新
--- ### 終了時(または作業の区切りで必ず実行)
1. `HANDOVER.md` を定型フォーマットで更新する
## 📝 更新履歴 2. 重要な設計判断があれば `CLAUDE.md` と該当マスタードキュメントを更新
3. 実装状況に変化があれば `TASK_CONTEXT.md` を更新
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.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: 初版作成(ハイブリッドアプローチの方針決定)

830
CODEX.md Normal file
View File

@@ -0,0 +1,830 @@
# CODEX 実装指示書: 施肥計画連携・引当機能Phase 1.5
> 作成日: 2026-03-14
> 対象: `keinasystem_t02`
> 設計案: `改善案/在庫管理機能実装案.md`セクション23が対象
> 前提: Phase 1セクション1〜16は実装済み。`apps/materials` が稼働中。
---
## 0. 実装の前提と絶対ルール
### 現在のプロジェクト構造Phase 1 実装済み)
```
keinasystem_t02/
├── backend/
│ ├── keinasystem/
│ │ ├── settings.py # apps.materials 登録済み
│ │ └── urls.py # /api/materials/ 登録済み
│ └── apps/
│ ├── fields/ # 圃場管理Field モデル)
│ ├── plans/ # 作付け計画Crop, Variety モデル)
│ ├── fertilizer/ # 施肥計画Fertilizer, FertilizationPlan, FertilizationEntry 等)
│ │ └── models.py # Fertilizer.material = OneToOneField(Material) 追加済み
│ └── materials/ # 在庫管理Material, FertilizerProfile, PesticideProfile, StockTransaction
│ └── models.py # Phase 1 で作成済み
└── frontend/
└── src/
├── types/index.ts # Material, StockTransaction, StockSummary 定義済み
├── lib/api.ts # axios インスタンス(変更不要)
├── components/
│ └── Navbar.tsx # 在庫管理メニュー追加済み
└── app/
├── fertilizer/ # 施肥計画(既存)← 今回変更対象
│ ├── page.tsx
│ ├── [id]/edit/page.tsx
│ └── _components/FertilizerEditPage.tsx
└── materials/ # 在庫管理Phase 1 で作成済み)← 今回変更対象
├── page.tsx
└── _components/StockOverview.tsx
```
### 技術スタック
- Backend: Django 5.2 + Django REST Framework + PostgreSQL 16
- Frontend: Next.js 14 (App Router) + TypeScript strict + Tailwind CSS
- 認証: SimpleJWTヘッダー `Authorization: Bearer <token>`
- Docker: `docker compose exec backend python manage.py ...`
### 絶対ルール
1. **既存の施肥計画 CRUD作成・編集・削除・PDFを壊さない**
2. **`FertilizationEntry → Fertilizer` の FK は変更しない**
3. **`Fertilizer` モデルは改名・削除しない**
4. **フロントエンドでは `alert()` / `confirm()` を使わない**(インラインバナーで表示)
5. **TypeScript strict mode に従う**
6. **Next.js 14 では `params` は通常のオブジェクト**`use(params)` は使わない)
7. **マイグレーションは段階的に。1つのマイグレーションで複数の大きな変更をしない**
---
## 1. 実装スコープPhase 1.5
### やること
1. `StockTransaction``reserve` タイプ追加
2. `StockTransaction``fertilization_plan` FK 追加(マイグレーション)
3. `FertilizationPlan``is_confirmed` / `confirmed_at` 追加(マイグレーション)
4. 在庫集計 API に `reserved_stock` / `available_stock` 追加
5. 施肥計画の保存時に引当reserveを自動作成
6. 施肥計画の削除時に引当を自動解除
7. 散布確定 API`confirm_spreading`
8. 肥料在庫一覧 API施肥計画画面用
9. フロントエンド: 在庫一覧に引当表示追加
10. フロントエンド: 施肥計画編集に在庫参照追加
11. フロントエンド: 散布確定画面
12. フロントエンド: 施肥計画一覧に確定状態表示追加
### やらないこと
- 公式データ同期FAMIC、農水省
- 別名辞書MaterialAlias
- LLM 調査支援
- 農薬散布計画の在庫連携
---
## 2. バックエンド: モデル変更
### 2.1 StockTransaction の変更 (`backend/apps/materials/models.py`)
**現在のコード**(変更が必要な箇所のみ抜粋):
```python
class StockTransaction(models.Model):
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
INCREASE_TYPES = {
TransactionType.PURCHASE,
TransactionType.ADJUSTMENT_PLUS,
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
```
**変更後**:
```python
class StockTransaction(models.Model):
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
RESERVE = 'reserve', '引当' # ← 追加
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
INCREASE_TYPES = {
TransactionType.PURCHASE,
TransactionType.ADJUSTMENT_PLUS,
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.RESERVE, # ← 追加
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
```
**フィールド追加**(既存フィールドの後に追加):
```python
fertilization_plan = models.ForeignKey(
'fertilizer.FertilizationPlan',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='stock_reservations',
verbose_name='施肥計画',
)
```
### 2.2 FertilizationPlan の変更 (`backend/apps/fertilizer/models.py`)
**フィールド追加**(既存フィールドの後に追加):
```python
is_confirmed = models.BooleanField(
default=False, verbose_name='散布確定済み'
)
confirmed_at = models.DateTimeField(
null=True, blank=True, verbose_name='散布確定日時'
)
```
### 2.3 マイグレーション
#### マイグレーション1: `backend/apps/materials/migrations/0002_stocktransaction_fertilization_plan.py`
```python
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materials', '0001_initial'),
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='stocktransaction',
name='fertilization_plan',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='stock_reservations',
to='fertilizer.fertilizationplan',
verbose_name='施肥計画',
),
),
]
```
注意: `TransactionType` の choices 変更はマイグレーション不要Django は choices をDBレベルで強制しないため
#### マイグレーション2: `backend/apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py`
```python
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='is_confirmed',
field=models.BooleanField(default=False, verbose_name='散布確定済み'),
),
migrations.AddField(
model_name='fertilizationplan',
name='confirmed_at',
field=models.DateTimeField(
blank=True, null=True, verbose_name='散布確定日時'
),
),
]
```
---
## 3. バックエンド: 引当ロジック
### 3.1 引当の作成・解除ヘルパー関数
`backend/apps/materials/stock_service.py` を新規作成:
```python
from django.db import transaction
from .models import StockTransaction
@transaction.atomic
def create_reserves_for_plan(plan):
"""施肥計画の全エントリについて引当トランザクションを作成する。
既存の引当は全削除してから再作成する(差分更新ではなく全置換)。
"""
# 既存の引当を全削除
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type='reserve',
).delete()
# plan が確定済みなら引当を作らないuse が既にある)
if plan.is_confirmed:
return
for entry in plan.entries.select_related('fertilizer__material'):
material = getattr(entry.fertilizer, 'material', None)
if material is None:
# Fertilizer.material が未連携の場合はスキップ
continue
StockTransaction.objects.create(
material=material,
transaction_type='reserve',
quantity=entry.bags,
occurred_on=plan.updated_at.date() if plan.updated_at else plan.created_at.date(),
note=f'施肥計画「{plan.name}」からの引当',
fertilization_plan=plan,
)
@transaction.atomic
def delete_reserves_for_plan(plan):
"""施肥計画に紐づく全引当トランザクションを削除する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type='reserve',
).delete()
@transaction.atomic
def confirm_spreading(plan, actual_entries):
"""散布確定: 引当を削除し、実績数量で use トランザクションを作成する。
actual_entries: list of dict
[{"field_id": int, "fertilizer_id": int, "actual_bags": Decimal}, ...]
actual_bags=0 の行は引当解除のみuse を作成しない)
"""
from apps.fertilizer.models import Fertilizer
from django.utils import timezone
# 既存の引当を全削除
delete_reserves_for_plan(plan)
# 実績 > 0 の行について use トランザクションを作成
today = timezone.now().date()
for entry_data in actual_entries:
actual_bags = entry_data['actual_bags']
if actual_bags <= 0:
continue
try:
fertilizer = Fertilizer.objects.select_related('material').get(
id=entry_data['fertilizer_id']
)
except Fertilizer.DoesNotExist:
continue
material = getattr(fertilizer, 'material', None)
if material is None:
continue
StockTransaction.objects.create(
material=material,
transaction_type='use',
quantity=actual_bags,
occurred_on=today,
note=f'施肥計画「{plan.name}」散布確定',
fertilization_plan=plan,
)
# 計画を確定済みに更新
plan.is_confirmed = True
plan.confirmed_at = timezone.now()
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
```
### 3.2 施肥計画 ViewSet の変更 (`backend/apps/fertilizer/views.py`)
既存の `FertilizationPlanViewSet` に以下の変更を加える。
#### 保存時の引当自動作成
`perform_create``perform_update` をオーバーライドして、保存後に引当を作成する:
```python
from apps.materials.stock_service import (
create_reserves_for_plan,
delete_reserves_for_plan,
confirm_spreading as confirm_spreading_service,
)
class FertilizationPlanViewSet(viewsets.ModelViewSet):
# ... 既存コード ...
def perform_create(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_update(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_destroy(self, instance):
delete_reserves_for_plan(instance)
instance.delete()
```
#### 散布確定アクション
```python
from rest_framework.decorators import action
from decimal import Decimal
class FertilizationPlanViewSet(viewsets.ModelViewSet):
# ... 既存コード ...
@action(detail=True, methods=['post'], url_path='confirm_spreading')
def confirm_spreading(self, request, pk=None):
plan = self.get_object()
if plan.is_confirmed:
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:
actual_entries.append({
'field_id': entry['field_id'],
'fertilizer_id': entry['fertilizer_id'],
'actual_bags': Decimal(str(entry.get('actual_bags', 0))),
})
confirm_spreading_service(plan, actual_entries)
serializer = self.get_serializer(plan)
return Response(serializer.data)
```
### 3.3 施肥計画 Serializer の変更 (`backend/apps/fertilizer/serializers.py`)
`FertilizationPlanSerializer`(読み取り用)に `is_confirmed` / `confirmed_at` を追加:
```python
class FertilizationPlanSerializer(serializers.ModelSerializer):
# ... 既存フィールド ...
is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True)
class Meta:
model = FertilizationPlan
fields = [
# ... 既存フィールド ...,
'is_confirmed', 'confirmed_at',
]
```
---
## 4. バックエンド: 在庫集計 API の変更
### 4.1 StockSummarySerializer の変更 (`backend/apps/materials/serializers.py`)
```python
class StockSummarySerializer(serializers.Serializer):
material_id = serializers.IntegerField()
name = serializers.CharField()
material_type = serializers.CharField()
material_type_display = serializers.CharField()
maker = serializers.CharField()
stock_unit = serializers.CharField()
stock_unit_display = serializers.CharField()
is_active = serializers.BooleanField()
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3) # ← 追加
available_stock = serializers.DecimalField(max_digits=10, decimal_places=3) # ← 追加
last_transaction_date = serializers.DateField(allow_null=True)
```
### 4.2 StockSummaryView の変更 (`backend/apps/materials/views.py`)
在庫集計のループ内で `reserved_stock``available_stock` を計算する:
```python
for material in queryset:
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity for txn in transactions
if txn.transaction_type == 'reserve'
)
last_date = max((txn.occurred_on for txn in transactions), default=None)
current = increase - decrease # 引当込みの在庫(引当分は既に引かれている)
results.append({
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': current + reserved, # 引当を戻した「物理的な在庫」
'reserved_stock': reserved, # 引当中の数量
'available_stock': current, # 利用可能在庫(引当済み分を除く)
'last_transaction_date': last_date,
})
```
**在庫計算の定義**:
- `current_stock`: 物理的に倉庫にある数量(入庫 - 使用 - 廃棄 ± 調整)
- `reserved_stock`: そのうち施肥計画で引き当てられている数量
- `available_stock`: 新しい計画に使える数量(= current_stock - reserved_stock
### 4.3 肥料在庫 API施肥計画画面用
`backend/apps/materials/views.py` に追加:
```python
class FertilizerStockView(generics.ListAPIView):
"""施肥計画画面用: 肥料の在庫情報を返す"""
permission_classes = [IsAuthenticated]
serializer_class = StockSummarySerializer
def get_queryset(self):
return None
def list(self, request, *args, **kwargs):
queryset = Material.objects.filter(
material_type='fertilizer',
is_active=True,
).prefetch_related('stock_transactions')
results = []
for material in queryset:
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity for txn in transactions
if txn.transaction_type == 'reserve'
)
current = increase - decrease
results.append({
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': current + reserved,
'reserved_stock': reserved,
'available_stock': current,
'last_transaction_date': max(
(t.occurred_on for t in transactions), default=None
),
})
serializer = StockSummarySerializer(results, many=True)
return Response(serializer.data)
```
`backend/apps/materials/urls.py` に追加:
```python
urlpatterns = [
path('', include(router.urls)),
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'), # ← 追加
]
```
---
## 5. フロントエンド: 型定義の変更
### 5.1 StockTransaction 型に `reserve` 追加 (`frontend/src/types/index.ts`)
**変更前**:
```typescript
transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
```
**変更後**:
```typescript
transaction_type: 'purchase' | 'use' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
```
### 5.2 StockSummary 型に引当フィールド追加
**変更前**:
```typescript
export interface StockSummary {
material_id: number;
name: string;
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
material_type_display: string;
maker: string;
stock_unit: string;
stock_unit_display: string;
is_active: boolean;
current_stock: string;
last_transaction_date: string | null;
}
```
**変更後**:
```typescript
export interface StockSummary {
material_id: number;
name: string;
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
material_type_display: string;
maker: string;
stock_unit: string;
stock_unit_display: string;
is_active: boolean;
current_stock: string;
reserved_stock: string; // ← 追加
available_stock: string; // ← 追加
last_transaction_date: string | null;
}
```
### 5.3 FertilizationPlan 型に確定フィールド追加
既存の `FertilizationPlan` インターフェースに追加:
```typescript
export interface FertilizationPlan {
// ... 既存フィールド ...
is_confirmed: boolean; // ← 追加
confirmed_at: string | null; // ← 追加
}
```
---
## 6. フロントエンド: 画面変更
### 6.1 在庫一覧の引当表示 (`frontend/src/app/materials/_components/StockOverview.tsx`)
現在庫の表示を変更:
**変更前**:
```
現在庫: 18
```
**変更後**:
```
在庫 18袋引当 12袋/ 利用可能 6袋
```
引当が0の場合は引当表示を省略する。
### 6.2 施肥計画編集画面の在庫参照 (`frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx`)
施肥計画の編集画面(マトリクス表)で、肥料列ヘッダーに在庫情報を表示する。
**追加表示**(肥料名の下に小さく):
```
仁井田米有機
在庫 18袋 / 計画計 24袋
```
計画合計が在庫を超える場合は赤文字で「不足 6袋」を表示する。
**データ取得**: ページ読み込み時に `GET /api/materials/fertilizer-stock/` を呼び、
`Fertilizer.material` の OneToOne 経由で material_id と紐づける。
紐づけロジック:
1. `GET /api/fertilizer/fertilizers/` で肥料一覧を取得(既存)
2. `GET /api/materials/materials/?material_type=fertilizer` で Material 一覧を取得
3. `Fertilizer.name``Material.name` を突き合わせる(同名で作成されているため一致する)
または、Fertilizer の serializer に `material_id` を追加して直接紐づける(推奨)。
**Fertilizer serializer への追加**`backend/apps/fertilizer/serializers.py`:
```python
class FertilizerSerializer(serializers.ModelSerializer):
material_id = serializers.IntegerField(source='material.id', read_only=True, default=None)
class Meta:
model = Fertilizer
fields = [
# ... 既存フィールド ...,
'material_id',
]
```
### 6.3 施肥計画一覧の確定状態表示 (`frontend/src/app/fertilizer/page.tsx`)
各計画行に確定状態を表示:
- 未確定: 通常表示 + 「散布確定」ボタン
- 確定済み: 背景色変更(例: 薄い青)+ 「確定済み ✓」バッジ + 確定日時
### 6.4 散布確定画面
**実装方法**: モーダルまたは専用ページ。施肥計画一覧の「散布確定」ボタンから起動。
**画面構成**:
```
┌─ 散布確定: 「計画名」──────────────────────────────┐
│ │
│ 肥料: 仁井田米有機 │
│ ┌─────────────┬──────┬──────────┐ │
│ │ 圃場 │ 計画 │ 実績 │ │
│ ├─────────────┼──────┼──────────┤ │
│ │ 上の田 │ 3袋 │ [ 3 ] │ │
│ │ 下の田 │ 4袋 │ [ 3.5 ] │ │
│ │ 山の畑 │ 2袋 │ [ 0 ] │ │
│ └─────────────┴──────┴──────────┘ │
│ │
│ 肥料: 土佐勤農党 │
│ ┌─────────────┬──────┬──────────┐ │
│ │ 圃場 │ 計画 │ 実績 │ │
│ ├─────────────┼──────┼──────────┤ │
│ │ ... │ ... │ [ ... ] │ │
│ └─────────────┴──────┴──────────┘ │
│ │
│ [キャンセル] [一括確定] │
└─────────────────────────────────────────────────────┘
```
**動作**:
1. 施肥計画のエントリを肥料ごとにグループ化して表示
2. 「実績」列は計画値がプリセットされた数値入力欄
3. 修正が必要な行だけ数値を変更する
4. 実績を0にした行は「未散布」として引当解除される
5. 「一括確定」で `POST /api/fertilizer/plans/{id}/confirm_spreading/` を呼ぶ
**API リクエスト**:
```json
{
"entries": [
{"field_id": 1, "fertilizer_id": 3, "actual_bags": 3.0},
{"field_id": 2, "fertilizer_id": 3, "actual_bags": 3.5},
{"field_id": 3, "fertilizer_id": 3, "actual_bags": 0}
]
}
```
---
## 7. API エンドポイント一覧Phase 1.5 で追加・変更)
### 新規
| メソッド | パス | 認証 | 説明 |
|----------|------|------|------|
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | JWT | 散布確定reserve→use変換 |
| GET | `/api/materials/fertilizer-stock/` | JWT | 肥料在庫一覧(施肥計画画面用) |
### 変更
| メソッド | パス | 変更内容 |
|----------|------|----------|
| POST/PUT | `/api/fertilizer/plans/` | 保存後に reserve 自動作成 |
| DELETE | `/api/fertilizer/plans/{id}/` | 削除前に reserve 自動削除 |
| GET | `/api/fertilizer/plans/` | レスポンスに `is_confirmed`, `confirmed_at` 追加 |
| GET | `/api/fertilizer/fertilizers/` | レスポンスに `material_id` 追加 |
| GET | `/api/materials/stock-summary/` | レスポンスに `reserved_stock`, `available_stock` 追加 |
---
## 8. 実装順序(厳守)
### Step 1: バックエンド — モデル・マイグレーション
1. `apps/materials/models.py``reserve` タイプ追加、`DECREASE_TYPES` 更新、`fertilization_plan` FK 追加
2. `apps/fertilizer/models.py``is_confirmed`, `confirmed_at` 追加
3. `apps/materials/migrations/0002_stocktransaction_fertilization_plan.py` 作成
4. `apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py` 作成
### Step 2: バックエンド — ロジック・API
5. `apps/materials/stock_service.py` 作成(引当作成・解除・散布確定ヘルパー)
6. `apps/fertilizer/views.py``FertilizationPlanViewSet``perform_create`, `perform_update`, `perform_destroy` オーバーライド追加
7. `apps/fertilizer/views.py``confirm_spreading` アクション追加
8. `apps/fertilizer/serializers.py``is_confirmed`, `confirmed_at` 追加
9. `apps/fertilizer/serializers.py``FertilizerSerializer``material_id` 追加
10. `apps/materials/serializers.py``StockSummarySerializer``reserved_stock`, `available_stock` 追加
11. `apps/materials/views.py``StockSummaryView` で引当集計を追加
12. `apps/materials/views.py``FertilizerStockView` 追加
13. `apps/materials/urls.py``fertilizer-stock/` パス追加
### Step 3: フロントエンド
14. `types/index.ts``reserve` タイプ追加、`StockSummary` に引当フィールド追加、`FertilizationPlan` に確定フィールド追加
15. `app/materials/_components/StockOverview.tsx` に引当表示追加
16. `app/materials/page.tsx``StockTransactionForm``reserve` オプション追加(手動引当は不要なら省略可)
17. `app/fertilizer/_components/FertilizerEditPage.tsx` に在庫参照表示追加
18. `app/fertilizer/page.tsx` に確定状態表示・散布確定ボタン追加
19. `app/fertilizer/_components/ConfirmSpreadingModal.tsx` 新規作成(散布確定モーダル)
---
## 9. テスト確認項目
### バックエンド
- [ ] マイグレーション適用成功materials 0002, fertilizer 0006
- [ ] 施肥計画を保存すると、各エントリに対応する reserve トランザクションが作成される
- [ ] 施肥計画を更新すると、古い reserve が削除され新しい reserve が作成される
- [ ] 施肥計画を削除すると、reserve が全て削除される
- [ ] `GET /api/materials/stock-summary/``reserved_stock``available_stock` が返る
- [ ] 入庫10 → 引当3 → `current_stock=10`, `reserved_stock=3`, `available_stock=7`
- [ ] `POST /api/fertilizer/plans/{id}/confirm_spreading/` で reserve が use に変換される
- [ ] 確定済み計画に再度 confirm_spreading すると 400 エラー
- [ ] actual_bags=0 の行は reserve 削除のみuse は作成しない)
- [ ] `Fertilizer.material` が null の Fertilizer は引当をスキップする
- [ ] 既存の施肥計画 CRUD作成・編集・削除・PDFが壊れていない
### フロントエンド
- [ ] 在庫一覧に引当数量と利用可能在庫が表示される
- [ ] 施肥計画編集画面に肥料ごとの在庫情報が表示される
- [ ] 施肥計画一覧に確定状態(未確定/確定済み)が表示される
- [ ] 散布確定モーダルが開き、計画値がプリセットされる
- [ ] 実績を修正して一括確定できる
- [ ] 確定後、計画が「確定済み」表示に変わる
- [ ] 確定済みの計画には「散布確定」ボタンが表示されない
---
## 10. 既存コードへの変更一覧(影響範囲)
| ファイル | 変更内容 |
|----------|----------|
| `backend/apps/materials/models.py` | `StockTransaction``reserve` タイプ・`fertilization_plan` FK 追加 |
| `backend/apps/materials/serializers.py` | `StockSummarySerializer``reserved_stock``available_stock` 追加 |
| `backend/apps/materials/views.py` | `StockSummaryView` 集計変更、`FertilizerStockView` 追加 |
| `backend/apps/materials/urls.py` | `fertilizer-stock/` パス追加 |
| `backend/apps/materials/stock_service.py` | **新規作成** — 引当ロジック |
| `backend/apps/materials/migrations/0002_...py` | **新規作成** — fertilization_plan FK |
| `backend/apps/fertilizer/models.py` | `FertilizationPlan``is_confirmed``confirmed_at` 追加 |
| `backend/apps/fertilizer/views.py` | `perform_create/update/destroy` オーバーライド、`confirm_spreading` アクション追加 |
| `backend/apps/fertilizer/serializers.py` | `is_confirmed``confirmed_at``material_id` 追加 |
| `backend/apps/fertilizer/migrations/0006_...py` | **新規作成** — is_confirmed, confirmed_at |
| `frontend/src/types/index.ts` | `reserve` タイプ追加、引当フィールド追加、確定フィールド追加 |
| `frontend/src/app/materials/_components/StockOverview.tsx` | 引当表示追加 |
| `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx` | 在庫参照表示追加 |
| `frontend/src/app/fertilizer/page.tsx` | 確定状態表示・散布確定ボタン追加 |
| `frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx` | **新規作成** — 散布確定モーダル |
---
## 11. 参照すべき既存コード(実装パターンの手本)
| 目的 | 参照先 |
|------|--------|
| 施肥計画 ViewSetperform_create の追加先) | `backend/apps/fertilizer/views.py` |
| 施肥計画 Serializerフィールド追加先 | `backend/apps/fertilizer/serializers.py` |
| 施肥計画の @action パターンPDF アクション) | `backend/apps/fertilizer/views.py``pdf` アクション |
| 在庫集計ロジック | `backend/apps/materials/views.py``StockSummaryView` |
| 施肥計画編集画面(マトリクス表) | `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx` |
| 施肥計画一覧画面 | `frontend/src/app/fertilizer/page.tsx` |
| モーダルパターン | `frontend/src/app/materials/_components/StockTransactionForm.tsx` |
| 在庫一覧コンポーネント | `frontend/src/app/materials/_components/StockOverview.tsx` |

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` を参照。

BIN
all_fertilizer.zip Normal file

Binary file not shown.

52135
all_fertilizer/1.全件.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,9 @@
from django.contrib import admin from django.contrib import admin
from .models import Fertilizer, FertilizationPlan, FertilizationEntry from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
SpreadingSession, SpreadingSessionItem,
)
@admin.register(Fertilizer) @admin.register(Fertilizer)
@@ -14,6 +18,58 @@ class FertilizationEntryInline(admin.TabularInline):
@admin.register(FertilizationPlan) @admin.register(FertilizationPlan)
class FertilizationPlanAdmin(admin.ModelAdmin): class FertilizationPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'variety'] list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
list_filter = ['year'] list_filter = ['year']
inlines = [FertilizationEntryInline] inlines = [FertilizationEntryInline]
class DeliveryGroupFieldInline(admin.TabularInline):
model = DeliveryGroupField
extra = 0
readonly_fields = ['delivery_plan']
class DeliveryGroupInline(admin.TabularInline):
model = DeliveryGroup
extra = 0
class DeliveryTripItemInline(admin.TabularInline):
model = DeliveryTripItem
extra = 0
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,60 @@
# Generated by Django 5.0 on 2026-03-01 15:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0002_alter_fertilizationentry_fertilizer'),
('fields', '0006_e1c_chusankan_17_fields'),
]
operations = [
migrations.CreateModel(
name='DistributionPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='計画名')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('fertilization_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='distribution_plans', to='fertilizer.fertilizationplan', verbose_name='施肥計画')),
],
options={
'verbose_name': '分配計画',
'verbose_name_plural': '分配計画',
'ordering': ['-fertilization_plan__year', 'name'],
},
),
migrations.CreateModel(
name='DistributionGroup',
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='表示順')),
('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.distributionplan', verbose_name='分配計画')),
],
options={
'verbose_name': '分配グループ',
'verbose_name_plural': '分配グループ',
'ordering': ['order', 'id'],
'unique_together': {('distribution_plan', 'name')},
},
),
migrations.CreateModel(
name='DistributionGroupField',
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.distributiongroup', verbose_name='グループ')),
('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.distributionplan', verbose_name='分配計画')),
],
options={
'verbose_name': 'グループ圃場割り当て',
'verbose_name_plural': 'グループ圃場割り当て',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('distribution_plan', 'field')},
},
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0003_distributionplan_distributiongroup_and_more'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='calc_settings',
field=models.JSONField(blank=True, default=list, verbose_name='計算設定'),
),
]

View File

@@ -0,0 +1,56 @@
import django.db.models.deletion
from django.db import migrations, models
def create_materials_for_existing_fertilizers(apps, schema_editor):
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
Material = apps.get_model('materials', 'Material')
FertilizerProfile = apps.get_model('materials', 'FertilizerProfile')
for fertilizer in Fertilizer.objects.all():
material = Material.objects.create(
name=fertilizer.name,
material_type='fertilizer',
maker=fertilizer.maker or '',
stock_unit='bag',
is_active=True,
notes=fertilizer.notes or '',
)
FertilizerProfile.objects.create(
material=material,
capacity_kg=fertilizer.capacity_kg,
nitrogen_pct=fertilizer.nitrogen_pct,
phosphorus_pct=fertilizer.phosphorus_pct,
potassium_pct=fertilizer.potassium_pct,
)
fertilizer.material = material
fertilizer.save(update_fields=['material'])
def reverse_migration(apps, schema_editor):
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
Fertilizer.objects.all().update(material=None)
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0004_fertilizationplan_calc_settings'),
('materials', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='fertilizer',
name='material',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='legacy_fertilizer',
to='materials.material',
verbose_name='資材マスタ',
),
),
migrations.RunPython(create_materials_for_existing_fertilizers, reverse_migration),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='is_confirmed',
field=models.BooleanField(default=False, verbose_name='散布確定済み'),
),
migrations.AddField(
model_name='fertilizationplan',
name='confirmed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='散布確定日時'),
),
]

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

@@ -17,6 +17,14 @@ class Fertilizer(models.Model):
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)' max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
) )
notes = models.TextField(blank=True, null=True, verbose_name='備考') notes = models.TextField(blank=True, null=True, verbose_name='備考')
material = models.OneToOneField(
'materials.Material',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='legacy_fertilizer',
verbose_name='資材マスタ',
)
class Meta: class Meta:
verbose_name = '肥料マスタ' verbose_name = '肥料マスタ'
@@ -34,6 +42,9 @@ class FertilizationPlan(models.Model):
'plans.Variety', on_delete=models.PROTECT, 'plans.Variety', on_delete=models.PROTECT,
related_name='fertilization_plans', verbose_name='品種' related_name='fertilization_plans', verbose_name='品種'
) )
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み')
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -58,6 +69,13 @@ class FertilizationEntry(models.Model):
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料' Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
) )
bags = models.DecimalField(max_digits=8, decimal_places=2, 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: class Meta:
verbose_name = '施肥エントリ' verbose_name = '施肥エントリ'
@@ -67,3 +85,164 @@ class FertilizationEntry(models.Model):
def __str__(self): def __str__(self):
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}" return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}"
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 = ['-year', 'name']
def __str__(self):
return f"{self.year} {self.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 = [['delivery_plan', 'name']]
ordering = ['order', 'id']
def __str__(self):
return f"{self.delivery_plan} / {self.name}"
class DeliveryGroupField(models.Model):
"""圃場のグループへの割り当て1圃場=1グループ/1運搬計画"""
delivery_plan = models.ForeignKey(
DeliveryPlan, on_delete=models.CASCADE, verbose_name='運搬計画'
)
group = models.ForeignKey(
DeliveryGroup, on_delete=models.CASCADE,
related_name='field_assignments', verbose_name='グループ'
)
field = models.ForeignKey(
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
)
class Meta:
verbose_name = 'グループ圃場割り当て'
verbose_name_plural = 'グループ圃場割り当て'
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,11 +1,44 @@
from decimal import Decimal
from django.db.models import Sum
from rest_framework import serializers from rest_framework import serializers
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
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): class FertilizerSerializer(serializers.ModelSerializer):
material_id = serializers.SerializerMethodField()
class Meta: class Meta:
model = Fertilizer model = Fertilizer
fields = '__all__' fields = [
'id',
'name',
'maker',
'capacity_kg',
'nitrogen_pct',
'phosphorus_pct',
'potassium_pct',
'notes',
'material',
'material_id',
]
def get_material_id(self, obj):
return obj.material_id
class FertilizationEntrySerializer(serializers.ModelSerializer): class FertilizationEntrySerializer(serializers.ModelSerializer):
@@ -17,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = FertilizationEntry 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): class FertilizationPlanSerializer(serializers.ModelSerializer):
@@ -26,12 +68,36 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
entries = FertilizationEntrySerializer(many=True, read_only=True) entries = FertilizationEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField() field_count = serializers.SerializerMethodField()
fertilizer_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: class Meta:
model = FertilizationPlan model = FertilizationPlan
fields = [ fields = [
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name', 'id',
'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at' '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): def get_variety_name(self, obj):
@@ -46,19 +112,46 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
def get_fertilizer_count(self, obj): def get_fertilizer_count(self, obj):
return obj.entries.values('fertilizer').distinct().count() 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): class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
"""保存用entries を一括で受け取る)"""
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False) entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta: class Meta:
model = FertilizationPlan model = FertilizationPlan
fields = ['id', 'name', 'year', 'variety', 'entries'] fields = ['id', 'name', 'year', 'variety', 'calc_settings', 'entries']
def create(self, validated_data): def create(self, validated_data):
entries_data = validated_data.pop('entries', []) entries_data = validated_data.pop('entries', [])
plan = FertilizationPlan.objects.create(**validated_data) 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 return plan
def update(self, instance, validated_data): def update(self, instance, validated_data):
@@ -68,14 +161,332 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
instance.save() instance.save()
if entries_data is not None: if entries_data is not None:
instance.entries.all().delete() 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 return instance
def _save_entries(self, plan, entries_data): def _save_entries(self, plan, entries_data):
pairs = set()
for entry in entries_data: for entry in entries_data:
pairs.add((entry['field_id'], entry['fertilizer_id']))
FertilizationEntry.objects.create( FertilizationEntry.objects.create(
plan=plan, plan=plan,
field_id=entry['field_id'], field_id=entry['field_id'],
fertilizer_id=entry['fertilizer_id'], fertilizer_id=entry['fertilizer_id'],
bags=entry['bags'], bags=entry['bags'],
) )
return pairs
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source='field.id', read_only=True)
name = serializers.CharField(source='field.name', read_only=True)
area_tan = serializers.DecimalField(
source='field.area_tan', max_digits=6, decimal_places=4, read_only=True
)
class Meta:
model = DeliveryGroupField
fields = ['id', 'name', 'area_tan']
class DeliveryGroupReadSerializer(serializers.ModelSerializer):
fields = DeliveryGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class Meta:
model = DeliveryGroup
fields = ['id', 'name', 'order', 'fields']
class DeliveryTripItemSerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
spread_bags = serializers.SerializerMethodField()
remaining_bags = serializers.SerializerMethodField()
class Meta:
model = DeliveryTripItem
fields = [
'id',
'field',
'field_name',
'fertilizer',
'fertilizer_name',
'bags',
'spread_bags',
'remaining_bags',
]
def get_spread_bags(self, obj):
total = (
SpreadingSessionItem.objects.filter(
session__year=obj.trip.delivery_plan.year,
field_id=obj.field_id,
fertilizer_id=obj.fertilizer_id,
).aggregate(total=Sum('actual_bags'))['total']
)
return str(total or Decimal('0'))
def get_remaining_bags(self, obj):
total = (
SpreadingSessionItem.objects.filter(
session__year=obj.trip.delivery_plan.year,
field_id=obj.field_id,
fertilizer_id=obj.fertilizer_id,
).aggregate(total=Sum('actual_bags'))['total']
)
spread_total = total or Decimal('0')
return str(obj.bags - spread_total)
class DeliveryTripReadSerializer(serializers.ModelSerializer):
items = DeliveryTripItemSerializer(many=True, read_only=True)
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
class Meta:
model = DeliveryTrip
fields = ['id', 'order', 'name', 'date', '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_trip_count(self, obj):
return obj.trips.count()
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 = 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 = 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]
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 = DeliveryPlan
fields = ['id', 'year', 'name', 'groups', 'trips']
def create(self, validated_data):
groups_data = validated_data.pop('groups', [])
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 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 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,74 @@
<!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; }
</style>
</head>
<body>
<h1>分配計画書</h1>
<p class="subtitle">
{{ fert_plan.year }}年度 {{ fert_plan.variety.crop.name }} / {{ fert_plan.variety.name }}
/施肥計画「{{ fert_plan.name }}」
/分配計画「{{ dist_plan.name }}」
</p>
<table>
<thead>
<tr>
<th class="col-name">グループ / 圃場</th>
{% for fert in fertilizers %}
<th>{{ fert.name }}<br><small>(袋)</small></th>
{% endfor %}
<th>合計袋数</th>
</tr>
</thead>
<tbody>
{% for group in 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 }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
<td>{{ group.row_total }}</td>
</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 }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
<td>{{ row.total }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td class="col-name">合計</td>
{% for total in fert_totals %}
<td>{{ total }}</td>
{% endfor %}
<td>{{ grand_total }}</td>
</tr>
</tfoot>
</table>
</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,9 +5,12 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer') router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan') router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)),
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'), path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'), 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,5 +1,6 @@
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.db.models import Sum
from django.http import HttpResponse from django.http import HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from rest_framework import viewsets, status from rest_framework import viewsets, status
@@ -10,12 +11,31 @@ from rest_framework.views import APIView
from weasyprint import HTML from weasyprint import HTML
from apps.fields.models import Field from apps.fields.models import Field
from apps.plans.models import Plan, Variety from apps.materials.stock_service import (
from .models import Fertilizer, FertilizationPlan create_reserves_for_plan,
delete_reserves_for_plan,
)
from apps.plans.models import Plan
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
SpreadingSession, SpreadingSessionItem,
)
from .serializers import ( from .serializers import (
FertilizerSerializer, FertilizerSerializer,
FertilizationPlanSerializer, FertilizationPlanSerializer,
FertilizationPlanWriteSerializer, FertilizationPlanWriteSerializer,
DeliveryPlanListSerializer,
DeliveryPlanReadSerializer,
DeliveryPlanWriteSerializer,
SpreadingSessionSerializer,
SpreadingSessionWriteSerializer,
)
from .services import (
FertilizationPlanMergeConflict,
FertilizationPlanMergeError,
merge_fertilization_plan_into,
sync_actual_bags_for_pairs,
) )
@@ -30,7 +50,7 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related( qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
'entries', 'entries__field', 'entries__fertilizer' 'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
) )
year = self.request.query_params.get('year') year = self.request.query_params.get('year')
if year: if year:
@@ -42,6 +62,18 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
return FertilizationPlanWriteSerializer return FertilizationPlanWriteSerializer
return FertilizationPlanSerializer return FertilizationPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_update(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_destroy(self, instance):
delete_reserves_for_plan(instance)
instance.delete()
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def pdf(self, request, pk=None): def pdf(self, request, pk=None):
plan = self.get_object() plan = self.get_object()
@@ -96,6 +128,54 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"' response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
return response return response
@action(detail=True, methods=['get'])
def merge_targets(self, request, pk=None):
source_plan = self.get_object()
targets = (
FertilizationPlan.objects
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
.exclude(id=source_plan.id)
.prefetch_related('entries')
.order_by('-updated_at', 'id')
)
data = [
{
'id': plan.id,
'name': plan.name,
'field_count': plan.entries.values('field').distinct().count(),
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
'is_confirmed': plan.is_confirmed,
}
for plan in targets
]
return Response(data)
@action(detail=True, methods=['post'])
def merge_into(self, request, pk=None):
source_plan = self.get_object()
target_plan_id = request.data.get('target_plan_id')
if not target_plan_id:
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
try:
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
except FertilizationPlan.DoesNotExist:
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
try:
result = merge_fertilization_plan_into(source_plan, target_plan)
except FertilizationPlanMergeConflict as exc:
return Response(
{
'error': '競合する圃場・肥料があるためマージできません。',
'conflicts': exc.conflicts,
},
status=status.HTTP_409_CONFLICT,
)
except FertilizationPlanMergeError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(result)
class CandidateFieldsView(APIView): class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す""" """作付け計画から圃場候補を返す"""
@@ -194,3 +274,375 @@ class CalculateView(APIView):
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
return Response(results) return Response(results)
class DeliveryPlanViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = DeliveryPlan.objects.prefetch_related(
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
)
year = self.request.query_params.get('year')
if year:
qs = qs.filter(year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return DeliveryPlanWriteSerializer
if self.action == 'list':
return DeliveryPlanListSerializer
return DeliveryPlanReadSerializer
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
plan = self.get_object()
# 全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
)
# グループ情報: 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
# 回ごとにページを構築
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))
]
trip_pages.append({
'trip': trip,
'fertilizers': trip_fertilizers,
'group_rows': group_rows,
'fert_totals': fert_totals,
})
context = {
'plan': plan,
'trip_pages': trip_pages,
}
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="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,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mail', '0004_rename_infoseek_to_gmail_service'),
]
operations = [
migrations.AlterField(
model_name='mailemail',
name='account',
field=models.CharField(
choices=[
('gmail', 'Gmail'),
('gmail_service', 'Gmail (サービス用)'),
('hotmail', 'Hotmail'),
('xserver1', 'Xserver (akira@keinafarm.com)'),
('xserver2', 'Xserver (service@keinafarm.com)'),
('xserver3', 'Xserver (midori@keinafarm.com)'),
('xserver4', 'Xserver (kouseiren@keinafarm.com)'),
('xserver5', 'Xserver (post@keinafarm.com)'),
('xserver6', 'Xserver (sales@keinafarm.com)'),
('xserver', 'Xserver (legacy)'),
],
max_length=20,
verbose_name='アカウント',
),
),
]

View File

@@ -42,10 +42,16 @@ class MailSender(models.Model):
ACCOUNT_CHOICES = [ ACCOUNT_CHOICES = [
('xserver', 'Xserver'),
('gmail', 'Gmail'), ('gmail', 'Gmail'),
('hotmail', 'Hotmail'),
('gmail_service', 'Gmail (サービス用)'), ('gmail_service', 'Gmail (サービス用)'),
('hotmail', 'Hotmail'),
('xserver1', 'Xserver (akira@keinafarm.com)'),
('xserver2', 'Xserver (service@keinafarm.com)'),
('xserver3', 'Xserver (midori@keinafarm.com)'),
('xserver4', 'Xserver (kouseiren@keinafarm.com)'),
('xserver5', 'Xserver (post@keinafarm.com)'),
('xserver6', 'Xserver (sales@keinafarm.com)'),
('xserver', 'Xserver (legacy)'),
] ]
FEEDBACK_CHOICES = [ FEEDBACK_CHOICES = [
@@ -105,3 +111,4 @@ class MailNotificationToken(models.Model):
def __str__(self): def __str__(self):
return str(self.token) return str(self.token)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,28 @@
from django.contrib import admin
from .models import FertilizerProfile, Material, PesticideProfile, StockTransaction
class FertilizerProfileInline(admin.StackedInline):
model = FertilizerProfile
extra = 0
class PesticideProfileInline(admin.StackedInline):
model = PesticideProfile
extra = 0
@admin.register(Material)
class MaterialAdmin(admin.ModelAdmin):
list_display = ['name', 'material_type', 'maker', 'stock_unit', 'is_active']
list_filter = ['material_type', 'is_active']
search_fields = ['name', 'maker']
inlines = [FertilizerProfileInline, PesticideProfileInline]
@admin.register(StockTransaction)
class StockTransactionAdmin(admin.ModelAdmin):
list_display = ['material', 'transaction_type', 'quantity', 'occurred_on']
list_filter = ['transaction_type', 'occurred_on']
search_fields = ['material__name']

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class MaterialsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.materials'
verbose_name = '資材管理'

View File

@@ -0,0 +1,87 @@
import decimal
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Material',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='資材名')),
('material_type', models.CharField(choices=[('fertilizer', '肥料'), ('pesticide', '農薬'), ('seedling', '種苗'), ('other', 'その他')], max_length=20, verbose_name='資材種別')),
('maker', models.CharField(blank=True, default='', max_length=100, verbose_name='メーカー')),
('stock_unit', models.CharField(choices=[('bag', ''), ('bottle', ''), ('kg', 'kg'), ('liter', 'L'), ('piece', '')], default='bag', max_length=20, verbose_name='在庫単位')),
('is_active', models.BooleanField(default=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)),
],
options={
'verbose_name': '資材',
'verbose_name_plural': '資材',
'ordering': ['material_type', 'name'],
},
),
migrations.CreateModel(
name='FertilizerProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('capacity_kg', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True, verbose_name='1袋重量(kg)')),
('nitrogen_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='窒素(%)')),
('phosphorus_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='リン酸(%)')),
('potassium_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='カリ(%)')),
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fertilizer_profile', to='materials.material')),
],
options={
'verbose_name': '肥料詳細',
'verbose_name_plural': '肥料詳細',
},
),
migrations.CreateModel(
name='PesticideProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('registration_no', models.CharField(blank=True, default='', max_length=100, verbose_name='農薬登録番号')),
('formulation', models.CharField(blank=True, default='', max_length=100, verbose_name='剤型')),
('usage_unit', models.CharField(blank=True, default='', max_length=50, verbose_name='使用単位')),
('dilution_ratio', models.CharField(blank=True, default='', max_length=100, verbose_name='希釈倍率')),
('active_ingredient', models.CharField(blank=True, default='', max_length=200, verbose_name='有効成分')),
('category', models.CharField(blank=True, default='', max_length=100, verbose_name='分類')),
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pesticide_profile', to='materials.material')),
],
options={
'verbose_name': '農薬詳細',
'verbose_name_plural': '農薬詳細',
},
),
migrations.CreateModel(
name='StockTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(decimal.Decimal('0.001'))], verbose_name='数量')),
('occurred_on', models.DateField(verbose_name='発生日')),
('note', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stock_transactions', to='materials.material', verbose_name='資材')),
],
options={
'verbose_name': '入出庫履歴',
'verbose_name_plural': '入出庫履歴',
'ordering': ['-occurred_on', '-created_at', '-id'],
},
),
migrations.AddConstraint(
model_name='material',
constraint=models.UniqueConstraint(fields=('material_type', 'name'), name='uniq_material_type_name'),
),
]

View File

@@ -0,0 +1,25 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materials', '0001_initial'),
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='stocktransaction',
name='fertilization_plan',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='stock_reservations',
to='fertilizer.fertilizationplan',
verbose_name='施肥計画',
),
),
]

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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,229 @@
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
class Material(models.Model):
"""共通資材マスタ"""
class MaterialType(models.TextChoices):
FERTILIZER = 'fertilizer', '肥料'
PESTICIDE = 'pesticide', '農薬'
SEED = 'seed', '種子'
SEEDLING = 'seedling', '種苗'
OTHER = 'other', 'その他'
class StockUnit(models.TextChoices):
BAG = 'bag', ''
BOTTLE = 'bottle', ''
KG = 'kg', 'kg'
LITER = 'liter', 'L'
PIECE = 'piece', ''
name = models.CharField(max_length=100, verbose_name='資材名')
material_type = models.CharField(
max_length=20,
choices=MaterialType.choices,
verbose_name='資材種別',
)
maker = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='メーカー',
)
stock_unit = models.CharField(
max_length=20,
choices=StockUnit.choices,
default=StockUnit.BAG,
verbose_name='在庫単位',
)
is_active = models.BooleanField(default=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:
ordering = ['material_type', 'name']
constraints = [
models.UniqueConstraint(
fields=['material_type', 'name'],
name='uniq_material_type_name',
),
]
verbose_name = '資材'
verbose_name_plural = '資材'
def __str__(self):
return f'{self.get_material_type_display()}: {self.name}'
class FertilizerProfile(models.Model):
"""肥料専用属性"""
material = models.OneToOneField(
Material,
on_delete=models.CASCADE,
related_name='fertilizer_profile',
)
capacity_kg = models.DecimalField(
max_digits=8,
decimal_places=3,
blank=True,
null=True,
verbose_name='1袋重量(kg)',
)
nitrogen_pct = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
verbose_name='窒素(%)',
)
phosphorus_pct = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
verbose_name='リン酸(%)',
)
potassium_pct = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
verbose_name='カリ(%)',
)
class Meta:
verbose_name = '肥料詳細'
verbose_name_plural = '肥料詳細'
def __str__(self):
return f'肥料詳細: {self.material.name}'
class PesticideProfile(models.Model):
"""農薬専用属性"""
material = models.OneToOneField(
Material,
on_delete=models.CASCADE,
related_name='pesticide_profile',
)
registration_no = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='農薬登録番号',
)
formulation = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='剤型',
)
usage_unit = models.CharField(
max_length=50,
blank=True,
default='',
verbose_name='使用単位',
)
dilution_ratio = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='希釈倍率',
)
active_ingredient = models.CharField(
max_length=200,
blank=True,
default='',
verbose_name='有効成分',
)
category = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='分類',
)
class Meta:
verbose_name = '農薬詳細'
verbose_name_plural = '農薬詳細'
def __str__(self):
return f'農薬詳細: {self.material.name}'
class StockTransaction(models.Model):
"""入出庫履歴"""
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
RESERVE = 'reserve', '引当'
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
INCREASE_TYPES = {
TransactionType.PURCHASE,
TransactionType.ADJUSTMENT_PLUS,
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.RESERVE,
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
material = models.ForeignKey(
Material,
on_delete=models.PROTECT,
related_name='stock_transactions',
verbose_name='資材',
)
transaction_type = models.CharField(
max_length=30,
choices=TransactionType.choices,
verbose_name='取引種別',
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
validators=[MinValueValidator(Decimal('0.001'))],
verbose_name='数量',
)
occurred_on = models.DateField(verbose_name='発生日')
note = models.TextField(blank=True, default='', verbose_name='備考')
fertilization_plan = models.ForeignKey(
'fertilizer.FertilizationPlan',
on_delete=models.SET_NULL,
null=True,
blank=True,
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:
ordering = ['-occurred_on', '-created_at', '-id']
verbose_name = '入出庫履歴'
verbose_name_plural = '入出庫履歴'
def __str__(self):
return (
f'{self.material.name} '
f'{self.get_transaction_type_display()} '
f'{self.quantity}'
)

View File

@@ -0,0 +1,225 @@
from decimal import Decimal
from django.db import transaction
from rest_framework import serializers
from .models import (
FertilizerProfile,
Material,
PesticideProfile,
StockTransaction,
)
class FertilizerProfileSerializer(serializers.ModelSerializer):
class Meta:
model = FertilizerProfile
fields = ['capacity_kg', 'nitrogen_pct', 'phosphorus_pct', 'potassium_pct']
class PesticideProfileSerializer(serializers.ModelSerializer):
class Meta:
model = PesticideProfile
fields = [
'registration_no',
'formulation',
'usage_unit',
'dilution_ratio',
'active_ingredient',
'category',
]
class MaterialReadSerializer(serializers.ModelSerializer):
material_type_display = serializers.CharField(
source='get_material_type_display',
read_only=True,
)
stock_unit_display = serializers.CharField(
source='get_stock_unit_display',
read_only=True,
)
fertilizer_profile = FertilizerProfileSerializer(read_only=True)
pesticide_profile = PesticideProfileSerializer(read_only=True)
current_stock = serializers.SerializerMethodField()
class Meta:
model = Material
fields = [
'id',
'name',
'material_type',
'material_type_display',
'maker',
'stock_unit',
'stock_unit_display',
'is_active',
'notes',
'fertilizer_profile',
'pesticide_profile',
'current_stock',
'created_at',
'updated_at',
]
def get_current_stock(self, obj):
transactions = list(obj.stock_transactions.all())
increase = sum(
transaction.quantity
for transaction in transactions
if transaction.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
transaction.quantity
for transaction in transactions
if transaction.transaction_type in StockTransaction.DECREASE_TYPES
)
return increase - decrease
class MaterialWriteSerializer(serializers.ModelSerializer):
fertilizer_profile = FertilizerProfileSerializer(required=False, allow_null=True)
pesticide_profile = PesticideProfileSerializer(required=False, allow_null=True)
class Meta:
model = Material
fields = [
'id',
'name',
'material_type',
'maker',
'stock_unit',
'is_active',
'notes',
'fertilizer_profile',
'pesticide_profile',
]
def validate(self, attrs):
material_type = attrs.get('material_type')
if self.instance is not None and material_type is None:
material_type = self.instance.material_type
fertilizer_profile = attrs.get('fertilizer_profile')
pesticide_profile = attrs.get('pesticide_profile')
if material_type == Material.MaterialType.FERTILIZER and pesticide_profile:
raise serializers.ValidationError(
{'pesticide_profile': '肥料には農薬詳細を設定できません。'}
)
if material_type == Material.MaterialType.PESTICIDE and fertilizer_profile:
raise serializers.ValidationError(
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
)
if (
material_type in {
Material.MaterialType.SEED,
Material.MaterialType.SEEDLING,
Material.MaterialType.OTHER,
}
and (fertilizer_profile or pesticide_profile)
):
raise serializers.ValidationError(
'種子・種苗・その他には詳細プロファイルを設定できません。'
)
return attrs
@transaction.atomic
def create(self, validated_data):
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
material = Material.objects.create(**validated_data)
self._save_profiles(material, fertilizer_profile_data, pesticide_profile_data)
return material
@transaction.atomic
def update(self, instance, validated_data):
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
self._save_profiles(instance, fertilizer_profile_data, pesticide_profile_data)
return instance
def to_representation(self, instance):
return MaterialReadSerializer(instance, context=self.context).data
def _save_profiles(self, material, fertilizer_profile_data, pesticide_profile_data):
if material.material_type == Material.MaterialType.FERTILIZER:
if fertilizer_profile_data is not None:
profile, _ = FertilizerProfile.objects.get_or_create(material=material)
for attr, value in fertilizer_profile_data.items():
setattr(profile, attr, value)
profile.save()
PesticideProfile.objects.filter(material=material).delete()
return
if material.material_type == Material.MaterialType.PESTICIDE:
if pesticide_profile_data is not None:
profile, _ = PesticideProfile.objects.get_or_create(material=material)
for attr, value in pesticide_profile_data.items():
setattr(profile, attr, value)
profile.save()
FertilizerProfile.objects.filter(material=material).delete()
return
FertilizerProfile.objects.filter(material=material).delete()
PesticideProfile.objects.filter(material=material).delete()
class StockTransactionSerializer(serializers.ModelSerializer):
material_name = serializers.CharField(source='material.name', read_only=True)
material_type = serializers.CharField(source='material.material_type', read_only=True)
stock_unit = serializers.CharField(source='material.stock_unit', read_only=True)
stock_unit_display = serializers.CharField(
source='material.get_stock_unit_display',
read_only=True,
)
transaction_type_display = serializers.CharField(
source='get_transaction_type_display',
read_only=True,
)
is_locked = serializers.SerializerMethodField()
class Meta:
model = StockTransaction
fields = [
'id',
'material',
'material_name',
'material_type',
'transaction_type',
'transaction_type_display',
'quantity',
'stock_unit',
'stock_unit_display',
'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()
name = serializers.CharField()
material_type = serializers.CharField()
material_type_display = serializers.CharField()
maker = serializers.CharField()
stock_unit = serializers.CharField()
stock_unit_display = serializers.CharField()
is_active = serializers.BooleanField()
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
available_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
last_transaction_date = serializers.DateField(allow_null=True)

View File

@@ -0,0 +1,97 @@
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.utils import timezone
from .models import StockTransaction
@transaction.atomic
def create_reserves_for_plan(plan):
"""施肥計画の引当を全置換で作り直す。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
occurred_on = (
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
)
for entry in plan.entries.select_related('fertilizer__material'):
material = getattr(entry.fertilizer, 'material', None)
if material is None:
continue
StockTransaction.objects.create(
material=material,
transaction_type=StockTransaction.TransactionType.RESERVE,
quantity=entry.bags,
occurred_on=occurred_on,
note=f'施肥計画「{plan.name}」からの引当',
fertilization_plan=plan,
)
@transaction.atomic
def delete_reserves_for_plan(plan):
"""施肥計画に紐づく引当のみ削除する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
@transaction.atomic
def confirm_spreading(plan, actual_entries):
"""引当を使用実績へ変換して施肥計画を確定済みにする。"""
from apps.fertilizer.models import Fertilizer
delete_reserves_for_plan(plan)
for entry_data in actual_entries:
actual_bags = _to_decimal(entry_data.get('actual_bags'))
if actual_bags <= 0:
continue
fertilizer = (
Fertilizer.objects.select_related('material')
.filter(id=entry_data['fertilizer_id'])
.first()
)
if fertilizer is None or fertilizer.material is None:
continue
StockTransaction.objects.create(
material=fertilizer.material,
transaction_type=StockTransaction.TransactionType.USE,
quantity=actual_bags,
occurred_on=timezone.localdate(),
note=f'施肥計画「{plan.name}」散布確定',
fertilization_plan=plan,
)
plan.is_confirmed = True
plan.confirmed_at = timezone.now()
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
@transaction.atomic
def unconfirm_spreading(plan):
"""散布確定を取り消し、USE トランザクションを削除して引当を再作成する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.USE,
).delete()
plan.is_confirmed = False
plan.confirmed_at = None
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
create_reserves_for_plan(plan)
def _to_decimal(value):
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return Decimal('0')

View File

@@ -0,0 +1,18 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'materials', views.MaterialViewSet, basename='material')
router.register(
r'stock-transactions',
views.StockTransactionViewSet,
basename='stock-transaction',
)
urlpatterns = [
path('', include(router.urls)),
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'),
]

View File

@@ -0,0 +1,191 @@
from decimal import Decimal
from rest_framework import generics, status, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Material, StockTransaction
from .serializers import (
MaterialReadSerializer,
MaterialWriteSerializer,
StockSummarySerializer,
StockTransactionSerializer,
)
class MaterialViewSet(viewsets.ModelViewSet):
"""資材マスタ CRUD"""
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = Material.objects.select_related(
'fertilizer_profile',
'pesticide_profile',
).prefetch_related('stock_transactions')
material_type = self.request.query_params.get('material_type')
if material_type:
queryset = queryset.filter(material_type=material_type)
active = self.request.query_params.get('active')
if active is not None:
queryset = queryset.filter(is_active=active.lower() == 'true')
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return MaterialWriteSerializer
return MaterialReadSerializer
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.stock_transactions.exists():
return Response(
{'detail': 'この資材には入出庫履歴があるため削除できません。無効化してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
class StockTransactionViewSet(viewsets.ModelViewSet):
"""入出庫履歴 CRUD"""
serializer_class = StockTransactionSerializer
permission_classes = [IsAuthenticated]
http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
queryset = StockTransaction.objects.select_related('material')
material_id = self.request.query_params.get('material_id')
if material_id:
queryset = queryset.filter(material_id=material_id)
material_type = self.request.query_params.get('material_type')
if material_type:
queryset = queryset.filter(material__material_type=material_type)
date_from = self.request.query_params.get('date_from')
if date_from:
queryset = queryset.filter(occurred_on__gte=date_from)
date_to = self.request.query_params.get('date_to')
if date_to:
queryset = queryset.filter(occurred_on__lte=date_to)
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):
"""在庫集計一覧"""
serializer_class = StockSummarySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Material.objects.none()
def list(self, request, *args, **kwargs):
queryset = Material.objects.prefetch_related('stock_transactions').order_by(
'material_type',
'name',
)
material_type = request.query_params.get('material_type')
if material_type:
queryset = queryset.filter(material_type=material_type)
active = request.query_params.get('active')
if active is not None:
queryset = queryset.filter(is_active=active.lower() == 'true')
results = []
for material in queryset:
results.append(_build_stock_summary(material))
serializer = self.get_serializer(results, many=True)
return Response(serializer.data)
class FertilizerStockView(generics.ListAPIView):
"""施肥計画画面用: 肥料の在庫情報を返す"""
permission_classes = [IsAuthenticated]
serializer_class = StockSummarySerializer
def get_queryset(self):
return Material.objects.none()
def list(self, request, *args, **kwargs):
queryset = Material.objects.filter(
material_type=Material.MaterialType.FERTILIZER,
is_active=True,
).prefetch_related('stock_transactions').order_by('name')
results = [_build_stock_summary(material) for material in queryset]
serializer = self.get_serializer(results, many=True)
return Response(serializer.data)
def _build_stock_summary(material):
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity
for txn in transactions
if txn.transaction_type == StockTransaction.TransactionType.RESERVE
)
available = increase - decrease if transactions else Decimal('0')
last_date = max((txn.occurred_on for txn in transactions), default=None)
return {
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': available + reserved,
'reserved_stock': reserved,
'available_stock': available,
'last_transaction_date': last_date,
}

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2 on 2026-04-04 00:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fields', '0006_e1c_chusankan_17_fields'),
('plans', '0004_crop_base_temp'),
]
operations = [
migrations.AddField(
model_name='crop',
name='seed_inventory_kg',
field=models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='種もみ在庫(kg)'),
),
migrations.AddField(
model_name='variety',
name='default_seedling_boxes_per_tan',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数デフォルト'),
),
migrations.CreateModel(
name='RiceTransplantPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='計画名')),
('year', models.IntegerField(verbose_name='年度')),
('default_seed_grams_per_box', models.DecimalField(decimal_places=2, default=0, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)デフォルト')),
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rice_transplant_plans', to='plans.variety', verbose_name='品種')),
],
options={
'verbose_name': '田植え計画',
'verbose_name_plural': '田植え計画',
'ordering': ['-year', 'variety'],
},
),
migrations.CreateModel(
name='RiceTransplantEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('seedling_boxes_per_tan', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='反当苗箱枚数')),
('seed_grams_per_box', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rice_transplant_entries', to='fields.field', verbose_name='圃場')),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='plans.ricetransplantplan', verbose_name='田植え計画')),
],
options={
'verbose_name': '田植え計画エントリ',
'verbose_name_plural': '田植え計画エントリ',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('plan', 'field')},
},
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('plans', '0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant'),
]
operations = [
migrations.RenameField(
model_name='ricetransplantentry',
old_name='seedling_boxes_per_tan',
new_name='installed_seedling_boxes',
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plans', '0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes'),
]
operations = [
migrations.AddField(
model_name='ricetransplantplan',
name='seedling_boxes_per_tan',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数'),
),
]

View File

@@ -0,0 +1,26 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materials', '0005_material_seed_type'),
('plans', '0007_ricetransplantplan_seedling_boxes_per_tan'),
]
operations = [
migrations.AddField(
model_name='variety',
name='seed_material',
field=models.ForeignKey(
blank=True,
limit_choices_to={'material_type': 'seed'},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='varieties',
to='materials.material',
verbose_name='種子在庫資材',
),
),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plans', '0008_variety_seed_material'),
]
operations = [
migrations.AlterField(
model_name='ricetransplantentry',
name='installed_seedling_boxes',
field=models.DecimalField(
decimal_places=2,
max_digits=8,
verbose_name='設置苗箱枚数',
),
),
]

View File

@@ -0,0 +1,32 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fields', '0006_e1c_chusankan_17_fields'),
('plans', '0009_alter_ricetransplantentry_installed_seedling_boxes'),
]
operations = [
migrations.CreateModel(
name='PlanVarietyChange',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.IntegerField(verbose_name='作付年度')),
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='変更日時')),
('reason', models.TextField(blank=True, default='', verbose_name='変更理由')),
('fertilizer_moved_entry_count', models.IntegerField(default=0, verbose_name='施肥移動エントリ数')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='plan_variety_changes', to='fields.field', verbose_name='圃場')),
('new_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='new_plan_variety_changes', to='plans.variety', verbose_name='変更後品種')),
('old_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='old_plan_variety_changes', to='plans.variety', verbose_name='変更前品種')),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variety_changes', to='plans.plan', verbose_name='作付け計画')),
],
options={
'verbose_name': '作付け計画品種変更履歴',
'verbose_name_plural': '作付け計画品種変更履歴',
'ordering': ['-changed_at', '-id'],
},
),
]

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
from django.db import transaction
from .models import Plan, PlanVarietyChange
class _NoChange:
pass
NO_CHANGE = _NoChange()
@transaction.atomic
def update_plan_with_variety_tracking(
plan: Plan,
*,
crop=NO_CHANGE,
variety=NO_CHANGE,
notes=NO_CHANGE,
reason: str = '',
):
old_variety = plan.variety
updated_fields = []
if crop is not NO_CHANGE:
plan.crop = crop
updated_fields.append('crop')
if variety is not NO_CHANGE:
plan.variety = variety
updated_fields.append('variety')
if notes is not NO_CHANGE:
plan.notes = notes
updated_fields.append('notes')
if updated_fields:
plan.save(update_fields=updated_fields)
if variety is not NO_CHANGE and _get_variety_id(old_variety) != _get_variety_id(plan.variety):
handle_plan_variety_change(plan, old_variety=old_variety, new_variety=plan.variety, reason=reason)
return plan
@transaction.atomic
def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: str = ''):
if _get_variety_id(old_variety) == _get_variety_id(new_variety):
return None
change = PlanVarietyChange.objects.create(
field=plan.field,
year=plan.year,
plan=plan,
old_variety=old_variety,
new_variety=new_variety,
reason=reason,
)
process_plan_variety_change(change)
return change
def process_plan_variety_change(change: PlanVarietyChange):
from apps.fertilizer.services import move_fertilization_entries_for_variety_change
from .services_rice_transplant import move_rice_transplant_entries_for_variety_change
moved_count = move_fertilization_entries_for_variety_change(change)
move_rice_transplant_entries_for_variety_change(change)
if moved_count != change.fertilizer_moved_entry_count:
change.fertilizer_moved_entry_count = moved_count
change.save(update_fields=['fertilizer_moved_entry_count'])
return change
def _get_variety_id(variety):
return getattr(variety, 'id', None)

View File

@@ -0,0 +1,46 @@
from django.db import transaction
from .models import RiceTransplantEntry, RiceTransplantPlan
@transaction.atomic
def move_rice_transplant_entries_for_variety_change(change):
old_variety_id = change.old_variety_id
new_variety = change.new_variety
if old_variety_id is None or new_variety is None:
return 0
old_plans = (
RiceTransplantPlan.objects
.filter(
year=change.year,
variety_id=old_variety_id,
entries__field_id=change.field_id,
)
.distinct()
.prefetch_related('entries')
)
moved_count = 0
for old_plan in old_plans:
entries_to_move = list(
old_plan.entries.filter(field_id=change.field_id).order_by('id')
)
if not entries_to_move:
continue
new_plan = RiceTransplantPlan.objects.create(
name=f'{change.year}年度 {new_variety.name} 田植え計画(品種変更移動)',
year=change.year,
variety=new_variety,
default_seed_grams_per_box=old_plan.default_seed_grams_per_box,
seedling_boxes_per_tan=old_plan.seedling_boxes_per_tan,
notes=old_plan.notes,
)
RiceTransplantEntry.objects.filter(
id__in=[entry.id for entry in entries_to_move]
).update(plan=new_plan)
moved_count += len(entries_to_move)
return moved_count

View File

@@ -1,3 +1,263 @@
from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from decimal import Decimal
# Create your tests here. from apps.fertilizer.models import FertilizationEntry, FertilizationPlan, Fertilizer
from apps.fields.models import Field
from apps.materials.models import Material, StockTransaction
from apps.materials.stock_service import create_reserves_for_plan
from .models import (
Crop,
Plan,
PlanVarietyChange,
RiceTransplantEntry,
RiceTransplantPlan,
Variety,
)
from .serializers import PlanSerializer
from .views import PlanViewSet
class PlanVarietyChangeTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username='tester',
password='secret12345',
)
self.crop = Crop.objects.create(name='水稲')
self.old_variety = Variety.objects.create(crop=self.crop, name='にこまる')
self.new_variety = Variety.objects.create(crop=self.crop, name='たちはるか特栽')
self.field = Field.objects.create(
name='足川北上',
address='高知県高岡郡',
area_tan='1.2000',
area_m2=1200,
owner_name='吉田',
group_name='',
display_order=1,
)
self.plan = Plan.objects.create(
field=self.field,
year=2026,
crop=self.crop,
variety=self.old_variety,
notes='',
)
self.other_field = Field.objects.create(
name='足川南',
address='高知県高岡郡',
area_tan='0.8000',
area_m2=800,
owner_name='吉田',
group_name='',
display_order=2,
)
def test_serializer_update_creates_history_when_variety_changes(self):
serializer = PlanSerializer(
instance=self.plan,
data={'variety': self.new_variety.id},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()
self.plan.refresh_from_db()
self.assertEqual(self.plan.variety_id, self.new_variety.id)
change = PlanVarietyChange.objects.get(plan=self.plan)
self.assertEqual(change.field_id, self.field.id)
self.assertEqual(change.year, 2026)
self.assertEqual(change.old_variety_id, self.old_variety.id)
self.assertEqual(change.new_variety_id, self.new_variety.id)
self.assertEqual(change.fertilizer_moved_entry_count, 0)
def test_serializer_update_does_not_create_history_without_variety_change(self):
serializer = PlanSerializer(
instance=self.plan,
data={'notes': 'メモ更新'},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()
self.plan.refresh_from_db()
self.assertEqual(self.plan.notes, 'メモ更新')
self.assertFalse(PlanVarietyChange.objects.exists())
def test_bulk_update_creates_history_for_existing_plan(self):
view = PlanViewSet.as_view({'post': 'bulk_update'})
request = self.factory.post(
'/api/plans/bulk_update/',
{
'field_ids': [self.field.id],
'year': 2026,
'crop': self.crop.id,
'variety': self.new_variety.id,
},
format='json',
)
force_authenticate(request, user=self.user)
response = view(request)
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
self.assertEqual(self.plan.variety_id, self.new_variety.id)
change = PlanVarietyChange.objects.get(plan=self.plan)
self.assertEqual(change.old_variety_id, self.old_variety.id)
self.assertEqual(change.new_variety_id, self.new_variety.id)
def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self):
material_target = Material.objects.create(
name='高度化成14号',
material_type=Material.MaterialType.FERTILIZER,
)
material_spread = Material.objects.create(
name='分げつ一発',
material_type=Material.MaterialType.FERTILIZER,
)
fertilizer_target = Fertilizer.objects.create(
name='高度化成14号',
material=material_target,
)
fertilizer_spread = Fertilizer.objects.create(
name='分げつ一発',
material=material_spread,
)
old_fertilization_plan = FertilizationPlan.objects.create(
name='2026年度 にこまる 元肥',
year=2026,
variety=self.old_variety,
calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}],
)
target_entry = FertilizationEntry.objects.create(
plan=old_fertilization_plan,
field=self.field,
fertilizer=fertilizer_target,
bags='4.00',
actual_bags=None,
)
spread_entry = FertilizationEntry.objects.create(
plan=old_fertilization_plan,
field=self.field,
fertilizer=fertilizer_spread,
bags='3.00',
actual_bags='1.0000',
)
untouched_entry = FertilizationEntry.objects.create(
plan=old_fertilization_plan,
field=self.other_field,
fertilizer=fertilizer_target,
bags='2.00',
actual_bags=None,
)
create_reserves_for_plan(old_fertilization_plan)
serializer = PlanSerializer(
instance=self.plan,
data={'variety': self.new_variety.id},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()
change = PlanVarietyChange.objects.get(plan=self.plan)
self.assertEqual(change.fertilizer_moved_entry_count, 2)
old_fertilization_plan.refresh_from_db()
new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get(
year=2026,
variety=self.new_variety,
)
self.assertEqual(
new_plan.name,
f'2026年度 {self.new_variety.name} 施肥計画(品種変更移動)',
)
self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings)
target_entry.refresh_from_db()
spread_entry.refresh_from_db()
untouched_entry.refresh_from_db()
self.assertEqual(target_entry.plan_id, new_plan.id)
self.assertEqual(spread_entry.plan_id, new_plan.id)
self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id)
old_reserves = list(
StockTransaction.objects.filter(
fertilization_plan=old_fertilization_plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).order_by('material__name')
)
new_reserves = list(
StockTransaction.objects.filter(
fertilization_plan=new_plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).order_by('material__name')
)
self.assertEqual(len(old_reserves), 1)
self.assertEqual(len(new_reserves), 2)
self.assertEqual(
{(reserve.material_id, reserve.quantity) for reserve in old_reserves},
{
(material_target.id, untouched_entry.bags),
},
)
self.assertEqual(
{(reserve.material_id, reserve.quantity) for reserve in new_reserves},
{
(material_target.id, target_entry.bags),
(material_spread.id, spread_entry.bags),
},
)
def test_serializer_update_moves_rice_transplant_entries_for_target_field(self):
old_rice_plan = RiceTransplantPlan.objects.create(
name='2026年度 にこまる 田植え計画',
year=2026,
variety=self.old_variety,
default_seed_grams_per_box='200.00',
seedling_boxes_per_tan='12.00',
notes='旧計画メモ',
)
target_entry = RiceTransplantEntry.objects.create(
plan=old_rice_plan,
field=self.field,
installed_seedling_boxes='14.40',
seed_grams_per_box='200.00',
)
other_entry = RiceTransplantEntry.objects.create(
plan=old_rice_plan,
field=self.other_field,
installed_seedling_boxes='9.60',
seed_grams_per_box='200.00',
)
serializer = PlanSerializer(
instance=self.plan,
data={'variety': self.new_variety.id},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()
target_entry.refresh_from_db()
other_entry.refresh_from_db()
new_rice_plan = RiceTransplantPlan.objects.exclude(id=old_rice_plan.id).get(
year=2026,
variety=self.new_variety,
)
self.assertEqual(
new_rice_plan.name,
f'2026年度 {self.new_variety.name} 田植え計画(品種変更移動)',
)
self.assertEqual(new_rice_plan.default_seed_grams_per_box, Decimal('200.00'))
self.assertEqual(new_rice_plan.seedling_boxes_per_tan, Decimal('12.00'))
self.assertEqual(new_rice_plan.notes, old_rice_plan.notes)
self.assertEqual(target_entry.plan_id, new_rice_plan.id)
self.assertEqual(other_entry.plan_id, old_rice_plan.id)

View File

@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register(r'crops', views.CropViewSet) router.register(r'crops', views.CropViewSet)
router.register(r'varieties', views.VarietyViewSet) router.register(r'varieties', views.VarietyViewSet)
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
router.register(r'', views.PlanViewSet) router.register(r'', views.PlanViewSet)
urlpatterns = [ urlpatterns = [

View File

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

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

@@ -44,6 +44,9 @@ INSTALLED_APPS = [
'apps.mail', 'apps.mail',
'apps.weather', 'apps.weather',
'apps.fertilizer', 'apps.fertilizer',
'apps.materials',
'apps.workrecords',
'apps.levee_work',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -58,4 +58,7 @@ urlpatterns = [
path('api/mail/', include('apps.mail.urls')), path('api/mail/', include('apps.mail.urls')),
path('api/weather/', include('apps.weather.urls')), path('api/weather/', include('apps.weather.urls')),
path('api/fertilizer/', include('apps.fertilizer.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

25
deploy.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "=== KeinaSystem デプロイ ==="
echo "[1/4] git pull..."
git pull origin main
echo "[2/4] docker compose down..."
docker compose down
echo "[3/4] docker compose build..."
docker compose build
echo "[4/5] docker compose up -d..."
docker compose up -d
echo "[5/5] migrate..."
docker compose exec backend python manage.py migrate
echo ""
echo "=== デプロイ完了 ==="
docker compose ps

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"

BIN
designated_mix_national.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

20
develop.bat Normal file
View File

@@ -0,0 +1,20 @@
@echo off
cd /d "%~dp0"
echo === KeinaSystem 開発環境起動 ===
echo [1/3] docker compose down...
docker compose -f docker-compose.develop.yml down
echo [2/3] docker compose build...
docker compose -f docker-compose.develop.yml build
echo [3/3] docker compose up -d...
docker compose -f docker-compose.develop.yml up -d
echo.
echo === 開発環境起動完了 ===
docker compose -f docker-compose.develop.yml ps
echo.
echo Frontend: http://localhost:3000
echo Backend: http://localhost:8000

View File

@@ -0,0 +1,59 @@
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:/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
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: "True"
MAIL_API_KEY: ${MAIL_API_KEY}
ports:
- "8000:8000"
volumes:
- ./backend:/app
depends_on:
db:
condition: service_healthy
command: python manage.py runserver 0.0.0.0:8000
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: keinasystem_frontend
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
WATCHPACK_POLLING: "true"
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
volumes:
postgres_data:

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,84 +0,0 @@
version: '3.8'
networks:
traefik-net:
external: true
internal:
internal: true
services:
db:
image: postgis/postgis:16-3.4
container_name: keinasystem_db
restart: always
environment:
POSTGRES_DB: keinasystem
POSTGRES_USER: keinasystem
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keinasystem -d keinasystem"]
interval: 5s
timeout: 5s
retries: 5
networks:
- internal
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
container_name: keinasystem_backend
restart: always
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: main.keinafarm.net
CORS_ALLOWED_ORIGINS: https://main.keinafarm.net
MAIL_API_KEY: ${MAIL_API_KEY}
FRONTEND_URL: https://main.keinafarm.net
depends_on:
db:
condition: service_healthy
networks:
- internal
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.keinasystem-api.rule=Host(`main.keinafarm.net`) && PathPrefix(`/api/`)"
- "traefik.http.routers.keinasystem-api.entrypoints=websecure"
- "traefik.http.routers.keinasystem-api.tls=true"
- "traefik.http.routers.keinasystem-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.keinasystem-api.priority=10"
- "traefik.http.services.keinasystem-api.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik-net"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
args:
NEXT_PUBLIC_API_URL: https://main.keinafarm.net
container_name: keinasystem_frontend
restart: always
depends_on:
- backend
networks:
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.keinasystem.rule=Host(`main.keinafarm.net`)"
- "traefik.http.routers.keinasystem.entrypoints=websecure"
- "traefik.http.routers.keinasystem.tls=true"
- "traefik.http.routers.keinasystem.tls.certresolver=letsencrypt"
- "traefik.http.routers.keinasystem.priority=5"
- "traefik.http.services.keinasystem.loadbalancer.server.port=3000"
volumes:
postgres_data:

View File

@@ -1,15 +1,18 @@
version: '3.8' networks:
traefik-net:
external: true
internal:
internal: true
services: services:
db: db:
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
container_name: keinasystem_db container_name: keinasystem_db
restart: always
environment: environment:
POSTGRES_DB: keinasystem POSTGRES_DB: keinasystem
POSTGRES_USER: keinasystem POSTGRES_USER: keinasystem
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -17,12 +20,15 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- internal
backend: backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile.prod
container_name: keinasystem_backend container_name: keinasystem_backend
restart: always
environment: environment:
DB_NAME: keinasystem DB_NAME: keinasystem
DB_USER: keinasystem DB_USER: keinasystem
@@ -30,32 +36,47 @@ services:
DB_HOST: db DB_HOST: db
DB_PORT: 5432 DB_PORT: 5432
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
DEBUG: "True" DEBUG: "False"
ALLOWED_HOSTS: main.keinafarm.net
CORS_ALLOWED_ORIGINS: https://main.keinafarm.net
MAIL_API_KEY: ${MAIL_API_KEY} MAIL_API_KEY: ${MAIL_API_KEY}
ports: FRONTEND_URL: https://main.keinafarm.net
- "8000:8000"
volumes:
- ./backend:/app
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
command: python manage.py runserver 0.0.0.0:8000 networks:
- internal
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.keinasystem-api.rule=Host(`main.keinafarm.net`) && PathPrefix(`/api/`)"
- "traefik.http.routers.keinasystem-api.entrypoints=websecure"
- "traefik.http.routers.keinasystem-api.tls=true"
- "traefik.http.routers.keinasystem-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.keinasystem-api.priority=10"
- "traefik.http.services.keinasystem-api.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik-net"
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile.prod
args:
NEXT_PUBLIC_API_URL: https://main.keinafarm.net
container_name: keinasystem_frontend container_name: keinasystem_frontend
environment: restart: always
NEXT_PUBLIC_API_URL: http://localhost:8000
WATCHPACK_POLLING: "true"
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on: depends_on:
- backend - backend
networks:
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.keinasystem.rule=Host(`main.keinafarm.net`)"
- "traefik.http.routers.keinasystem.entrypoints=websecure"
- "traefik.http.routers.keinasystem.tls=true"
- "traefik.http.routers.keinasystem.tls.certresolver=letsencrypt"
- "traefik.http.routers.keinasystem.priority=5"
- "traefik.http.services.keinasystem.loadbalancer.server.port=3000"
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -1,6 +1,6 @@
# マスタードキュメント - メール通知関連編 # マスタードキュメント - メール通知関連編
> **最終更新**: 2026-02-25 > **最終更新**: 2026-03-05
> **対象バージョン**: Phase 1 完了時点(本番稼働中) > **対象バージョン**: Phase 1 完了時点(本番稼働中)
> **目的**: このドキュメントだけでメール通知機能の全容を把握できること > **目的**: このドキュメントだけでメール通知機能の全容を把握できること
@@ -47,16 +47,18 @@
``` ```
1. IMAP 接続 → 前回処理済み UID 以降の新着メールを取得 1. IMAP 接続 → 前回処理済み UID 以降の新着メールを取得
2. 送信者ルール確認GET /api/mail/sender-rule/ 2. 宛先補正To ヘッダー
└── @keinafarm.com 宛先は xserver1〜xserver6 に正規化Gmail先行取り込み時の誤表示防止
3. 送信者ルール確認GET /api/mail/sender-rule/
├── never_notify → スキップ(記録しない) ├── never_notify → スキップ(記録しない)
├── always_notify → LLMスキップ、即 LINE 通知 ├── always_notify → LLMスキップ、即 LINE 通知
└── ルールなし → 3 └── ルールなし → 4
3. 過去フィードバック集計取得GET /api/mail/sender-context/ 4. 過去フィードバック集計取得GET /api/mail/sender-context/
4. Gemini API で重要度判定LLM 5. Gemini API で重要度判定LLM
5. KeinaSystem に記録POST /api/mail/emails/ 6. KeinaSystem に記録POST /api/mail/emails/
├── not_important → 記録のみ、通知なし ├── not_important → 記録のみ、通知なし
└── important → フィードバックURLを発行、LINE 通知 └── important → フィードバックURLを発行、LINE 通知
6. 処理済み最終 UID を Windmill Variable に保存 7. 処理済み最終 UID を Windmill Variable に保存
``` ```
### 10分ごとの定期実行 ### 10分ごとの定期実行
@@ -92,7 +94,7 @@ Windmill スケジュール `0 */10 * * * *` で自動実行。サーバー上
| フィールド | 型 | 説明 | | フィールド | 型 | 説明 |
|---|---|---| |---|---|---|
| `id` | BigAutoField | PK | | `id` | BigAutoField | PK |
| `account` | CharField(20) | `gmail` / `gmail_service` / `xserver` / `hotmail` | | `account` | CharField(20) | `gmail` / `gmail_service` / `hotmail` / `xserver1``xserver6`(旧データは `xserver` |
| `message_id` | CharField(500, unique) | メールの Message-ID ヘッダー(重複防止に使用)| | `message_id` | CharField(500, unique) | メールの Message-ID ヘッダー(重複防止に使用)|
| `sender_email` | EmailField | 送信者メールアドレス | | `sender_email` | EmailField | 送信者メールアドレス |
| `sender_domain` | CharField(255) | 送信者ドメイン | | `sender_domain` | CharField(255) | 送信者ドメイン |
@@ -370,6 +372,12 @@ Hotmail は定義済みだがコメントアウト(未有効化)。
回答: `1`(重要)/ `2`重要でないの1文字。`1` で始まる場合 `important` 回答: `1`(重要)/ `2`重要でないの1文字。`1` で始まる場合 `important`
### 4.7 宛先補正ロジック
- 対象: Gmail 側で先に取得された転送メール
- 方法: `To` ヘッダーの宛先アドレスを `recipient_map``xserver1``xserver6` に変換
- 目的: message_id 重複時に Gmail で先着しても、実際の受信メールボックスXserver側を通知文・履歴で保持する
### 4.6 LINE 通知文フォーマット ### 4.6 LINE 通知文フォーマット
``` ```
@@ -514,7 +522,7 @@ UUID v4 のランダムトークンのみで認証。有効期限なし。LINE
### 重複メール処理 ### 重複メール処理
同じメールが複数アカウントで受信される場合(転送設定等)、`message_id` の unique 制約で2件目以降を自動スキップ。最初に処理したアカウントの `account_code` でDBに記録される。 同じメールが複数アカウントで受信される場合(転送設定等)、`message_id` の unique 制約で2件目以降を自動スキップ。先着レコードを採用するが、Gmail先行時でも `To` ヘッダー宛先補正により `xserver1``xserver6` を優先して記録する。
--- ---
@@ -597,3 +605,6 @@ curl -s -H "Authorization: Bearer $TOKEN" \
本番 Windmill でのパス: `f/mail/mail_filter` 本番 Windmill でのパス: `f/mail/mail_filter`
スケジュール: `f/mail/mail_filter_schedule` スケジュール: `f/mail/mail_filter_schedule`

View File

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

View File

@@ -0,0 +1,412 @@
# マスタードキュメント:運搬計画機能(旧・分配計画)
> **作成**: 2026-03-02
> **最終更新**: 2026-03-16
> **対象機能**: 運搬計画(施肥計画の肥料を軽トラで運ぶ単位で計画・記録する)
> **実装状況**: 本番稼働中
---
## 概要
施肥計画で決めた圃場ごとの肥料袋数を、**軽トラ1回分の積載単位**で運搬計画にまとめる機能。
実際の作業では一度に全部運べないため、「何回目にどのグループのどの肥料を何袋運ぶか」を計画・記録する。
### 旧設計(分配計画)からの変更理由
旧設計は「1つの施肥計画の圃場をグループ分けする」だけだった。
実運用で以下のギャップが判明2026-03-16
1. **複数の施肥計画が混在する** - 軽トラには品種をまたいで積む
2. **単一の施肥計画が分割される** - 1回で運びきれない
3. **全肥料を一度に運ぶわけではない** - 運ぶ肥料を指定する必要がある
4. **圃場単位の合計袋数は不要** - グループ×肥料の合計が重要
5. **同じグループの圃場を回ごとに分割する** - 載りきらないときは次の回に
6. **作業記録でもある** - 運搬した日付を記録したい
### 機能スコープ
| IN実装対象 | OUT対象外 |
|---|---|
| 年度単位の運搬計画作成 | 購入管理 |
| 配送先グループへの圃場割り当て | 肥料の在庫管理 |
| 運搬回ごとの圃場×肥料割り当て | ルート最適化 |
| 回ごとの積載合計リアルタイム表示 | |
| 圃場を回の間で移動する操作 | |
| 「残り全部」一括割り当て | |
| 回ごとの運搬日記録 | |
| PDF出力回ごとに1ページ | |
---
## データモデル
### 旧モデルからの移行
| 旧(削除) | 新(追加) | 備考 |
|---|---|---|
| DistributionPlan | DeliveryPlan | FK(FertilizationPlan) 廃止 → year ベース |
| DistributionGroup | DeliveryGroup | ほぼ同等 |
| DistributionGroupField | DeliveryGroupField | ほぼ同等 |
| (なし) | DeliveryTrip | 新規:運搬回 |
| (なし) | DeliveryTripItem | 新規:運搬明細(圃場×肥料単位) |
### DeliveryPlan運搬計画
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| year | int | required | 年度 |
| name | varchar(200) | required | 計画名 |
| created_at / updated_at | datetime | auto | |
- `ordering = ['-year', 'name']`
- 施肥計画への直接FK なし(年度ベースで全施肥計画を横断)
### DeliveryGroup配送先グループ
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| name | varchar(100) | required | グループ名(例: キウイ, 足川北) |
| order | PositiveIntegerField | default=0 | 表示順 |
- `unique_together = [['delivery_plan', 'name']]`
- `ordering = ['order', 'id']`
### DeliveryGroupFieldグループ圃場割り当て
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | 一意制約用 |
| group | FK(DeliveryGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
- `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. 「残り全部」操作: 施肥計画の合計 既に割り当て済みの回の合計 = 残り
---
## API エンドポイント
すべて JWT 認証(`Authorization: Bearer <token>`)。
| メソッド | URL | 説明 |
|---|---|---|
| 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出力 |
### 一覧レスポンス
```json
{
"id": 1,
"year": 2026,
"name": "2026春 肥料運搬",
"group_count": 5,
"trip_count": 3,
"created_at": "...",
"updated_at": "..."
}
```
### 詳細レスポンス
```json
{
"id": 1,
"year": 2026,
"name": "2026春 肥料運搬",
"groups": [
{
"id": 10,
"name": "キウイ",
"order": 0,
"fields": [
{"id": 5, "name": "キウイ畑1", "area_tan": "1.2000"}
]
}
],
"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
{
"year": 2026,
"name": "2026春 肥料運搬",
"groups": [
{"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・trips を全削除→再作成する全置換方式。
---
## フロントエンド画面
### 運搬計画一覧 `/distribution`
- 年度セレクタ(`localStorage distributionYear` で保持)
- テーブル: 計画名・グループ数・回数
- アクション: PDF・編集・削除
### 運搬計画編集 `/distribution/new` / `/distribution/[id]/edit`
#### 画面レイアウト
```
[計画名: ________________] [年度: 2026]
━━━ グループ定義 ━━━━━━━━━━━━━━━━━━━
(既存の方式: グループ追加・圃場割り当て・並び替え)
━━━ 対象肥料 ━━━━━━━━━━━━━━━━━━━━━
☑電気炉さい ☑ミネラルホウ素 ☐有機100号 ...
(年度の施肥計画に含まれる肥料をチェックボックスで選択)
━━━ 未割り当て ━━━━━━━━━━━━━━━━━━━━
★ キウイ (小計: 電気炉さい 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)
...
[+回を追加] [残り全部→新しい回] [保存]
```
#### 主要な操作
| 操作 | 方法 | 説明 |
|---|---|---|
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
| 圃場を別の回に移動 | 圃場行の「移動...」ドロップダウン | 回の間で移動 |
| グループを回に一括割り当て | 回内の「グループを追加...」ドロップダウン | グループの全圃場を一括割り当て |
| グループを別の回に移動 | グループ★行の「移動...」ドロップダウン | グループの全圃場を回の間で一括移動 |
| グループを未割り当てに戻す | グループ★行の「移動...」→「未割り当てに戻す」 | グループの全圃場を一括で未割り当てに戻す |
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て全圃場を新しい回に追加 |
| 回の追加 | 「+回を追加」ボタン | 空の回を追加 |
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
| 回の日付設定 | 日付入力フィールド | デフォルトは1回目の日付 |
| 対象肥料の絞り込み | チェックボックス | 選択した肥料だけ表示 |
#### 積載合計のリアルタイム表示
各回のヘッダーに、その回の肥料ごとの合計袋数と総袋数を表示。
圃場を追加・削除するたびに即時再計算(サーバー通信なし)。
---
## PDF 出力
`GET /api/fertilizer/delivery/{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 # 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/
└── delivery_pdf.html # 回ごと1ページ PDF テンプレート
```
### Frontend
```
frontend/src/app/distribution/
├── page.tsx # 一覧ページ
├── new/page.tsx # 新規作成(ラッパー)
├── [id]/edit/page.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 を削除
---
## 注意点
### 施肥計画との関係
- 運搬計画は施肥計画への直接FKを持たない
- 年度ベースで、その年度の全 FertilizationEntry を参照して圃場×肥料の袋数を取得する
- 施肥計画を変更すると、未割り当ての圃場の袋数は自動で反映される
- 既に運搬回に割り当て済みの DeliveryTripItem.bags は変わらない(コピー済み)
### 集計はクライアントサイド計算
画面上の集計(グループ小計・回の積載合計)は API を呼ばずクライアントで計算。
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` 側が保持する

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