Compare commits
109 Commits
0490515941
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04e1884d9 | ||
|
|
cc6823b071 | ||
|
|
b7b5ce3943 | ||
|
|
8de1ae70aa | ||
|
|
3901caf668 | ||
|
|
5f58c2c686 | ||
|
|
83525c2f59 | ||
|
|
627d7e4f59 | ||
|
|
9059b2b51e | ||
|
|
7d2eb1ebe2 | ||
|
|
3e2942b479 | ||
|
|
70fe3824b3 | ||
|
|
10f2b6f77f | ||
|
|
6dfcd0be06 | ||
|
|
1371eef648 | ||
|
|
ac0bc7b6a9 | ||
|
|
1c474e9692 | ||
|
|
0cd90e61db | ||
|
|
8de27de335 | ||
|
|
71b8258281 | ||
|
|
4516a74772 | ||
|
|
a42ccb5cda | ||
|
|
4a1db5ef27 | ||
|
|
c90c6210e1 | ||
|
|
c675b7b7ae | ||
|
|
ae0249be69 | ||
|
|
1d5bcc9dd6 | ||
|
|
98814299cf | ||
|
|
21fb2323eb | ||
|
|
5a9b6a053b | ||
|
|
429a98decb | ||
|
|
4299c6eb4b | ||
|
|
8dd680e28a | ||
|
|
3eb2852b78 | ||
|
|
5c2d17fe0a | ||
|
|
182ef5d83d | ||
|
|
0131982c34 | ||
|
|
491f05eee8 | ||
|
|
a38472e4a0 | ||
|
|
11b36b28a5 | ||
|
|
95c90dd699 | ||
|
|
9bcc5e5e21 | ||
|
|
0c57dd7886 | ||
|
|
f236fe2f90 | ||
|
|
b7b9818855 | ||
|
|
c773c7d3b8 | ||
|
|
edd2f2a274 | ||
|
|
00fd4a8cba | ||
|
|
13c21ed7de | ||
|
|
daae1a42e5 | ||
|
|
4e06318985 | ||
|
|
9f96d1f820 | ||
|
|
140d5e5a4d | ||
|
|
865d53ed9a | ||
|
|
c9ae99ebc8 | ||
|
|
9dbbb48ee0 | ||
|
|
1f26d5001b | ||
|
|
722ac4efd0 | ||
|
|
bba04f24c2 | ||
|
|
287a1ebb59 | ||
|
|
1c27a66691 | ||
|
|
eba6267495 | ||
|
|
d9a4bd19eb | ||
|
|
89ab9b7b83 | ||
|
|
d5d78a2b14 | ||
|
|
391b0b265e | ||
|
|
736b9c824e | ||
|
|
e3c21d6e81 | ||
|
|
72b4d670fe | ||
|
|
42b11a5df8 | ||
|
|
497bc87c24 | ||
|
|
67d4197b7f | ||
|
|
1b619c44a0 | ||
|
|
f1512febde | ||
|
|
776a269d6d | ||
|
|
1425094107 | ||
|
|
f74dc4c4b7 | ||
|
|
7825f0eb30 | ||
|
|
422a6781c5 | ||
|
|
0e809ebb99 | ||
|
|
ff67a6bf26 | ||
|
|
5145217481 | ||
|
|
21d1dc355d | ||
|
|
8c47217003 | ||
|
|
a331f8b30a | ||
|
|
466eef128c | ||
|
|
0d321df1c4 | ||
|
|
5a92c87c14 | ||
|
|
50d5fdcbb3 | ||
|
|
deb03efaed | ||
|
|
84ae939d73 | ||
|
|
db20a9c3d2 | ||
|
|
048b17ef43 | ||
|
|
b855608084 | ||
|
|
cfd67e0d55 | ||
|
|
8ac3a00737 | ||
|
|
f207f5de27 | ||
|
|
371e40236c | ||
|
|
6e99164e3f | ||
|
|
adb235250e | ||
|
|
3c888f0503 | ||
|
|
8931e4eb87 | ||
|
|
d11e2a708d | ||
|
|
8a1887a26d | ||
|
|
2c515cca6f | ||
|
|
b386ee4380 | ||
|
|
407d915b35 | ||
|
|
a010ece7ed | ||
|
|
72ac0c22b4 |
78
.agents/workflows/testing-cleanup.md
Normal file
78
.agents/workflows/testing-cleanup.md
Normal 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/" }
|
||||
```
|
||||
|
||||
上記の結果が空であればクリーンアップ完了。
|
||||
@@ -56,10 +56,33 @@
|
||||
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
||||
"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(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": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
"Bash(docker compose exec:*)",
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(TOKEN=\"15c19c3c-3476-4177-8351-3b545c1e51d1\")",
|
||||
"Bash(ssh:*)"
|
||||
"Bash(ssh:*)",
|
||||
"Bash(claude mcp list)",
|
||||
"Bash(claude mcp get trilium)",
|
||||
"Bash(claude mcp get gitea)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
26
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
26
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: バグ報告
|
||||
about: 不具合・予期しない動作の報告
|
||||
labels: バグ
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 問題の概要
|
||||
|
||||
## 再現手順
|
||||
|
||||
## 期待する動作
|
||||
|
||||
## 実際の動作
|
||||
|
||||
## 関連
|
||||
24
.gitea/ISSUE_TEMPLATE/design.md
Normal file
24
.gitea/ISSUE_TEMPLATE/design.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 設計・方針決定
|
||||
about: 実装前の設計議論・方針決定が必要なもの
|
||||
labels: "種別: 設計待ち"
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 問題・背景
|
||||
|
||||
## 検討事項
|
||||
|
||||
## 完了条件
|
||||
|
||||
## 関連
|
||||
24
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
24
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 機能追加
|
||||
about: 新機能・改善提案
|
||||
labels: 機能
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
## 背景・目的
|
||||
|
||||
## 完了条件
|
||||
|
||||
## 関連
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ out/
|
||||
db.sqlite3
|
||||
postgres_data/
|
||||
nul
|
||||
|
||||
*.tsbuildinfo
|
||||
.mcp.json
|
||||
.codex
|
||||
|
||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
1
.serena/memories/project_overview.md
Normal file
1
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1 @@
|
||||
keinasystem_t02 は農業生産者向けの作付け計画・圃場管理システム。主要スタックは Django/DRF/PostgreSQL(PostGIS) のバックエンドと Next.js 14 App Router + TypeScript + Tailwind CSS のフロントエンド。backend/apps に fields, plans, weather, reports, fertilizer, materials, mail があり、frontend/src/app に各画面がある。ドキュメント駆動で、CLAUDE.md と document/*.md が重要な仕様ソース。Windows 環境で Docker Compose による開発を前提としている。
|
||||
1
.serena/memories/style_and_completion.md
Normal file
1
.serena/memories/style_and_completion.md
Normal file
@@ -0,0 +1 @@
|
||||
コードと仕様の変更はドキュメントドリブンで進める。仕様変更時は document 配下や CLAUDE.md の更新が重要。バックエンドは Django/DRF の標準的なモデル・serializer・viewset 構成、フロントは Next.js App Router と TypeScript。完了時は影響範囲に応じて少なくとも関連ドキュメント確認、必要な migration 確認、frontend lint (`npm run lint`) や対象 API/画面の動作確認を行う。既存の dirty worktree は勝手に戻さない。
|
||||
1
.serena/memories/suggested_commands.md
Normal file
1
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1 @@
|
||||
Windows 環境の主要コマンド: `git status`, `rg <pattern>`, `Get-ChildItem`, `Get-Content <file>`, `docker compose -f docker-compose.develop.yml up -d`, `docker compose exec backend python manage.py migrate`, `docker compose exec backend python manage.py makemigrations`, `docker compose exec backend python manage.py runserver 0.0.0.0:8000`, `cd frontend; npm install; npm run dev`, `cd frontend; npm run lint`。開発用 compose では backend は `python manage.py runserver 0.0.0.0:8000`、frontend は `npm run dev` を利用する。
|
||||
149
.serena/project.yml
Normal file
149
.serena/project.yml
Normal 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 read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
22
.vscode/mcp.json
vendored
Normal file
22
.vscode/mcp.json
vendored
Normal 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",
|
||||
"."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
387
CLAUDE.md
387
CLAUDE.md
@@ -1,343 +1,128 @@
|
||||
# Keina System - Claude 向けガイド
|
||||
|
||||
> **最終更新**: 2026-02-21
|
||||
> **現在のフェーズ**: Phase 1 (MVP) - 基本機能実装完了、試験中
|
||||
## プロジェクト概要
|
||||
|
||||
## 📌 このファイルの目的
|
||||
|
||||
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
|
||||
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
|
||||
|
||||
## ⚠️ Claude への重要な指示
|
||||
|
||||
**このファイルは、セッションごとに必ず最初に読んでください。**
|
||||
|
||||
さらに、以下のルールを厳守してください:
|
||||
|
||||
### 📝 更新義務
|
||||
|
||||
**ドキュメントドリブンの徹底**
|
||||
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
|
||||
|
||||
**機能追加・変更時は、必ずこのファイルを更新すること。**
|
||||
|
||||
- ✅ 新機能実装時 → 「実装状況」セクションを更新
|
||||
- ✅ データモデル変更時 → 「データモデル概要」を更新
|
||||
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
|
||||
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
|
||||
- ✅ 問題解決時 → 「トラブルシューティング」に追加
|
||||
- ✅ 更新時は必ず「更新履歴」セクションに記録
|
||||
|
||||
|
||||
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 プロジェクト概要(30秒で理解)
|
||||
|
||||
**何を作っているか:**
|
||||
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
||||
ユーザーは65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理。
|
||||
|
||||
**ユーザー:**
|
||||
65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理
|
||||
**技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
|
||||
|
||||
**技術スタック:**
|
||||
- Backend: Django 5.2 + DRF + PostGIS
|
||||
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
|
||||
- Database: PostgreSQL 16 + PostGIS 3.4
|
||||
|
||||
**開発方針:**
|
||||
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||
**開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||
|
||||
---
|
||||
|
||||
## 📂 プロジェクト構造
|
||||
## 絶対に守るべき制約
|
||||
|
||||
1. **Field ↔ OfficialKyosaiField / OfficialChusankanField は M:N** — 決してFK (1:N) に戻さない
|
||||
2. **年度+圃場の組み合わせは1つの Plan のみ** (`unique_together`)
|
||||
3. **面積**: 表示=反(tan)、計算・保存=m2、変換: 1反=1000m2
|
||||
4. **FertilizationEntry.fertilizer は PROTECT** — 使用中の肥料は削除不可
|
||||
5. **3回同じコードを書くまでは抽象化しない**
|
||||
6. **ドキュメントドリブン**: 仕様変更時はまず関連ドキュメントから更新する
|
||||
|
||||
## コーディング規約
|
||||
|
||||
- **Backend**: Django ベストプラクティス、日本語フィールドは `verbose_name` で対応
|
||||
- **Frontend**: TypeScript strict mode、ESLint に従う
|
||||
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
||||
|
||||
---
|
||||
|
||||
## プロジェクト構造
|
||||
|
||||
```
|
||||
keinasystem_t02/
|
||||
├── CLAUDE.md # このファイル(Claude向けガイド)
|
||||
├── document/ # 詳細設計書(人間向け)
|
||||
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
||||
│ ├── 01_プロダクトビジョン.md
|
||||
│ ├── 02_ユーザーストーリー.md
|
||||
│ ├── 03_データ仕様書.md
|
||||
│ ├── 04_画面設計書.md
|
||||
│ └── 05_実装優先順位.md
|
||||
├── CLAUDE.md # このファイル
|
||||
├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
|
||||
├── document/ # 設計書・マスタードキュメント
|
||||
├── backend/
|
||||
│ ├── keinasystem/ # Django設定
|
||||
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
|
||||
│ │ └── urls.py # ルートURL設定
|
||||
│ ├── keinasystem/ # Django設定 (settings.py, urls.py)
|
||||
│ └── apps/
|
||||
│ ├── fields/ # 圃場管理アプリ
|
||||
│ │ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
|
||||
│ │ ├── views.py # インポート機能、CRUD API
|
||||
│ │ └── urls.py
|
||||
│ ├── plans/ # 作付け計画アプリ
|
||||
│ │ ├── models.py # Plan, Crop, Variety
|
||||
│ │ └── views.py # 作付け計画API、集計API
|
||||
│ └── reports/ # 申請書生成アプリ
|
||||
│ ├── views.py # PDF生成API
|
||||
│ └── templates/ # PDF用HTMLテンプレート
|
||||
└── frontend/
|
||||
└── src/app/
|
||||
├── allocation/ # 作付け計画編集画面(メイン)
|
||||
│ ├── fields/ # 圃場管理(Field, OfficialKyosaiField, OfficialChusankanField)
|
||||
│ ├── plans/ # 作付け計画(Plan, Crop, Variety)
|
||||
│ ├── weather/ # 気象データ(WeatherRecord)
|
||||
│ ├── reports/ # 申請書PDF生成
|
||||
│ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
|
||||
│ ├── workrecords/ # 作業記録索引
|
||||
│ └── mail/ # メールフィルタリング(Windmill連携)
|
||||
└── frontend/src/app/
|
||||
├── allocation/ # 作付け計画編集(メイン画面)
|
||||
├── fields/ # 圃場一覧・詳細
|
||||
├── reports/ # 申請書ダウンロード
|
||||
└── import/ # データ取込画面
|
||||
├── fertilizer/ # 施肥計画・散布実績
|
||||
├── distribution/ # 運搬計画
|
||||
├── weather/ # 気象データ
|
||||
├── reports/ # 申請書DL
|
||||
├── import/ # データ取込
|
||||
├── mail/ # メール管理
|
||||
└── settings/ # パスワード変更
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ データモデル概要
|
||||
|
||||
### コアエンティティ
|
||||
|
||||
```
|
||||
Field (実圃場)
|
||||
├── 39筆の実際の農地
|
||||
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
|
||||
├── group_name, display_order (グループ分け・表示順)
|
||||
└── ManyToMany関係
|
||||
├── kyosai_fields (共済マスタ、M:N)
|
||||
└── chusankan_fields (中山間マスタ、M:N)
|
||||
|
||||
OfficialKyosaiField (共済マスタ)
|
||||
└── 31区画(水稲共済細目書用)
|
||||
|
||||
OfficialChusankanField (中山間マスタ)
|
||||
├── 71区画(中山間地域等直接支払交付金用)
|
||||
└── 17フィールド: c_id, chusankan_flag, oaza, aza, chiban,
|
||||
branch_num, land_type, area, planting_area,
|
||||
original_crop, manager, owner, slope,
|
||||
base_amount, steep_slope_addition, smart_agri_addition,
|
||||
payment_amount
|
||||
|
||||
Plan (作付け計画)
|
||||
├── field (FK to Field)
|
||||
├── year (年度)
|
||||
├── crop (FK to Crop)
|
||||
├── variety (FK to Variety, nullable)
|
||||
└── unique_together = ['field', 'year']
|
||||
|
||||
Crop (作物マスタ)
|
||||
└── 米、トウモロコシ、エンドウ、野菜、その他
|
||||
|
||||
Variety (品種マスタ)
|
||||
├── crop (FK to Crop)
|
||||
├── name (品種名)
|
||||
└── unique_together = ['crop', 'name']
|
||||
```
|
||||
|
||||
### 重要な設計判断
|
||||
|
||||
1. **M:N関係に変更**: 当初はM:1だったが、実運用で「1つの実圃場が複数の申請区画に紐づく」ケースが判明し、ManyToManyに変更(マイグレーション0003で実施)
|
||||
|
||||
2. **面積単位の二重管理**:
|
||||
- DB内部は `area_m2` (整数) で保存
|
||||
- 表示用に `area_tan` (反, Decimal) も保持
|
||||
- 理由: 申請書ではm2、農家の感覚では反
|
||||
|
||||
3. **品種は全作物で統一**:
|
||||
- 「作付けしない」も「その他」作物の品種として扱う
|
||||
- UI操作を統一するため
|
||||
|
||||
4. **グループ機能**:
|
||||
- `group_name` (エリアや用途によるグループ分け)
|
||||
- `display_order` (リスト表示時の順序)
|
||||
- マイグレーション0004で追加
|
||||
|
||||
5. **年度管理の設計方針**(⚠️ Phase 2 で必ず参照):
|
||||
- **作付け計画**: 年度セレクタで独立して来年度も選べる。選んだ年度はlocalStorageに保存して維持
|
||||
- **過去年度**: 「参照モード」として視覚的に区別(背景色・バナー)
|
||||
- **Phase 2 の栽培管理・販売管理**: グローバル作業年度を導入し、基本は今年度に従う
|
||||
- **栽培記録・作業日誌**: 日付中心設計、年度は日付から自動算出
|
||||
- 参考: ソリマチ農業簿記の年度管理方式(明示的に年度を選択、変更するまで固定)
|
||||
|
||||
---
|
||||
|
||||
## 🔑 重要な制約・ルール
|
||||
|
||||
### 絶対に守るべきこと
|
||||
|
||||
1. **データの整合性**
|
||||
- 年度 + 圃場の組み合わせは1つの Plan のみ (`unique_together`)
|
||||
- 作物 + 品種名の組み合わせは一意 (`unique_together`)
|
||||
|
||||
2. **面積の扱い**
|
||||
- 表示: 反 (tan)
|
||||
- 計算・保存: m2
|
||||
- 変換: 1反 = 1000m2 (正確には991.736m2だが、実運用では1000で統一)
|
||||
|
||||
3. **M:N関係の重要性**
|
||||
- Field と OfficialKyosaiField は M:N
|
||||
- Field と OfficialChusankanField は M:N
|
||||
- 決して FK (1:N) に戻さない
|
||||
|
||||
4. **シンプルさ優先**
|
||||
- 過度な抽象化を避ける
|
||||
- 3回同じコードを書くまでは抽象化しない
|
||||
- ユーザーは1人、パフォーマンス最適化は後回し
|
||||
|
||||
### コーディング規約
|
||||
|
||||
- **Backend**: Django のベストプラクティスに従う
|
||||
- **Frontend**: TypeScript strict mode、ESLint に従う
|
||||
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
||||
- **命名**: 日本語のフィールドは `verbose_name` で対応
|
||||
|
||||
---
|
||||
|
||||
## 📍 現在の実装状況
|
||||
|
||||
### ✅ 実装済み(Phase 1 - MVP)
|
||||
|
||||
1. **認証**: JWT認証(アクセストークン24h、リフレッシュトークン7日)
|
||||
2. **圃場管理**:
|
||||
- CRUD API (`/api/fields/`)
|
||||
- ODS/Excelインポート (`/api/fields/import/`)
|
||||
- グループ機能(マイグレーション0004)
|
||||
3. **作付け計画**:
|
||||
- 年度別の作付け計画 CRUD (`/api/plans/?year=2025`)
|
||||
- 前年度コピー機能 (`/api/plans/copy_from_previous_year/`)
|
||||
- 一括更新 (`/api/plans/bulk_update/`)
|
||||
- 集計API (`/api/plans/summary/?year=2025`)
|
||||
4. **申請書生成**:
|
||||
- 水稲共済細目書 PDF (`/api/reports/kyosai/?year=2025`)
|
||||
- 中山間交付金 PDF (`/api/reports/chusankan/?year=2025`)
|
||||
5. **フロントエンド**:
|
||||
- 作付け計画編集画面(集計サイドバー付き)
|
||||
- 圃場一覧・詳細・新規作成
|
||||
- データ取込画面
|
||||
- 申請書ダウンロード画面
|
||||
- ダッシュボード画面(概要サマリー、作物別集計、クイックアクセス)
|
||||
6. **対応付け可視化・紐づけ管理** (E-2):
|
||||
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
|
||||
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
|
||||
- 共通 LinkModal コンポーネント
|
||||
7. **メールフィルタリング機能**(Windmill連携):
|
||||
- Django `apps/mail` アプリ(MailSender, MailEmail, MailNotificationToken)
|
||||
- Windmill向けAPI(APIキー認証): `GET /api/mail/sender-rule/`, `GET /api/mail/sender-context/`, `POST /api/mail/emails/`
|
||||
- フィードバックAPI(認証不要・UUIDトークン): `GET/POST /api/mail/feedback/<token>/`
|
||||
- ルール管理API(JWT認証): `GET/POST/DELETE /api/mail/senders/`
|
||||
- フィードバックページ: `/mail/feedback/[token]`(LINEからタップ一発、認証不要)
|
||||
- ルール管理ページ: `/mail/rules/`
|
||||
- 処理履歴ページ: `/mail/history/`
|
||||
- 対応アカウント: Gmail(有効)、infoseek.jp(Outlook→Gmail転送で対応、To:ヘッダで判別)、Hotmail/Xserver(flow.jsonでenable可能)
|
||||
- 仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
||||
- Windmill フロー: `f/mail/mail_filter`(ローカル: localhost, 本番: windmill.keinafarm.net — 本番デプロイ未実施)
|
||||
|
||||
### 🚧 既知の課題・技術的負債
|
||||
|
||||
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
|
||||
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
||||
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
||||
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
||||
### 🔜 次の実装タスク(優先順)
|
||||
|
||||
差異レポートの全タスク(A-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2)は全件完了。
|
||||
Phase 2 のタスクに進む段階。
|
||||
|
||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
||||
|
||||
### 📅 次のマイルストーン(Phase 2)
|
||||
|
||||
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
|
||||
- 作業予定のカレンダー表示
|
||||
- モバイル対応の改善(スマホでの記録入力)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ よくある作業パターン
|
||||
## よくある作業パターン
|
||||
|
||||
### 新しいモデルを追加する場合
|
||||
|
||||
1. `apps/<app_name>/models.py` にモデルクラスを追加
|
||||
2. `python manage.py makemigrations`
|
||||
3. `python manage.py migrate`
|
||||
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
|
||||
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
|
||||
6. ViewSet 作成 (`apps/<app_name>/views.py`)
|
||||
7. URL登録 (`apps/<app_name>/urls.py`)
|
||||
1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
|
||||
5. Serializer → 6. ViewSet → 7. URL登録
|
||||
|
||||
### 新しいAPI エンドポイントを追加する場合
|
||||
### 新しいAPI / 画面を追加する場合
|
||||
|
||||
1. `apps/<app_name>/views.py` にビューを追加
|
||||
2. `apps/<app_name>/urls.py` にパスを追加
|
||||
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
|
||||
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
|
||||
|
||||
### 新しい画面を追加する場合
|
||||
|
||||
1. `frontend/src/app/<page_name>/page.tsx` を作成
|
||||
2. 必要に応じてレイアウト調整 (`layout.tsx`)
|
||||
3. API呼び出しは `useEffect` + `fetch` で実装
|
||||
4. ローディング状態、エラー状態を適切に処理
|
||||
- API: `views.py` → `urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
|
||||
- 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
|
||||
|
||||
---
|
||||
|
||||
## 🔍 トラブルシューティング
|
||||
|
||||
### マイグレーションエラー
|
||||
## デプロイ・トラブルシューティング
|
||||
|
||||
```bash
|
||||
# マイグレーションをリセット(開発環境のみ!)
|
||||
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
|
||||
# 本番デプロイ(git pull → build → up -d を一括実行)
|
||||
ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
|
||||
|
||||
# 本番ヘルスチェック(9項目、curlベース)
|
||||
bash scripts/check_prod.sh claude keina1234
|
||||
|
||||
# 本番マイグレーション(バックエンド変更時のみ)
|
||||
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
||||
sudo -u keinasystem docker compose build backend && \
|
||||
sudo -u keinasystem docker compose up -d && sleep 5 && \
|
||||
sudo -u keinasystem docker compose exec backend python manage.py migrate'
|
||||
```
|
||||
|
||||
### CORS エラー
|
||||
|
||||
- `backend/keinasystem/settings.py` の `CORS_ALLOWED_ORIGINS` を確認
|
||||
- 現在は `http://localhost:3000` と `http://127.0.0.1:3000` を許可
|
||||
|
||||
### JWT トークンエラー
|
||||
|
||||
- トークンの有効期限を確認(アクセストークン: 24時間)
|
||||
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`)
|
||||
|
||||
### PDF 生成エラー
|
||||
|
||||
- WeasyPrint のインストールを確認
|
||||
- 日本語フォントの設定を確認(HTMLテンプレートのCSS)
|
||||
- **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
|
||||
- **CORS**: `settings.py` の `CORS_ALLOWED_ORIGINS`(localhost:3000 許可済み)
|
||||
- **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
|
||||
|
||||
---
|
||||
|
||||
## 📚 詳細情報へのリンク
|
||||
## マスタードキュメント(機能別リファレンス)
|
||||
|
||||
### マスタードキュメント(機能別の網羅的リファレンス)
|
||||
特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
|
||||
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
|
||||
|
||||
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。**
|
||||
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が
|
||||
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
|
||||
|
||||
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
||||
|
||||
### 設計ドキュメント(プロジェクト横断)
|
||||
|
||||
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
|
||||
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
|
||||
- **データモデル詳細**: `document/03_データ仕様書.md`
|
||||
- **画面設計**: `document/04_画面設計書.md`
|
||||
- **実装手順**: `document/00_Gemini向け統合指示書.md`
|
||||
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
|
||||
| 機能 | ドキュメント |
|
||||
|------|------------|
|
||||
| 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
|
||||
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
|
||||
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
|
||||
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
|
||||
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
|
||||
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
|
||||
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
|
||||
| TODO管理 | `document/19_マスタードキュメント_TODO管理編.md` |
|
||||
| データモデル全体 | `document/03_データ仕様書.md` |
|
||||
|
||||
---
|
||||
|
||||
## 💡 新しいセッションでの推奨フロー
|
||||
## セッション開始・終了フロー
|
||||
|
||||
### 開始時
|
||||
1. この `CLAUDE.md` を読む
|
||||
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md`)
|
||||
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照
|
||||
4. 実装・修正を行う
|
||||
5. 重要な設計判断があれば、この `CLAUDE.md` と該当マスタードキュメントを更新
|
||||
2. `HANDOVER.md` で前回の引き継ぎを確認する
|
||||
3. `TASK_CONTEXT.md` で現在の状況を把握する
|
||||
4. タスク対象の**マスタードキュメント**を読む
|
||||
|
||||
---
|
||||
|
||||
## 📝 更新履歴
|
||||
|
||||
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
||||
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
|
||||
- 2026-02-18: E-2(対応付け可視化・紐づけ管理)仕様追加。画面設計書・差異レポート・次タスク一覧を更新。完了済みタスク(A-8, D-1〜D-4, E-1)を既知の課題から除外
|
||||
- 2026-02-17: ドキュメント一斉更新(差異レポートA〜E反映、CSV→PDF統一、M:N関係、中山間モデル17列化、インライン編集方式、Navbar追加、既知の課題・次タスク一覧追加)
|
||||
- 2026-02-16: 初版作成(ハイブリッドアプローチの方針決定)
|
||||
### 終了時(または作業の区切りで必ず実行)
|
||||
1. `HANDOVER.md` を定型フォーマットで更新する
|
||||
2. 重要な設計判断があれば `CLAUDE.md` と該当マスタードキュメントを更新
|
||||
3. 実装状況に変化があれば `TASK_CONTEXT.md` を更新
|
||||
|
||||
830
CODEX.md
Normal file
830
CODEX.md
Normal 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. 参照すべき既存コード(実装パターンの手本)
|
||||
|
||||
| 目的 | 参照先 |
|
||||
|------|--------|
|
||||
| 施肥計画 ViewSet(perform_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
34
HANDOVER.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Goal
|
||||
Phase 1 全タスク完了後の安定運用。Phase 2 移行準備。
|
||||
|
||||
# Done
|
||||
- 施肥散布実績連携を実装・本番稼働(2026-03-17)
|
||||
- 運搬計画を再設計・本番稼働(2026-03-16)
|
||||
- CLAUDE.md を120行にスリム化、TASK_CONTEXT.md を分離
|
||||
|
||||
# In Progress
|
||||
- なし
|
||||
|
||||
# Pending
|
||||
- Phase 2 設計(栽培履歴管理、カレンダー表示、モバイル対応)
|
||||
- 自動テスト導入
|
||||
- フロントエンドの統一的エラーハンドリング
|
||||
|
||||
# Next Step
|
||||
Phase 2 の最初のタスクを決定する(栽培履歴管理 or カレンダー表示 or モバイル対応)
|
||||
|
||||
# Decisions
|
||||
- CLAUDE.md からデータモデル詳細・実装状況・更新履歴を分離(2026-03-18)
|
||||
- 実装状況は TASK_CONTEXT.md で管理する方針に変更
|
||||
|
||||
# Commands Run
|
||||
なし(ドキュメント整理のみ)
|
||||
|
||||
# Errors / Risks
|
||||
- 認証: ログアウト処理が未実装(トークン破棄のみ)
|
||||
- N+1問題が一部存在(現状はデータ量が少なく問題なし)
|
||||
|
||||
# Do Not Touch
|
||||
- Field ↔ OfficialKyosaiField / OfficialChusankanField の M:N 関係
|
||||
- FertilizationEntry.fertilizer の PROTECT 制約
|
||||
- 旧 is_confirmed / confirmed_at カラム(DB残留、UI未使用 — 将来のマイグレーションで削除予定)
|
||||
57
TASK_CONTEXT.md
Normal file
57
TASK_CONTEXT.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 現在の作業状況
|
||||
|
||||
> **最終更新**: 2026-04-04
|
||||
> **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中
|
||||
|
||||
## 実装済み機能(Phase 1 - MVP)
|
||||
|
||||
1. **認証**: JWT認証(アクセストークン24h、リフレッシュトークン7日)
|
||||
2. **圃場管理**: CRUD、ODS/Excelインポート、グループ機能
|
||||
3. **作付け計画**: 年度別CRUD、前年度コピー、一括更新、集計API
|
||||
4. **申請書生成**: 水稲共済細目書PDF、中山間交付金PDF
|
||||
5. **フロントエンド**: 作付け計画編集、圃場一覧/詳細、データ取込、申請書DL、ダッシュボード
|
||||
6. **対応付け可視化・紐づけ管理** (E-2): 圃場一覧「対応表」モード、共済/中山間リンク管理
|
||||
7. **メールフィルタリング**(Windmill連携):
|
||||
- Django `apps/mail`、Windmill向けAPI(APIキー認証)
|
||||
- フィードバックページ(認証不要・UUIDトークン)、ルール管理、処理履歴
|
||||
- 対応アカウント: Gmail × 2、Xserver × 6(本番稼働中、10分間隔)
|
||||
- To ヘッダー宛先補正実装済み
|
||||
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||
8. **パスワード変更**: `POST /api/auth/change-password/`、`/settings/password`
|
||||
9. **気象データ基盤**(Windmill連携):
|
||||
- Django `apps/weather`(WeatherRecord: 1日1行、2016-01-01〜)
|
||||
- Open-Meteo archive API(窪川)、Windmill毎朝6時同期
|
||||
- API: records, summary, gdd, similarity
|
||||
- フロントエンド `/weather`(年別集計・期間指定、Recharts)
|
||||
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
||||
10. **施肥計画**(本番稼働中):
|
||||
- 自動計算3方式: per_tan / even / nitrogen
|
||||
- 四捨五入トグル、PDF出力(A4横)、PROTECT制約
|
||||
- **散布実績**: 散布日単位記録、在庫USE連携、actual_bags再集計、WorkRecord自動生成
|
||||
- マスタードキュメント: `document/13_マスタードキュメント_施肥計画編.md`
|
||||
11. **運搬計画**(本番稼働中):
|
||||
- 旧 Distribution → Delivery に再設計(年度ベース、施肥計画FK廃止)
|
||||
- 軽トラ1回分単位、グループ一括割り当て、回間移動
|
||||
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
|
||||
12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert
|
||||
13. **田植え計画**(MVP実装):
|
||||
- 年度×品種単位で苗箱枚数・種もみ使用量を計画
|
||||
- 作物単位の種もみ在庫kg、品種単位の反当苗箱枚数デフォルト
|
||||
- 作付け計画から候補圃場を自動取得
|
||||
- マスタードキュメント: `document/16_マスタードキュメント_田植え計画編.md`
|
||||
|
||||
## 既知の課題・技術的負債
|
||||
|
||||
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
|
||||
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
||||
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
||||
4. **パフォーマンス**: N+1問題が一部存在
|
||||
|
||||
## 次のマイルストーン(Phase 2)
|
||||
|
||||
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
|
||||
- 作業予定のカレンダー表示
|
||||
- モバイル対応の改善(スマホでの記録入力)
|
||||
|
||||
差異レポートの全タスク(A-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2)は全件完了。
|
||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照。
|
||||
BIN
all_fertilizer.zip
Normal file
BIN
all_fertilizer.zip
Normal file
Binary file not shown.
52135
all_fertilizer/1.全件.csv
Normal file
52135
all_fertilizer/1.全件.csv
Normal file
File diff suppressed because it is too large
Load Diff
0
backend/apps/fertilizer/__init__.py
Normal file
0
backend/apps/fertilizer/__init__.py
Normal file
75
backend/apps/fertilizer/admin.py
Normal file
75
backend/apps/fertilizer/admin.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from django.contrib import admin
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Fertilizer)
|
||||
class FertilizerAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'maker', 'capacity_kg', 'nitrogen_pct']
|
||||
|
||||
|
||||
class FertilizationEntryInline(admin.TabularInline):
|
||||
model = FertilizationEntry
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(FertilizationPlan)
|
||||
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
|
||||
list_filter = ['year']
|
||||
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]
|
||||
7
backend/apps/fertilizer/apps.py
Normal file
7
backend/apps/fertilizer/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FertilizerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.fertilizer'
|
||||
verbose_name = '施肥計画'
|
||||
67
backend/apps/fertilizer/migrations/0001_initial.py
Normal file
67
backend/apps/fertilizer/migrations/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Generated by Django 5.0 on 2026-03-01 02:50
|
||||
|
||||
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='Fertilizer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='肥料名')),
|
||||
('maker', models.CharField(blank=True, max_length=100, null=True, verbose_name='メーカー')),
|
||||
('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='カリ含有率(%)')),
|
||||
('notes', models.TextField(blank=True, null=True, verbose_name='備考')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '肥料マスタ',
|
||||
'verbose_name_plural': '肥料マスタ',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FertilizationPlan',
|
||||
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='年度')),
|
||||
('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='fertilization_plans', to='plans.variety', verbose_name='品種')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '施肥計画',
|
||||
'verbose_name_plural': '施肥計画',
|
||||
'ordering': ['-year', 'variety'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FertilizationEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bags', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='袋数')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='fertilizer.fertilizationplan')),
|
||||
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '施肥エントリ',
|
||||
'verbose_name_plural': '施肥エントリ',
|
||||
'ordering': ['field', 'fertilizer'],
|
||||
'unique_together': {('plan', 'field', 'fertilizer')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.0 on 2026-03-01 08:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='fertilizationentry',
|
||||
name='fertilizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='計算設定'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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='散布確定日時'),
|
||||
),
|
||||
]
|
||||
127
backend/apps/fertilizer/migrations/0007_delivery_models.py
Normal file
127
backend/apps/fertilizer/migrations/0007_delivery_models.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/fertilizer/migrations/__init__.py
Normal file
0
backend/apps/fertilizer/migrations/__init__.py
Normal file
248
backend/apps/fertilizer/models.py
Normal file
248
backend/apps/fertilizer/models.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Fertilizer(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name='肥料名')
|
||||
maker = models.CharField(max_length=100, blank=True, null=True, verbose_name='メーカー')
|
||||
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='カリ含有率(%)'
|
||||
)
|
||||
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:
|
||||
verbose_name = '肥料マスタ'
|
||||
verbose_name_plural = '肥料マスタ'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class FertilizationPlan(models.Model):
|
||||
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
variety = models.ForeignKey(
|
||||
'plans.Variety', on_delete=models.PROTECT,
|
||||
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)
|
||||
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 FertilizationEntry(models.Model):
|
||||
"""圃場 × 肥料 × 袋数 の中間テーブル"""
|
||||
plan = models.ForeignKey(
|
||||
FertilizationPlan, on_delete=models.CASCADE, related_name='entries'
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field', on_delete=models.CASCADE, verbose_name='圃場'
|
||||
)
|
||||
fertilizer = models.ForeignKey(
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||
actual_bags = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='実績袋数',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '施肥エントリ'
|
||||
verbose_name_plural = '施肥エントリ'
|
||||
unique_together = [['plan', 'field', 'fertilizer']]
|
||||
ordering = ['field', 'fertilizer']
|
||||
|
||||
def __str__(self):
|
||||
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}袋'
|
||||
)
|
||||
492
backend/apps/fertilizer/serializers.py
Normal file
492
backend/apps/fertilizer/serializers.py
Normal file
@@ -0,0 +1,492 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.workrecords.services import sync_delivery_work_record
|
||||
from .models import (
|
||||
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):
|
||||
material_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Fertilizer
|
||||
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):
|
||||
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
|
||||
)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FertilizationEntry
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'actual_bags',
|
||||
]
|
||||
|
||||
|
||||
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
variety_name = serializers.SerializerMethodField()
|
||||
crop_name = serializers.SerializerMethodField()
|
||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
fertilizer_count = serializers.SerializerMethodField()
|
||||
planned_total_bags = serializers.SerializerMethodField()
|
||||
spread_total_bags = serializers.SerializerMethodField()
|
||||
remaining_total_bags = serializers.SerializerMethodField()
|
||||
spread_status = serializers.SerializerMethodField()
|
||||
is_confirmed = serializers.BooleanField(read_only=True)
|
||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||
is_variety_change_plan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'calc_settings',
|
||||
'entries',
|
||||
'field_count',
|
||||
'fertilizer_count',
|
||||
'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):
|
||||
return obj.variety.name
|
||||
|
||||
def get_crop_name(self, obj):
|
||||
return obj.variety.crop.name
|
||||
|
||||
def get_field_count(self, obj):
|
||||
return obj.entries.values('field').distinct().count()
|
||||
|
||||
def get_fertilizer_count(self, obj):
|
||||
return obj.entries.values('fertilizer').distinct().count()
|
||||
|
||||
def get_planned_total_bags(self, obj):
|
||||
total = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_spread_total_bags(self, obj):
|
||||
total = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_remaining_total_bags(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(planned - actual)
|
||||
|
||||
def get_spread_status(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
if actual <= 0:
|
||||
return 'unspread'
|
||||
if actual > planned:
|
||||
return 'over_applied'
|
||||
if actual < planned:
|
||||
return 'partial'
|
||||
return 'completed'
|
||||
|
||||
def get_is_variety_change_plan(self, obj):
|
||||
return obj.name.endswith('(品種変更移動)')
|
||||
|
||||
|
||||
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = ['id', 'name', 'year', 'variety', 'calc_settings', 'entries']
|
||||
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = FertilizationPlan.objects.create(**validated_data)
|
||||
pairs = self._save_entries(plan, entries_data)
|
||||
sync_actual_bags_for_pairs(plan.year, pairs)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
entries_data = validated_data.pop('entries', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
pairs = self._save_entries(instance, entries_data)
|
||||
sync_actual_bags_for_pairs(instance.year, pairs)
|
||||
return instance
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
pairs = set()
|
||||
for entry in entries_data:
|
||||
pairs.add((entry['field_id'], entry['fertilizer_id']))
|
||||
FertilizationEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
fertilizer_id=entry['fertilizer_id'],
|
||||
bags=entry['bags'],
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(source='field.id', read_only=True)
|
||||
name = serializers.CharField(source='field.name', read_only=True)
|
||||
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
|
||||
196
backend/apps/fertilizer/services.py
Normal file
196
backend/apps/fertilizer/services.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
|
||||
from apps.materials.stock_service import create_reserves_for_plan, delete_reserves_for_plan
|
||||
from apps.materials.models import StockTransaction
|
||||
from apps.workrecords.services import sync_spreading_work_record
|
||||
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem
|
||||
|
||||
|
||||
class FertilizationPlanMergeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FertilizationPlanMergeConflict(FertilizationPlanMergeError):
|
||||
def __init__(self, conflicts):
|
||||
super().__init__('merge conflict')
|
||||
self.conflicts = conflicts
|
||||
|
||||
|
||||
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
|
||||
pairs = {
|
||||
(int(field_id), int(fertilizer_id))
|
||||
for field_id, fertilizer_id in field_fertilizer_pairs
|
||||
}
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
for field_id, fertilizer_id in pairs:
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=year,
|
||||
field_id=field_id,
|
||||
fertilizer_id=fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
FertilizationEntry.objects.filter(
|
||||
plan__year=year,
|
||||
field_id=field_id,
|
||||
fertilizer_id=fertilizer_id,
|
||||
).update(actual_bags=total)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_spreading_session_side_effects(session, field_fertilizer_pairs):
|
||||
sync_actual_bags_for_pairs(session.year, field_fertilizer_pairs)
|
||||
sync_stock_uses_for_spreading_session(session)
|
||||
sync_spreading_work_record(session)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_stock_uses_for_spreading_session(session):
|
||||
StockTransaction.objects.filter(spreading_item__session=session).delete()
|
||||
|
||||
session_items = session.items.select_related('fertilizer__material')
|
||||
for item in session_items:
|
||||
material = getattr(item.fertilizer, 'material', None)
|
||||
if material is None:
|
||||
continue
|
||||
StockTransaction.objects.create(
|
||||
material=material,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
quantity=item.actual_bags,
|
||||
occurred_on=session.date,
|
||||
note=f'散布実績「{session.name.strip() or session.date}」',
|
||||
fertilization_plan=None,
|
||||
spreading_item=item,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def move_fertilization_entries_for_variety_change(change):
|
||||
moved_count = 0
|
||||
old_variety_id = change.old_variety_id
|
||||
new_variety = change.new_variety
|
||||
if old_variety_id is None or new_variety is None:
|
||||
return 0
|
||||
|
||||
old_plans = (
|
||||
FertilizationPlan.objects
|
||||
.filter(
|
||||
year=change.year,
|
||||
variety_id=old_variety_id,
|
||||
entries__field_id=change.field_id,
|
||||
)
|
||||
.distinct()
|
||||
.prefetch_related('entries')
|
||||
)
|
||||
|
||||
for old_plan in old_plans:
|
||||
entries_to_move = list(
|
||||
old_plan.entries.filter(
|
||||
field_id=change.field_id,
|
||||
).order_by('id')
|
||||
)
|
||||
if not entries_to_move:
|
||||
continue
|
||||
|
||||
new_plan = FertilizationPlan.objects.create(
|
||||
name=f'{change.year}年度 {new_variety.name} 施肥計画(品種変更移動)',
|
||||
year=change.year,
|
||||
variety=new_variety,
|
||||
calc_settings=old_plan.calc_settings,
|
||||
)
|
||||
|
||||
FertilizationEntry.objects.filter(
|
||||
id__in=[entry.id for entry in entries_to_move]
|
||||
).update(plan=new_plan)
|
||||
|
||||
create_reserves_for_plan(old_plan)
|
||||
create_reserves_for_plan(new_plan)
|
||||
moved_count += len(entries_to_move)
|
||||
|
||||
return moved_count
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def merge_fertilization_plan_into(source_plan, target_plan):
|
||||
if source_plan.id == target_plan.id:
|
||||
raise FertilizationPlanMergeError('同じ施肥計画にはマージできません。')
|
||||
if source_plan.year != target_plan.year:
|
||||
raise FertilizationPlanMergeError('年度が異なる施肥計画にはマージできません。')
|
||||
if source_plan.variety_id != target_plan.variety_id:
|
||||
raise FertilizationPlanMergeError('品種が異なる施肥計画にはマージできません。')
|
||||
if source_plan.is_confirmed or target_plan.is_confirmed:
|
||||
raise FertilizationPlanMergeError('散布確定済みの施肥計画はマージできません。')
|
||||
|
||||
source_entries = list(
|
||||
source_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
|
||||
)
|
||||
if not source_entries:
|
||||
raise FertilizationPlanMergeError('移動元の施肥計画にマージ対象の entry がありません。')
|
||||
|
||||
source_pairs = {(entry.field_id, entry.fertilizer_id) for entry in source_entries}
|
||||
target_entries = list(
|
||||
target_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
|
||||
)
|
||||
target_pairs = {(entry.field_id, entry.fertilizer_id): entry for entry in target_entries}
|
||||
|
||||
conflicts = [
|
||||
{
|
||||
'field_id': entry.field_id,
|
||||
'field_name': entry.field.name,
|
||||
'fertilizer_id': entry.fertilizer_id,
|
||||
'fertilizer_name': entry.fertilizer.name,
|
||||
}
|
||||
for entry in source_entries
|
||||
if (entry.field_id, entry.fertilizer_id) in target_pairs
|
||||
]
|
||||
if conflicts:
|
||||
raise FertilizationPlanMergeConflict(conflicts)
|
||||
|
||||
FertilizationEntry.objects.filter(
|
||||
id__in=[entry.id for entry in source_entries]
|
||||
).update(plan=target_plan)
|
||||
|
||||
target_plan.calc_settings = _merge_calc_settings(
|
||||
target_plan.calc_settings,
|
||||
source_plan.calc_settings,
|
||||
)
|
||||
target_plan.save()
|
||||
|
||||
create_reserves_for_plan(target_plan)
|
||||
|
||||
moved_count = len(source_entries)
|
||||
deleted_source_plan = False
|
||||
if not FertilizationEntry.objects.filter(plan=source_plan).exists():
|
||||
delete_reserves_for_plan(source_plan)
|
||||
source_plan.delete()
|
||||
deleted_source_plan = True
|
||||
else:
|
||||
create_reserves_for_plan(source_plan)
|
||||
|
||||
return {
|
||||
'moved_entry_count': moved_count,
|
||||
'deleted_source_plan': deleted_source_plan,
|
||||
}
|
||||
|
||||
|
||||
def _merge_calc_settings(target_settings, source_settings):
|
||||
merged = list(target_settings or [])
|
||||
existing_fertilizer_ids = {
|
||||
setting.get('fertilizer_id')
|
||||
for setting in merged
|
||||
if isinstance(setting, dict)
|
||||
}
|
||||
for setting in source_settings or []:
|
||||
if not isinstance(setting, dict):
|
||||
continue
|
||||
fertilizer_id = setting.get('fertilizer_id')
|
||||
if fertilizer_id in existing_fertilizer_ids:
|
||||
continue
|
||||
merged.append(setting)
|
||||
existing_fertilizer_ids.add(fertilizer_id)
|
||||
return merged
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
58
backend/apps/fertilizer/templates/fertilizer/pdf.html
Normal file
58
backend/apps/fertilizer/templates/fertilizer/pdf.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 15mm; }
|
||||
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: 10pt; color: #555; margin-bottom: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
|
||||
th { background: #e8f5e9; text-align: center; }
|
||||
.col-name { text-align: left; }
|
||||
.col-area { text-align: right; }
|
||||
tr.total-row { font-weight: bold; background: #f5f5f5; }
|
||||
.zero { color: #bbb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>施肥計画書</h1>
|
||||
<p class="subtitle">{{ plan.year }}年度 {{ plan.variety.crop.name }} / {{ plan.variety.name }} 「{{ plan.name }}」</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">圃場名</th>
|
||||
<th class="col-area">面積(反)</th>
|
||||
{% for fert in fertilizers %}
|
||||
<th>{{ fert.name }}<br><small>(袋)</small></th>
|
||||
{% endfor %}
|
||||
<th>合計袋数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="col-name">{{ row.field.name }}</td>
|
||||
<td class="col-area">{{ 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 %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td class="col-name">合計</td>
|
||||
<td></td>
|
||||
{% for total in fert_totals %}
|
||||
<td>{{ total }}</td>
|
||||
{% endfor %}
|
||||
<td>{{ grand_total }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
0
backend/apps/fertilizer/templatetags/__init__.py
Normal file
0
backend/apps/fertilizer/templatetags/__init__.py
Normal file
15
backend/apps/fertilizer/templatetags/fertilizer_tags.py
Normal file
15
backend/apps/fertilizer/templatetags/fertilizer_tags.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from decimal import Decimal
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def bags_fmt(value):
|
||||
"""袋数を整数 or 小数点以下1桁で表示する。"""
|
||||
if value is None or value == '':
|
||||
return value
|
||||
d = Decimal(str(value))
|
||||
if d == d.to_integral_value():
|
||||
return str(int(d))
|
||||
return str(d.quantize(Decimal('0.1')))
|
||||
156
backend/apps/fertilizer/tests.py
Normal file
156
backend/apps/fertilizer/tests.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import Material, StockTransaction
|
||||
from apps.materials.stock_service import create_reserves_for_plan
|
||||
from apps.plans.models import Crop, Variety
|
||||
|
||||
from .models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||
|
||||
|
||||
class FertilizationPlanMergeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username='merge-user',
|
||||
password='secret12345',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
crop = Crop.objects.create(name='水稲')
|
||||
self.variety = Variety.objects.create(crop=crop, name='たちはるか特栽')
|
||||
self.field_a = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
self.field_b = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
material_a = Material.objects.create(
|
||||
name='高度化成14号',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
material_b = Material.objects.create(
|
||||
name='分げつ一発',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
self.fertilizer_a = Fertilizer.objects.create(name='高度化成14号', material=material_a)
|
||||
self.fertilizer_b = Fertilizer.objects.create(name='分げつ一発', material=material_b)
|
||||
|
||||
def test_merge_into_moves_entries_and_deletes_empty_source_plan(self):
|
||||
target_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 元肥',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
calc_settings=[{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'}],
|
||||
)
|
||||
source_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
calc_settings=[{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'}],
|
||||
)
|
||||
target_entry = FertilizationEntry.objects.create(
|
||||
plan=target_plan,
|
||||
field=self.field_a,
|
||||
fertilizer=self.fertilizer_a,
|
||||
bags='3.00',
|
||||
actual_bags='1.0000',
|
||||
)
|
||||
source_entry = FertilizationEntry.objects.create(
|
||||
plan=source_plan,
|
||||
field=self.field_b,
|
||||
fertilizer=self.fertilizer_b,
|
||||
bags='2.00',
|
||||
actual_bags='2.0000',
|
||||
)
|
||||
create_reserves_for_plan(target_plan)
|
||||
create_reserves_for_plan(source_plan)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
|
||||
{'target_plan_id': target_plan.id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['moved_entry_count'], 1)
|
||||
self.assertTrue(response.data['deleted_source_plan'])
|
||||
|
||||
source_entry.refresh_from_db()
|
||||
self.assertEqual(source_entry.plan_id, target_plan.id)
|
||||
self.assertFalse(FertilizationPlan.objects.filter(id=source_plan.id).exists())
|
||||
|
||||
target_plan.refresh_from_db()
|
||||
self.assertEqual(
|
||||
target_plan.calc_settings,
|
||||
[
|
||||
{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'},
|
||||
{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'},
|
||||
],
|
||||
)
|
||||
reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=target_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
self.assertEqual(len(reserves), 2)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in reserves},
|
||||
{
|
||||
(self.fertilizer_a.material_id, Decimal(str(target_entry.bags))),
|
||||
(self.fertilizer_b.material_id, Decimal(str(source_entry.bags))),
|
||||
},
|
||||
)
|
||||
|
||||
def test_merge_into_stops_on_field_fertilizer_conflict(self):
|
||||
target_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 元肥',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
)
|
||||
source_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
|
||||
year=2026,
|
||||
variety=self.variety,
|
||||
)
|
||||
FertilizationEntry.objects.create(
|
||||
plan=target_plan,
|
||||
field=self.field_a,
|
||||
fertilizer=self.fertilizer_a,
|
||||
bags='3.00',
|
||||
)
|
||||
source_entry = FertilizationEntry.objects.create(
|
||||
plan=source_plan,
|
||||
field=self.field_a,
|
||||
fertilizer=self.fertilizer_a,
|
||||
bags='2.00',
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
|
||||
{'target_plan_id': target_plan.id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(len(response.data['conflicts']), 1)
|
||||
source_entry.refresh_from_db()
|
||||
self.assertEqual(source_entry.plan_id, source_plan.id)
|
||||
self.assertTrue(FertilizationPlan.objects.filter(id=source_plan.id).exists())
|
||||
16
backend/apps/fertilizer/urls.py
Normal file
16
backend/apps/fertilizer/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
||||
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||
path('spreading/candidates/', views.SpreadingCandidatesView.as_view(), name='spreading-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
648
backend/apps/fertilizer/views.py
Normal file
648
backend/apps/fertilizer/views.py
Normal file
@@ -0,0 +1,648 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from weasyprint import HTML
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.stock_service import (
|
||||
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 (
|
||||
FertilizerSerializer,
|
||||
FertilizationPlanSerializer,
|
||||
FertilizationPlanWriteSerializer,
|
||||
DeliveryPlanListSerializer,
|
||||
DeliveryPlanReadSerializer,
|
||||
DeliveryPlanWriteSerializer,
|
||||
SpreadingSessionSerializer,
|
||||
SpreadingSessionWriteSerializer,
|
||||
)
|
||||
from .services import (
|
||||
FertilizationPlanMergeConflict,
|
||||
FertilizationPlanMergeError,
|
||||
merge_fertilization_plan_into,
|
||||
sync_actual_bags_for_pairs,
|
||||
)
|
||||
|
||||
|
||||
class FertilizerViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = Fertilizer.objects.all()
|
||||
serializer_class = FertilizerSerializer
|
||||
|
||||
|
||||
class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
||||
'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
|
||||
)
|
||||
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 FertilizationPlanWriteSerializer
|
||||
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'])
|
||||
def pdf(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
entries = plan.entries.select_related('field', 'fertilizer').order_by(
|
||||
'field__display_order', 'field__id', 'fertilizer__name'
|
||||
)
|
||||
|
||||
# 圃場・肥料の一覧を整理
|
||||
fields_map = {}
|
||||
fertilizers_map = {}
|
||||
for entry in entries:
|
||||
fields_map[entry.field_id] = entry.field
|
||||
fertilizers_map[entry.fertilizer_id] = entry.fertilizer
|
||||
|
||||
fields = sorted(fields_map.values(), key=lambda f: (f.display_order, f.id))
|
||||
fertilizers = sorted(fertilizers_map.values(), key=lambda f: f.name)
|
||||
|
||||
# マトリクスデータ生成
|
||||
matrix = {}
|
||||
for entry in entries:
|
||||
matrix[(entry.field_id, entry.fertilizer_id)] = entry.bags
|
||||
|
||||
rows = []
|
||||
for field in fields:
|
||||
cells = [matrix.get((field.id, fert.id), '') for fert in fertilizers]
|
||||
total = sum(v for v in cells if v != '')
|
||||
rows.append({
|
||||
'field': field,
|
||||
'cells': cells,
|
||||
'total': total,
|
||||
})
|
||||
|
||||
# 肥料ごとの合計
|
||||
fert_totals = []
|
||||
for fert in fertilizers:
|
||||
total = sum(
|
||||
matrix.get((field.id, fert.id), Decimal('0'))
|
||||
for field in fields
|
||||
)
|
||||
fert_totals.append(total)
|
||||
|
||||
context = {
|
||||
'plan': plan,
|
||||
'fertilizers': fertilizers,
|
||||
'rows': rows,
|
||||
'fert_totals': fert_totals,
|
||||
'grand_total': sum(fert_totals),
|
||||
}
|
||||
html_string = render_to_string('fertilizer/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="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||
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):
|
||||
"""作付け計画から圃場候補を返す"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(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': f.id,
|
||||
'name': f.name,
|
||||
'area_tan': str(f.area_tan),
|
||||
'area_m2': f.area_m2,
|
||||
'group_name': f.group_name,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
|
||||
class CalculateView(APIView):
|
||||
"""自動計算(保存しない)"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
method = request.data.get('method') # 'nitrogen' | 'even' | 'per_tan'
|
||||
param = request.data.get('param') # 数値パラメータ
|
||||
fertilizer_id = request.data.get('fertilizer_id')
|
||||
field_ids = request.data.get('field_ids', [])
|
||||
|
||||
if not method or param is None or not field_ids:
|
||||
return Response({'error': 'method, param, field_ids が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
param = Decimal(str(param))
|
||||
except InvalidOperation:
|
||||
return Response({'error': 'param は数値で指定してください'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
fields = Field.objects.filter(id__in=field_ids)
|
||||
if not fields.exists():
|
||||
return Response({'error': '圃場が見つかりません'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
results = []
|
||||
|
||||
if method == 'per_tan':
|
||||
# 反当袋数配分: S = Sa × A
|
||||
for field in fields:
|
||||
area = Decimal(str(field.area_tan))
|
||||
bags = (param * area).quantize(Decimal('0.01'))
|
||||
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||
|
||||
elif method == 'even':
|
||||
# 在庫/指定数量均等配分: S = (SS / Sum(A)) × A
|
||||
total_area = sum(Decimal(str(f.area_tan)) for f in fields)
|
||||
if total_area == 0:
|
||||
return Response({'error': '圃場の面積が0です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
for field in fields:
|
||||
area = Decimal(str(field.area_tan))
|
||||
bags = (param * area / total_area).quantize(Decimal('0.01'))
|
||||
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||
|
||||
elif method == 'nitrogen':
|
||||
# 反当チッソ成分量配分: S = (Nr / (C × Nd/100)) × A
|
||||
if not fertilizer_id:
|
||||
return Response({'error': 'nitrogen 方式には fertilizer_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
fertilizer = Fertilizer.objects.get(id=fertilizer_id)
|
||||
except Fertilizer.DoesNotExist:
|
||||
return Response({'error': '肥料が見つかりません'}, status=status.HTTP_404_NOT_FOUND)
|
||||
if not fertilizer.capacity_kg or not fertilizer.nitrogen_pct:
|
||||
return Response(
|
||||
{'error': 'この肥料には1袋重量(kg)と窒素含有率(%)の登録が必要です'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
c = Decimal(str(fertilizer.capacity_kg))
|
||||
nd = Decimal(str(fertilizer.nitrogen_pct))
|
||||
# 1袋あたりの窒素量 (kg)
|
||||
nc = c * nd / Decimal('100')
|
||||
if nc == 0:
|
||||
return Response({'error': '窒素含有量が0のため計算できません'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
for field in fields:
|
||||
area = Decimal(str(field.area_tan))
|
||||
bags = (param / nc * area).quantize(Decimal('0.01'))
|
||||
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||
|
||||
else:
|
||||
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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)
|
||||
1
backend/apps/levee_work/__init__.py
Normal file
1
backend/apps/levee_work/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
backend/apps/levee_work/admin.py
Normal file
17
backend/apps/levee_work/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemInline(admin.TabularInline):
|
||||
model = LeveeWorkSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(LeveeWorkSession)
|
||||
class LeveeWorkSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['date', 'title', 'year', 'created_at']
|
||||
list_filter = ['year', 'date']
|
||||
search_fields = ['title', 'items__field__name']
|
||||
inlines = [LeveeWorkSessionItemInline]
|
||||
|
||||
8
backend/apps/levee_work/apps.py
Normal file
8
backend/apps/levee_work/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LeveeWorkConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.levee_work'
|
||||
verbose_name = '畔塗作業'
|
||||
|
||||
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2 on 2026-04-04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0004_crop_base_temp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='畔塗日')),
|
||||
('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗記録',
|
||||
'verbose_name_plural': '畔塗記録',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
|
||||
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗対象圃場',
|
||||
'verbose_name_plural': '畔塗対象圃場',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('session', 'field')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
1
backend/apps/levee_work/migrations/__init__.py
Normal file
1
backend/apps/levee_work/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
59
backend/apps/levee_work/models.py
Normal file
59
backend/apps/levee_work/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class LeveeWorkSession(models.Model):
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='畔塗日')
|
||||
title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗記録'
|
||||
verbose_name_plural = '畔塗記録'
|
||||
ordering = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.date} {self.title}'
|
||||
|
||||
|
||||
class LeveeWorkSessionItem(models.Model):
|
||||
session = models.ForeignKey(
|
||||
LeveeWorkSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='畔塗記録',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field',
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name='圃場',
|
||||
)
|
||||
plan = models.ForeignKey(
|
||||
'plans.Plan',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='+',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
|
||||
variety_name_snapshot = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='品種名スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗対象圃場'
|
||||
verbose_name_plural = '畔塗対象圃場'
|
||||
unique_together = [['session', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.session} / {self.field.name}'
|
||||
|
||||
149
backend/apps/levee_work/serializers.py
Normal file
149
backend/apps/levee_work/serializers.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from django.db import transaction
|
||||
from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from apps.workrecords.services import sync_levee_work_record
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'group_name',
|
||||
'plan',
|
||||
'crop_name_snapshot',
|
||||
'variety_name_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
item_count = serializers.SerializerMethodField()
|
||||
total_area_tan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'title',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'item_count',
|
||||
'total_area_tan',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_item_count(self, obj):
|
||||
return len(obj.items.all())
|
||||
|
||||
def get_total_area_tan(self, obj):
|
||||
total = sum((item.field.area_tan or Decimal('0')) for item in obj.items.all())
|
||||
return str(total)
|
||||
|
||||
|
||||
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field = serializers.IntegerField()
|
||||
plan = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
|
||||
|
||||
def validate(self, attrs):
|
||||
year = attrs.get('year', getattr(self.instance, 'year', None))
|
||||
date = attrs.get('date', getattr(self.instance, 'date', None))
|
||||
if year is not None and date is not None and year != date.year:
|
||||
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
|
||||
return attrs
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
key = item['field']
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
|
||||
session = LeveeWorkSession.objects.create(**validated_data)
|
||||
self._replace_items(session, items_data)
|
||||
sync_levee_work_record(session)
|
||||
return session
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', None)
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'title':
|
||||
value = (value or '').strip() or '水稲畔塗'
|
||||
setattr(instance, attr, value)
|
||||
if 'title' not in validated_data:
|
||||
instance.title = (instance.title or '').strip() or '水稲畔塗'
|
||||
instance.save()
|
||||
if items_data is not None:
|
||||
self._replace_items(instance, items_data)
|
||||
sync_levee_work_record(instance)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
for item in items_data:
|
||||
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field'],
|
||||
plan=plan,
|
||||
crop_name_snapshot=plan.crop.name,
|
||||
variety_name_snapshot=plan.variety.name if plan.variety else '',
|
||||
)
|
||||
|
||||
def _resolve_plan(self, year, field_id, plan_id):
|
||||
queryset = Plan.objects.select_related('crop', 'variety').filter(
|
||||
year=year,
|
||||
field_id=field_id,
|
||||
crop__name='水稲',
|
||||
)
|
||||
if plan_id is not None:
|
||||
try:
|
||||
return queryset.get(id=plan_id)
|
||||
except Plan.DoesNotExist as exc:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
|
||||
) from exc
|
||||
|
||||
plan = queryset.first()
|
||||
if plan is None:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
|
||||
)
|
||||
return plan
|
||||
58
backend/apps/levee_work/tests.py
Normal file
58
backend/apps/levee_work/tests.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.plans.models import Crop, Plan, Variety
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
from .serializers import LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializerTests(TestCase):
|
||||
def test_total_area_tan_is_included(self):
|
||||
crop = Crop.objects.create(name='水稲')
|
||||
variety = Variety.objects.create(crop=crop, name='にこまる')
|
||||
field_a = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
field_b = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
plan_a = Plan.objects.create(field=field_a, year=2026, crop=crop, variety=variety, notes='')
|
||||
plan_b = Plan.objects.create(field=field_b, year=2026, crop=crop, variety=variety, notes='')
|
||||
session = LeveeWorkSession.objects.create(
|
||||
year=2026,
|
||||
date='2026-04-06',
|
||||
title='水稲畔塗',
|
||||
notes='',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_a,
|
||||
plan=plan_a,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_b,
|
||||
plan=plan_b,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
|
||||
data = LeveeWorkSessionSerializer(session).data
|
||||
|
||||
self.assertEqual(data['item_count'], 2)
|
||||
self.assertEqual(data['total_area_tan'], '2.0000')
|
||||
13
backend/apps/levee_work/urls.py
Normal file
13
backend/apps/levee_work/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
70
backend/apps/levee_work/views.py
Normal file
70
backend/apps/levee_work/views.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from .models import LeveeWorkSession
|
||||
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = LeveeWorkSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__plan',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return LeveeWorkSessionWriteSerializer
|
||||
return LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
plans = (
|
||||
Plan.objects.select_related('field', 'crop', 'variety')
|
||||
.filter(year=year, crop__name='水稲')
|
||||
.order_by('field__display_order', 'field__id')
|
||||
)
|
||||
|
||||
data = [
|
||||
{
|
||||
'field_id': plan.field_id,
|
||||
'field_name': plan.field.name,
|
||||
'field_area_tan': str(plan.field.area_tan),
|
||||
'group_name': plan.field.group_name,
|
||||
'plan_id': plan.id,
|
||||
'crop_name': plan.crop.name,
|
||||
'variety_name': plan.variety.name if plan.variety else '',
|
||||
'selected': True,
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
@@ -0,0 +1,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='アカウント',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -42,10 +42,16 @@ class MailSender(models.Model):
|
||||
|
||||
|
||||
ACCOUNT_CHOICES = [
|
||||
('xserver', 'Xserver'),
|
||||
('gmail', 'Gmail'),
|
||||
('hotmail', 'Hotmail'),
|
||||
('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 = [
|
||||
@@ -105,3 +111,4 @@ class MailNotificationToken(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return str(self.token)
|
||||
|
||||
|
||||
1
backend/apps/materials/__init__.py
Normal file
1
backend/apps/materials/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
backend/apps/materials/admin.py
Normal file
28
backend/apps/materials/admin.py
Normal 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']
|
||||
8
backend/apps/materials/apps.py
Normal file
8
backend/apps/materials/apps.py
Normal 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 = '資材管理'
|
||||
|
||||
87
backend/apps/materials/migrations/0001_initial.py
Normal file
87
backend/apps/materials/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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='施肥計画',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0002_stocktransaction_fertilization_plan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='transaction_type',
|
||||
field=models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('reserve', '引当'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 10:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0003_stocktransaction_spreading_item_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
]
|
||||
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0004_fix_spreading_item_on_delete'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='material',
|
||||
name='material_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('fertilizer', '肥料'),
|
||||
('pesticide', '農薬'),
|
||||
('seed', '種子'),
|
||||
('seedling', '種苗'),
|
||||
('other', 'その他'),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name='資材種別',
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/materials/migrations/__init__.py
Normal file
1
backend/apps/materials/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
229
backend/apps/materials/models.py
Normal file
229
backend/apps/materials/models.py
Normal 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}'
|
||||
)
|
||||
225
backend/apps/materials/serializers.py
Normal file
225
backend/apps/materials/serializers.py
Normal 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)
|
||||
97
backend/apps/materials/stock_service.py
Normal file
97
backend/apps/materials/stock_service.py
Normal 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')
|
||||
18
backend/apps/materials/urls.py
Normal file
18
backend/apps/materials/urls.py
Normal 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'),
|
||||
]
|
||||
191
backend/apps/materials/views.py
Normal file
191
backend/apps/materials/views.py
Normal 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,
|
||||
}
|
||||
18
backend/apps/plans/migrations/0004_crop_base_temp.py
Normal file
18
backend/apps/plans/migrations/0004_crop_base_temp.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0 on 2026-02-28 04:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0003_variety_on_delete_set_null'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='crop',
|
||||
name='base_temp',
|
||||
field=models.FloatField(default=0.0, verbose_name='有効積算温度 基準温度(℃)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.2 on 2026-04-04 00:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0004_crop_base_temp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='crop',
|
||||
name='seed_inventory_kg',
|
||||
field=models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='種もみ在庫(kg)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='variety',
|
||||
name='default_seedling_boxes_per_tan',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数デフォルト'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RiceTransplantPlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='計画名')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('default_seed_grams_per_box', models.DecimalField(decimal_places=2, default=0, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)デフォルト')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rice_transplant_plans', to='plans.variety', verbose_name='品種')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '田植え計画',
|
||||
'verbose_name_plural': '田植え計画',
|
||||
'ordering': ['-year', 'variety'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RiceTransplantEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('seedling_boxes_per_tan', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='反当苗箱枚数')),
|
||||
('seed_grams_per_box', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rice_transplant_entries', to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='plans.ricetransplantplan', verbose_name='田植え計画')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '田植え計画エントリ',
|
||||
'verbose_name_plural': '田植え計画エントリ',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('plan', 'field')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='ricetransplantentry',
|
||||
old_name='seedling_boxes_per_tan',
|
||||
new_name='installed_seedling_boxes',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ricetransplantplan',
|
||||
name='seedling_boxes_per_tan',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数'),
|
||||
),
|
||||
]
|
||||
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0005_material_seed_type'),
|
||||
('plans', '0007_ricetransplantplan_seedling_boxes_per_tan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='variety',
|
||||
name='seed_material',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='varieties',
|
||||
to='materials.material',
|
||||
verbose_name='種子在庫資材',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plans', '0008_variety_seed_material'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ricetransplantentry',
|
||||
name='installed_seedling_boxes',
|
||||
field=models.DecimalField(
|
||||
decimal_places=2,
|
||||
max_digits=8,
|
||||
verbose_name='設置苗箱枚数',
|
||||
),
|
||||
),
|
||||
]
|
||||
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0009_alter_ricetransplantentry_installed_seedling_boxes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlanVarietyChange',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='作付年度')),
|
||||
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='変更日時')),
|
||||
('reason', models.TextField(blank=True, default='', verbose_name='変更理由')),
|
||||
('fertilizer_moved_entry_count', models.IntegerField(default=0, verbose_name='施肥移動エントリ数')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='plan_variety_changes', to='fields.field', verbose_name='圃場')),
|
||||
('new_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='new_plan_variety_changes', to='plans.variety', verbose_name='変更後品種')),
|
||||
('old_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='old_plan_variety_changes', to='plans.variety', verbose_name='変更前品種')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variety_changes', to='plans.plan', verbose_name='作付け計画')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '作付け計画品種変更履歴',
|
||||
'verbose_name_plural': '作付け計画品種変更履歴',
|
||||
'ordering': ['-changed_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,13 @@ from apps.fields.models import Field
|
||||
|
||||
class Crop(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
||||
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
|
||||
seed_inventory_kg = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=3,
|
||||
default=0,
|
||||
verbose_name="種もみ在庫(kg)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "作物マスタ"
|
||||
@@ -16,6 +23,21 @@ class Crop(models.Model):
|
||||
class Variety(models.Model):
|
||||
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
||||
name = models.CharField(max_length=100, verbose_name="品種名")
|
||||
default_seedling_boxes_per_tan = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="反当苗箱枚数デフォルト",
|
||||
)
|
||||
seed_material = models.ForeignKey(
|
||||
'materials.Material',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='varieties',
|
||||
verbose_name='種子在庫資材',
|
||||
blank=True,
|
||||
null=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "品種マスタ"
|
||||
@@ -41,3 +63,116 @@ class Plan(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.field.name} - {self.year} - {self.crop.name}"
|
||||
|
||||
|
||||
class PlanVarietyChange(models.Model):
|
||||
field = models.ForeignKey(
|
||||
Field,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='plan_variety_changes',
|
||||
verbose_name='圃場',
|
||||
)
|
||||
year = models.IntegerField(verbose_name='作付年度')
|
||||
plan = models.ForeignKey(
|
||||
Plan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='variety_changes',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
changed_at = models.DateTimeField(auto_now_add=True, verbose_name='変更日時')
|
||||
old_variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='old_plan_variety_changes',
|
||||
verbose_name='変更前品種',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
new_variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='new_plan_variety_changes',
|
||||
verbose_name='変更後品種',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
reason = models.TextField(blank=True, default='', verbose_name='変更理由')
|
||||
fertilizer_moved_entry_count = models.IntegerField(default=0, verbose_name='施肥移動エントリ数')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '作付け計画品種変更履歴'
|
||||
verbose_name_plural = '作付け計画品種変更履歴'
|
||||
ordering = ['-changed_at', '-id']
|
||||
|
||||
def __str__(self):
|
||||
old_name = self.old_variety.name if self.old_variety else '未設定'
|
||||
new_name = self.new_variety.name if self.new_variety else '未設定'
|
||||
return f'{self.field.name} {self.year}: {old_name} -> {new_name}'
|
||||
|
||||
|
||||
class RiceTransplantPlan(models.Model):
|
||||
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rice_transplant_plans',
|
||||
verbose_name='品種',
|
||||
)
|
||||
default_seed_grams_per_box = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
|
||||
)
|
||||
seedling_boxes_per_tan = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name='反当苗箱枚数',
|
||||
)
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '田植え計画'
|
||||
verbose_name_plural = '田植え計画'
|
||||
ordering = ['-year', 'variety']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.year} {self.name}'
|
||||
|
||||
|
||||
class RiceTransplantEntry(models.Model):
|
||||
plan = models.ForeignKey(
|
||||
RiceTransplantPlan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='entries',
|
||||
verbose_name='田植え計画',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
Field,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rice_transplant_entries',
|
||||
verbose_name='圃場',
|
||||
)
|
||||
installed_seedling_boxes = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
verbose_name='設置苗箱枚数',
|
||||
)
|
||||
seed_grams_per_box = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
verbose_name='苗箱1枚あたり種もみ(g)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '田植え計画エントリ'
|
||||
verbose_name_plural = '田植え計画エントリ'
|
||||
unique_together = [['plan', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.plan} / {self.field} / {self.installed_seedling_boxes}枚'
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import StockTransaction
|
||||
from .models import Crop, Variety, Plan
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
from .services import NO_CHANGE, update_plan_with_variety_tracking
|
||||
|
||||
|
||||
class VarietySerializer(serializers.ModelSerializer):
|
||||
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Variety
|
||||
fields = '__all__'
|
||||
fields = [
|
||||
'id',
|
||||
'crop',
|
||||
'name',
|
||||
'default_seedling_boxes_per_tan',
|
||||
'seed_material',
|
||||
'seed_material_name',
|
||||
]
|
||||
|
||||
|
||||
class CropSerializer(serializers.ModelSerializer):
|
||||
@@ -20,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||
field_name = serializers.ReadOnlyField(source='field.name')
|
||||
variety_change_count = serializers.SerializerMethodField()
|
||||
latest_variety_change = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Plan
|
||||
@@ -30,7 +47,215 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
return Plan.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
return update_plan_with_variety_tracking(
|
||||
instance,
|
||||
crop=validated_data.get('crop', NO_CHANGE),
|
||||
variety=validated_data.get('variety', NO_CHANGE),
|
||||
notes=validated_data.get('notes', NO_CHANGE),
|
||||
)
|
||||
|
||||
def get_variety_change_count(self, obj):
|
||||
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||
changes = prefetched.get('variety_changes')
|
||||
if changes is not None:
|
||||
return len(changes)
|
||||
return obj.variety_changes.count()
|
||||
|
||||
def get_latest_variety_change(self, obj):
|
||||
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||
changes = prefetched.get('variety_changes')
|
||||
if changes is not None:
|
||||
latest = changes[0] if changes else None
|
||||
else:
|
||||
latest = obj.variety_changes.select_related('old_variety', 'new_variety').first()
|
||||
if latest is None:
|
||||
return None
|
||||
return {
|
||||
'id': latest.id,
|
||||
'changed_at': latest.changed_at,
|
||||
'old_variety_id': latest.old_variety_id,
|
||||
'old_variety_name': latest.old_variety.name if latest.old_variety else None,
|
||||
'new_variety_id': latest.new_variety_id,
|
||||
'new_variety_name': latest.new_variety.name if latest.new_variety else None,
|
||||
'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count,
|
||||
}
|
||||
|
||||
|
||||
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
planned_boxes = serializers.SerializerMethodField()
|
||||
default_seedling_boxes = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantEntry
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'installed_seedling_boxes',
|
||||
'default_seedling_boxes',
|
||||
'planned_boxes',
|
||||
]
|
||||
|
||||
def get_default_seedling_boxes(self, obj):
|
||||
area = Decimal(str(obj.field.area_tan))
|
||||
default_boxes_per_tan = obj.plan.seedling_boxes_per_tan
|
||||
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
|
||||
|
||||
def get_planned_boxes(self, obj):
|
||||
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
|
||||
|
||||
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
|
||||
variety_name = serializers.CharField(source='variety.name', read_only=True)
|
||||
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
|
||||
seed_material_name = serializers.CharField(source='variety.seed_material.name', read_only=True)
|
||||
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
total_seedling_boxes = serializers.SerializerMethodField()
|
||||
total_seed_kg = serializers.SerializerMethodField()
|
||||
variety_seed_inventory_kg = serializers.SerializerMethodField()
|
||||
remaining_seed_kg = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'seed_material_name',
|
||||
'entries',
|
||||
'field_count',
|
||||
'total_seedling_boxes',
|
||||
'total_seed_kg',
|
||||
'variety_seed_inventory_kg',
|
||||
'remaining_seed_kg',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_field_count(self, obj):
|
||||
return obj.entries.count()
|
||||
|
||||
def get_total_seedling_boxes(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.01')))
|
||||
|
||||
def get_total_seed_kg(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
* obj.default_seed_grams_per_box
|
||||
/ Decimal('1000')
|
||||
)
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.001')))
|
||||
|
||||
def get_variety_seed_inventory_kg(self, obj):
|
||||
return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001')))
|
||||
|
||||
def get_remaining_seed_kg(self, obj):
|
||||
total_seed = Decimal(self.get_total_seed_kg(obj))
|
||||
return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001')))
|
||||
|
||||
def _get_seed_inventory_kg(self, obj):
|
||||
material = obj.variety.seed_material
|
||||
if material is None:
|
||||
return Decimal('0')
|
||||
|
||||
transactions = list(material.stock_transactions.all())
|
||||
increase = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
decrease = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return increase - decrease
|
||||
|
||||
|
||||
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'entries',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = RiceTransplantPlan.objects.create(**validated_data)
|
||||
self._save_entries(plan, entries_data)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
entries_data = validated_data.pop('entries', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
self._save_entries(instance, entries_data)
|
||||
return instance
|
||||
|
||||
def validate(self, attrs):
|
||||
entries_data = attrs.get('entries')
|
||||
if entries_data is None:
|
||||
return attrs
|
||||
|
||||
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
|
||||
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
|
||||
missing_ids = sorted(set(field_ids) - existing_ids)
|
||||
if missing_ids:
|
||||
raise serializers.ValidationError({
|
||||
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
|
||||
})
|
||||
return attrs
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
for entry in entries_data:
|
||||
RiceTransplantEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
installed_seedling_boxes=entry['installed_seedling_boxes'],
|
||||
seed_grams_per_box=plan.default_seed_grams_per_box,
|
||||
)
|
||||
|
||||
74
backend/apps/plans/services.py
Normal file
74
backend/apps/plans/services.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Plan, PlanVarietyChange
|
||||
|
||||
|
||||
class _NoChange:
|
||||
pass
|
||||
|
||||
|
||||
NO_CHANGE = _NoChange()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_plan_with_variety_tracking(
|
||||
plan: Plan,
|
||||
*,
|
||||
crop=NO_CHANGE,
|
||||
variety=NO_CHANGE,
|
||||
notes=NO_CHANGE,
|
||||
reason: str = '',
|
||||
):
|
||||
old_variety = plan.variety
|
||||
updated_fields = []
|
||||
|
||||
if crop is not NO_CHANGE:
|
||||
plan.crop = crop
|
||||
updated_fields.append('crop')
|
||||
if variety is not NO_CHANGE:
|
||||
plan.variety = variety
|
||||
updated_fields.append('variety')
|
||||
if notes is not NO_CHANGE:
|
||||
plan.notes = notes
|
||||
updated_fields.append('notes')
|
||||
|
||||
if updated_fields:
|
||||
plan.save(update_fields=updated_fields)
|
||||
|
||||
if variety is not NO_CHANGE and _get_variety_id(old_variety) != _get_variety_id(plan.variety):
|
||||
handle_plan_variety_change(plan, old_variety=old_variety, new_variety=plan.variety, reason=reason)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: str = ''):
|
||||
if _get_variety_id(old_variety) == _get_variety_id(new_variety):
|
||||
return None
|
||||
|
||||
change = PlanVarietyChange.objects.create(
|
||||
field=plan.field,
|
||||
year=plan.year,
|
||||
plan=plan,
|
||||
old_variety=old_variety,
|
||||
new_variety=new_variety,
|
||||
reason=reason,
|
||||
)
|
||||
process_plan_variety_change(change)
|
||||
return change
|
||||
|
||||
|
||||
def process_plan_variety_change(change: PlanVarietyChange):
|
||||
from apps.fertilizer.services import move_fertilization_entries_for_variety_change
|
||||
from .services_rice_transplant import move_rice_transplant_entries_for_variety_change
|
||||
|
||||
moved_count = move_fertilization_entries_for_variety_change(change)
|
||||
move_rice_transplant_entries_for_variety_change(change)
|
||||
if moved_count != change.fertilizer_moved_entry_count:
|
||||
change.fertilizer_moved_entry_count = moved_count
|
||||
change.save(update_fields=['fertilizer_moved_entry_count'])
|
||||
return change
|
||||
|
||||
|
||||
def _get_variety_id(variety):
|
||||
return getattr(variety, 'id', None)
|
||||
46
backend/apps/plans/services_rice_transplant.py
Normal file
46
backend/apps/plans/services_rice_transplant.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import transaction
|
||||
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def move_rice_transplant_entries_for_variety_change(change):
|
||||
old_variety_id = change.old_variety_id
|
||||
new_variety = change.new_variety
|
||||
if old_variety_id is None or new_variety is None:
|
||||
return 0
|
||||
|
||||
old_plans = (
|
||||
RiceTransplantPlan.objects
|
||||
.filter(
|
||||
year=change.year,
|
||||
variety_id=old_variety_id,
|
||||
entries__field_id=change.field_id,
|
||||
)
|
||||
.distinct()
|
||||
.prefetch_related('entries')
|
||||
)
|
||||
|
||||
moved_count = 0
|
||||
for old_plan in old_plans:
|
||||
entries_to_move = list(
|
||||
old_plan.entries.filter(field_id=change.field_id).order_by('id')
|
||||
)
|
||||
if not entries_to_move:
|
||||
continue
|
||||
|
||||
new_plan = RiceTransplantPlan.objects.create(
|
||||
name=f'{change.year}年度 {new_variety.name} 田植え計画(品種変更移動)',
|
||||
year=change.year,
|
||||
variety=new_variety,
|
||||
default_seed_grams_per_box=old_plan.default_seed_grams_per_box,
|
||||
seedling_boxes_per_tan=old_plan.seedling_boxes_per_tan,
|
||||
notes=old_plan.notes,
|
||||
)
|
||||
|
||||
RiceTransplantEntry.objects.filter(
|
||||
id__in=[entry.id for entry in entries_to_move]
|
||||
).update(plan=new_plan)
|
||||
moved_count += len(entries_to_move)
|
||||
|
||||
return moved_count
|
||||
@@ -1,3 +1,263 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from decimal import Decimal
|
||||
|
||||
# Create your tests here.
|
||||
from apps.fertilizer.models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import Material, StockTransaction
|
||||
from apps.materials.stock_service import create_reserves_for_plan
|
||||
from .models import (
|
||||
Crop,
|
||||
Plan,
|
||||
PlanVarietyChange,
|
||||
RiceTransplantEntry,
|
||||
RiceTransplantPlan,
|
||||
Variety,
|
||||
)
|
||||
from .serializers import PlanSerializer
|
||||
from .views import PlanViewSet
|
||||
|
||||
|
||||
class PlanVarietyChangeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username='tester',
|
||||
password='secret12345',
|
||||
)
|
||||
self.crop = Crop.objects.create(name='水稲')
|
||||
self.old_variety = Variety.objects.create(crop=self.crop, name='にこまる')
|
||||
self.new_variety = Variety.objects.create(crop=self.crop, name='たちはるか特栽')
|
||||
self.field = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
self.plan = Plan.objects.create(
|
||||
field=self.field,
|
||||
year=2026,
|
||||
crop=self.crop,
|
||||
variety=self.old_variety,
|
||||
notes='',
|
||||
)
|
||||
self.other_field = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
|
||||
def test_serializer_update_creates_history_when_variety_changes(self):
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.field_id, self.field.id)
|
||||
self.assertEqual(change.year, 2026)
|
||||
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||
self.assertEqual(change.fertilizer_moved_entry_count, 0)
|
||||
|
||||
def test_serializer_update_does_not_create_history_without_variety_change(self):
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'notes': 'メモ更新'},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.notes, 'メモ更新')
|
||||
self.assertFalse(PlanVarietyChange.objects.exists())
|
||||
|
||||
def test_bulk_update_creates_history_for_existing_plan(self):
|
||||
view = PlanViewSet.as_view({'post': 'bulk_update'})
|
||||
request = self.factory.post(
|
||||
'/api/plans/bulk_update/',
|
||||
{
|
||||
'field_ids': [self.field.id],
|
||||
'year': 2026,
|
||||
'crop': self.crop.id,
|
||||
'variety': self.new_variety.id,
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = view(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||
|
||||
def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self):
|
||||
material_target = Material.objects.create(
|
||||
name='高度化成14号',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
material_spread = Material.objects.create(
|
||||
name='分げつ一発',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
fertilizer_target = Fertilizer.objects.create(
|
||||
name='高度化成14号',
|
||||
material=material_target,
|
||||
)
|
||||
fertilizer_spread = Fertilizer.objects.create(
|
||||
name='分げつ一発',
|
||||
material=material_spread,
|
||||
)
|
||||
old_fertilization_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 にこまる 元肥',
|
||||
year=2026,
|
||||
variety=self.old_variety,
|
||||
calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}],
|
||||
)
|
||||
target_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.field,
|
||||
fertilizer=fertilizer_target,
|
||||
bags='4.00',
|
||||
actual_bags=None,
|
||||
)
|
||||
spread_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.field,
|
||||
fertilizer=fertilizer_spread,
|
||||
bags='3.00',
|
||||
actual_bags='1.0000',
|
||||
)
|
||||
untouched_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.other_field,
|
||||
fertilizer=fertilizer_target,
|
||||
bags='2.00',
|
||||
actual_bags=None,
|
||||
)
|
||||
create_reserves_for_plan(old_fertilization_plan)
|
||||
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.fertilizer_moved_entry_count, 2)
|
||||
|
||||
old_fertilization_plan.refresh_from_db()
|
||||
new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get(
|
||||
year=2026,
|
||||
variety=self.new_variety,
|
||||
)
|
||||
self.assertEqual(
|
||||
new_plan.name,
|
||||
f'2026年度 {self.new_variety.name} 施肥計画(品種変更移動)',
|
||||
)
|
||||
self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings)
|
||||
|
||||
target_entry.refresh_from_db()
|
||||
spread_entry.refresh_from_db()
|
||||
untouched_entry.refresh_from_db()
|
||||
|
||||
self.assertEqual(target_entry.plan_id, new_plan.id)
|
||||
self.assertEqual(spread_entry.plan_id, new_plan.id)
|
||||
self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id)
|
||||
|
||||
old_reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=old_fertilization_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
new_reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=new_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
self.assertEqual(len(old_reserves), 1)
|
||||
self.assertEqual(len(new_reserves), 2)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in old_reserves},
|
||||
{
|
||||
(material_target.id, untouched_entry.bags),
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in new_reserves},
|
||||
{
|
||||
(material_target.id, target_entry.bags),
|
||||
(material_spread.id, spread_entry.bags),
|
||||
},
|
||||
)
|
||||
|
||||
def test_serializer_update_moves_rice_transplant_entries_for_target_field(self):
|
||||
old_rice_plan = RiceTransplantPlan.objects.create(
|
||||
name='2026年度 にこまる 田植え計画',
|
||||
year=2026,
|
||||
variety=self.old_variety,
|
||||
default_seed_grams_per_box='200.00',
|
||||
seedling_boxes_per_tan='12.00',
|
||||
notes='旧計画メモ',
|
||||
)
|
||||
target_entry = RiceTransplantEntry.objects.create(
|
||||
plan=old_rice_plan,
|
||||
field=self.field,
|
||||
installed_seedling_boxes='14.40',
|
||||
seed_grams_per_box='200.00',
|
||||
)
|
||||
other_entry = RiceTransplantEntry.objects.create(
|
||||
plan=old_rice_plan,
|
||||
field=self.other_field,
|
||||
installed_seedling_boxes='9.60',
|
||||
seed_grams_per_box='200.00',
|
||||
)
|
||||
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
target_entry.refresh_from_db()
|
||||
other_entry.refresh_from_db()
|
||||
|
||||
new_rice_plan = RiceTransplantPlan.objects.exclude(id=old_rice_plan.id).get(
|
||||
year=2026,
|
||||
variety=self.new_variety,
|
||||
)
|
||||
self.assertEqual(
|
||||
new_rice_plan.name,
|
||||
f'2026年度 {self.new_variety.name} 田植え計画(品種変更移動)',
|
||||
)
|
||||
self.assertEqual(new_rice_plan.default_seed_grams_per_box, Decimal('200.00'))
|
||||
self.assertEqual(new_rice_plan.seedling_boxes_per_tan, Decimal('12.00'))
|
||||
self.assertEqual(new_rice_plan.notes, old_rice_plan.notes)
|
||||
self.assertEqual(target_entry.plan_id, new_rice_plan.id)
|
||||
self.assertEqual(other_entry.plan_id, old_rice_plan.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ from . import views
|
||||
router = DefaultRouter()
|
||||
router.register(r'crops', views.CropViewSet)
|
||||
router.register(r'varieties', views.VarietyViewSet)
|
||||
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
|
||||
router.register(r'', views.PlanViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -2,8 +2,15 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Sum
|
||||
from .models import Crop, Variety, Plan
|
||||
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
|
||||
from .models import Crop, Variety, Plan, RiceTransplantPlan
|
||||
from .serializers import (
|
||||
CropSerializer,
|
||||
VarietySerializer,
|
||||
PlanSerializer,
|
||||
RiceTransplantPlanSerializer,
|
||||
RiceTransplantPlanWriteSerializer,
|
||||
)
|
||||
from .services import update_plan_with_variety_tracking
|
||||
from apps.fields.models import Field
|
||||
|
||||
|
||||
@@ -13,16 +20,20 @@ class CropViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class VarietyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Variety.objects.all()
|
||||
queryset = Variety.objects.select_related('seed_material', 'crop').all()
|
||||
serializer_class = VarietySerializer
|
||||
|
||||
|
||||
class PlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = Plan.objects.all()
|
||||
queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related(
|
||||
'variety_changes',
|
||||
'variety_changes__old_variety',
|
||||
'variety_changes__new_variety',
|
||||
)
|
||||
serializer_class = PlanSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Plan.objects.all()
|
||||
queryset = self.queryset
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
@@ -114,19 +125,78 @@ class PlanViewSet(viewsets.ModelViewSet):
|
||||
updated = 0
|
||||
created = 0
|
||||
for field_id in field_ids:
|
||||
plan, was_created = Plan.objects.update_or_create(
|
||||
plan = Plan.objects.filter(field_id=field_id, year=year).first()
|
||||
if plan is None:
|
||||
Plan.objects.create(
|
||||
field_id=field_id,
|
||||
year=year,
|
||||
defaults={'crop': crop, 'variety': variety}
|
||||
crop=crop,
|
||||
variety=variety,
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
else:
|
||||
continue
|
||||
|
||||
update_plan_with_variety_tracking(
|
||||
plan,
|
||||
crop=crop,
|
||||
variety=variety,
|
||||
)
|
||||
updated += 1
|
||||
|
||||
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def get_crops_with_varieties(self, request):
|
||||
crops = Crop.objects.prefetch_related('varieties').all()
|
||||
crops = Crop.objects.prefetch_related('varieties__seed_material').all()
|
||||
return Response(CropSerializer(crops, many=True).data)
|
||||
|
||||
|
||||
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = RiceTransplantPlan.objects.select_related(
|
||||
'variety',
|
||||
'variety__crop',
|
||||
'variety__seed_material',
|
||||
).prefetch_related(
|
||||
'variety__seed_material__stock_transactions',
|
||||
'entries',
|
||||
'entries__field',
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return RiceTransplantPlanWriteSerializer
|
||||
return RiceTransplantPlanSerializer
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def candidate_fields(self, request):
|
||||
year = request.query_params.get('year')
|
||||
variety_id = request.query_params.get('variety_id')
|
||||
if not year or not variety_id:
|
||||
return Response(
|
||||
{'error': 'year と variety_id が必要です'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
field_ids = Plan.objects.filter(
|
||||
year=year,
|
||||
variety_id=variety_id,
|
||||
).values_list('field_id', flat=True)
|
||||
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
|
||||
data = [
|
||||
{
|
||||
'id': field.id,
|
||||
'name': field.name,
|
||||
'area_tan': str(field.area_tan),
|
||||
'area_m2': field.area_m2,
|
||||
'group_name': field.group_name,
|
||||
}
|
||||
for field in fields
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
0
backend/apps/weather/__init__.py
Normal file
0
backend/apps/weather/__init__.py
Normal file
12
backend/apps/weather/admin.py
Normal file
12
backend/apps/weather/admin.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.contrib import admin
|
||||
from .models import WeatherRecord
|
||||
|
||||
|
||||
@admin.register(WeatherRecord)
|
||||
class WeatherRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ['date', 'temp_mean', 'temp_max', 'temp_min',
|
||||
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']
|
||||
list_filter = ['date']
|
||||
search_fields = ['date']
|
||||
ordering = ['-date']
|
||||
date_hierarchy = 'date'
|
||||
7
backend/apps/weather/apps.py
Normal file
7
backend/apps/weather/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WeatherConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.weather'
|
||||
verbose_name = '気象データ'
|
||||
0
backend/apps/weather/management/__init__.py
Normal file
0
backend/apps/weather/management/__init__.py
Normal file
163
backend/apps/weather/management/commands/fetch_weather.py
Normal file
163
backend/apps/weather/management/commands/fetch_weather.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
気象データを Open-Meteo API から取得して DB に保存する。
|
||||
|
||||
使い方:
|
||||
# 差分取得(最終レコードの翌日〜昨日)
|
||||
python manage.py fetch_weather
|
||||
|
||||
# 全件取得(初回インポート)
|
||||
python manage.py fetch_weather --full
|
||||
|
||||
# 期間指定
|
||||
python manage.py fetch_weather --start-date 2024-01-01 --end-date 2024-12-31
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import requests
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from apps.weather.models import WeatherRecord
|
||||
|
||||
|
||||
LATITUDE = 33.213
|
||||
LONGITUDE = 133.133
|
||||
TIMEZONE = 'Asia/Tokyo'
|
||||
FULL_START = '2016-01-01'
|
||||
|
||||
OPEN_METEO_URL = 'https://archive-api.open-meteo.com/v1/archive'
|
||||
DAILY_VARS = [
|
||||
'temperature_2m_mean',
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'sunshine_duration',
|
||||
'precipitation_sum',
|
||||
'wind_speed_10m_max',
|
||||
'surface_pressure_min',
|
||||
]
|
||||
|
||||
|
||||
def fetch_from_api(start_date: str, end_date: str) -> list[dict]:
|
||||
"""Open-Meteo から daily データを取得し、WeatherRecord 形式のリストで返す。"""
|
||||
params = {
|
||||
'latitude': LATITUDE,
|
||||
'longitude': LONGITUDE,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'daily': DAILY_VARS,
|
||||
'timezone': TIMEZONE,
|
||||
}
|
||||
resp = requests.get(OPEN_METEO_URL, params=params, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
raise CommandError(f'Open-Meteo API エラー: {resp.status_code} {resp.text[:200]}')
|
||||
|
||||
data = resp.json().get('daily', {})
|
||||
dates = data.get('time', [])
|
||||
|
||||
if not dates:
|
||||
return []
|
||||
|
||||
sunshine_raw = data.get('sunshine_duration', [])
|
||||
results = []
|
||||
for i, d in enumerate(dates):
|
||||
# 日照: 秒 → 時間
|
||||
sun_sec = sunshine_raw[i]
|
||||
sunshine_h = round(sun_sec / 3600, 2) if sun_sec is not None else None
|
||||
|
||||
results.append({
|
||||
'date': d,
|
||||
'temp_mean': data['temperature_2m_mean'][i],
|
||||
'temp_max': data['temperature_2m_max'][i],
|
||||
'temp_min': data['temperature_2m_min'][i],
|
||||
'sunshine_h': sunshine_h,
|
||||
'precip_mm': data['precipitation_sum'][i],
|
||||
'wind_max': data['wind_speed_10m_max'][i],
|
||||
'pressure_min': data['surface_pressure_min'][i],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def upsert_records(records: list[dict], stdout=None) -> int:
|
||||
"""WeatherRecord に upsert し、保存件数を返す。"""
|
||||
saved = 0
|
||||
for item in records:
|
||||
record, created = WeatherRecord.objects.get_or_create(date=item['date'])
|
||||
for field in ['temp_mean', 'temp_max', 'temp_min',
|
||||
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']:
|
||||
val = item.get(field)
|
||||
if val is not None:
|
||||
setattr(record, field, val)
|
||||
record.save()
|
||||
saved += 1
|
||||
if stdout and created:
|
||||
stdout.write(f' new: {item["date"]}')
|
||||
return saved
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '気象データを Open-Meteo から取得して DB に保存する'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--full',
|
||||
action='store_true',
|
||||
help=f'2016-01-01 から昨日まで全件取得(初回インポート用)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--start-date',
|
||||
type=str,
|
||||
help='取得開始日 (YYYY-MM-DD)。省略時は最終レコードの翌日',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--end-date',
|
||||
type=str,
|
||||
help='取得終了日 (YYYY-MM-DD)。省略時は昨日',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
# 終了日
|
||||
end_date = options.get('end_date') or yesterday
|
||||
|
||||
# 開始日の決定
|
||||
if options['full']:
|
||||
start_date = FULL_START
|
||||
elif options.get('start_date'):
|
||||
start_date = options['start_date']
|
||||
else:
|
||||
# 最終レコードの翌日を自動算出
|
||||
last = WeatherRecord.objects.order_by('-date').first()
|
||||
if last:
|
||||
start_date = (last.date + datetime.timedelta(days=1)).isoformat()
|
||||
else:
|
||||
start_date = FULL_START
|
||||
self.stdout.write(
|
||||
self.style.WARNING('DB にデータがないため 2016-01-01 から取得します。')
|
||||
)
|
||||
|
||||
if start_date > end_date:
|
||||
self.stdout.write(self.style.SUCCESS('すでに最新の状態です。取得をスキップします。'))
|
||||
return
|
||||
|
||||
self.stdout.write(f'取得期間: {start_date} 〜 {end_date}')
|
||||
|
||||
# Open-Meteo は 1回のリクエストで最大1年分程度が安定。
|
||||
# 長期間の場合は年単位で分割して取得する。
|
||||
start = datetime.date.fromisoformat(start_date)
|
||||
end = datetime.date.fromisoformat(end_date)
|
||||
total_saved = 0
|
||||
|
||||
current = start
|
||||
while current <= end:
|
||||
chunk_end = min(
|
||||
datetime.date(current.year, 12, 31),
|
||||
end
|
||||
)
|
||||
self.stdout.write(f' → {current} 〜 {chunk_end} を取得中...')
|
||||
records = fetch_from_api(current.isoformat(), chunk_end.isoformat())
|
||||
saved = upsert_records(records, stdout=None)
|
||||
total_saved += saved
|
||||
self.stdout.write(f' {saved} 件保存')
|
||||
current = datetime.date(chunk_end.year + 1, 1, 1)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'完了: 合計 {total_saved} 件を保存しました。'))
|
||||
33
backend/apps/weather/migrations/0001_initial.py
Normal file
33
backend/apps/weather/migrations/0001_initial.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0 on 2026-02-28 04:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WeatherRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(unique=True, verbose_name='日付')),
|
||||
('temp_mean', models.FloatField(blank=True, null=True, verbose_name='平均気温(℃)')),
|
||||
('temp_max', models.FloatField(blank=True, null=True, verbose_name='最高気温(℃)')),
|
||||
('temp_min', models.FloatField(blank=True, null=True, verbose_name='最低気温(℃)')),
|
||||
('sunshine_h', models.FloatField(blank=True, null=True, verbose_name='日照時間(h)')),
|
||||
('precip_mm', models.FloatField(blank=True, null=True, verbose_name='降水量(mm)')),
|
||||
('wind_max', models.FloatField(blank=True, null=True, verbose_name='最大風速(m/s)')),
|
||||
('pressure_min', models.FloatField(blank=True, null=True, verbose_name='最低気圧(hPa)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '気象記録',
|
||||
'verbose_name_plural': '気象記録',
|
||||
'ordering': ['date'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/weather/migrations/__init__.py
Normal file
0
backend/apps/weather/migrations/__init__.py
Normal file
20
backend/apps/weather/models.py
Normal file
20
backend/apps/weather/models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class WeatherRecord(models.Model):
|
||||
date = models.DateField(unique=True, verbose_name="日付")
|
||||
temp_mean = models.FloatField(null=True, blank=True, verbose_name="平均気温(℃)")
|
||||
temp_max = models.FloatField(null=True, blank=True, verbose_name="最高気温(℃)")
|
||||
temp_min = models.FloatField(null=True, blank=True, verbose_name="最低気温(℃)")
|
||||
sunshine_h = models.FloatField(null=True, blank=True, verbose_name="日照時間(h)")
|
||||
precip_mm = models.FloatField(null=True, blank=True, verbose_name="降水量(mm)")
|
||||
wind_max = models.FloatField(null=True, blank=True, verbose_name="最大風速(m/s)")
|
||||
pressure_min = models.FloatField(null=True, blank=True, verbose_name="最低気圧(hPa)")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "気象記録"
|
||||
verbose_name_plural = "気象記録"
|
||||
ordering = ['date']
|
||||
|
||||
def __str__(self):
|
||||
return str(self.date)
|
||||
16
backend/apps/weather/serializers.py
Normal file
16
backend/apps/weather/serializers.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from rest_framework import serializers
|
||||
from .models import WeatherRecord
|
||||
|
||||
|
||||
class WeatherRecordSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WeatherRecord
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class WeatherSyncSerializer(serializers.ModelSerializer):
|
||||
"""Windmill からの POST 用(id不要)"""
|
||||
class Meta:
|
||||
model = WeatherRecord
|
||||
fields = ['date', 'temp_mean', 'temp_max', 'temp_min',
|
||||
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']
|
||||
13
backend/apps/weather/urls.py
Normal file
13
backend/apps/weather/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Windmill 向け(APIキー認証)
|
||||
path('sync/', views.WeatherSyncView.as_view(), name='weather-sync'),
|
||||
|
||||
# フロントエンド向け(JWT認証)
|
||||
path('records/', views.WeatherRecordListView.as_view(), name='weather-records'),
|
||||
path('summary/', views.WeatherSummaryView.as_view(), name='weather-summary'),
|
||||
path('gdd/', views.WeatherGDDView.as_view(), name='weather-gdd'),
|
||||
path('similarity/', views.WeatherSimilarityView.as_view(), name='weather-similarity'),
|
||||
]
|
||||
349
backend/apps/weather/views.py
Normal file
349
backend/apps/weather/views.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import secrets
|
||||
import math
|
||||
import datetime
|
||||
from statistics import mean, stdev
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Avg, Sum, Max, Min, Count, Q
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import BasePermission, IsAuthenticated
|
||||
|
||||
from .models import WeatherRecord
|
||||
from .serializers import WeatherRecordSerializer, WeatherSyncSerializer
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 認証クラス
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class WeatherAPIKeyPermission(BasePermission):
|
||||
"""X-API-Key ヘッダーで認証(Windmill向け)"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
key = request.headers.get('X-API-Key', '')
|
||||
expected = getattr(settings, 'MAIL_API_KEY', '') # 既存キーを流用
|
||||
if not key or not expected:
|
||||
return False
|
||||
return secrets.compare_digest(key, expected)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Windmill 向け: 日次データ同期 (POST)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class WeatherSyncView(APIView):
|
||||
"""
|
||||
POST /api/weather/sync/
|
||||
Windmill から気象データを受け取り upsert する。
|
||||
単一オブジェクト、またはリスト形式どちらでも受け付ける。
|
||||
"""
|
||||
permission_classes = [WeatherAPIKeyPermission]
|
||||
|
||||
def post(self, request):
|
||||
data = request.data
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
|
||||
saved = 0
|
||||
errors = []
|
||||
for item in data:
|
||||
date_val = item.get('date')
|
||||
if not date_val:
|
||||
errors.append({'item': item, 'error': 'date is required'})
|
||||
continue
|
||||
try:
|
||||
record, _ = WeatherRecord.objects.get_or_create(date=date_val)
|
||||
for field in ['temp_mean', 'temp_max', 'temp_min',
|
||||
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']:
|
||||
val = item.get(field)
|
||||
if val is not None:
|
||||
setattr(record, field, float(val))
|
||||
record.save()
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
errors.append({'item': item, 'error': str(e)})
|
||||
|
||||
result = {'saved': saved}
|
||||
if errors:
|
||||
result['errors'] = errors
|
||||
code = status.HTTP_201_CREATED if saved > 0 else status.HTTP_400_BAD_REQUEST
|
||||
return Response(result, status=code)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# フロントエンド向け: 日次レコード一覧 (GET)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class WeatherRecordListView(APIView):
|
||||
"""
|
||||
GET /api/weather/records/?year=2025
|
||||
GET /api/weather/records/?start=2025-01-01&end=2025-12-31
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
qs = WeatherRecord.objects.all()
|
||||
|
||||
year = request.query_params.get('year')
|
||||
start = request.query_params.get('start')
|
||||
end = request.query_params.get('end')
|
||||
|
||||
if year:
|
||||
qs = qs.filter(date__year=int(year))
|
||||
if start:
|
||||
qs = qs.filter(date__gte=start)
|
||||
if end:
|
||||
qs = qs.filter(date__lte=end)
|
||||
|
||||
serializer = WeatherRecordSerializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# フロントエンド向け: 月別サマリー (GET)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class WeatherSummaryView(APIView):
|
||||
"""
|
||||
GET /api/weather/summary/?year=2025
|
||||
月別集計(平均気温、降水量合計、日照合計、猛暑日・冬日数など)
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
if not year:
|
||||
return Response({'error': 'year parameter is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
qs = WeatherRecord.objects.filter(date__year=int(year))
|
||||
|
||||
monthly = []
|
||||
for month in range(1, 13):
|
||||
mqs = qs.filter(date__month=month)
|
||||
agg = mqs.aggregate(
|
||||
temp_mean_avg=Avg('temp_mean'),
|
||||
temp_max_avg=Avg('temp_max'),
|
||||
temp_min_avg=Avg('temp_min'),
|
||||
precip_total=Sum('precip_mm'),
|
||||
sunshine_total=Sum('sunshine_h'),
|
||||
wind_max=Max('wind_max'),
|
||||
)
|
||||
hot_days = mqs.filter(temp_max__gte=35).count()
|
||||
cold_days = mqs.filter(temp_min__lt=0).count()
|
||||
rainy_days = mqs.filter(precip_mm__gte=1.0).count()
|
||||
|
||||
monthly.append({
|
||||
'month': month,
|
||||
**{k: (round(v, 1) if v is not None else None) for k, v in agg.items()},
|
||||
'hot_days': hot_days,
|
||||
'cold_days': cold_days,
|
||||
'rainy_days': rainy_days,
|
||||
})
|
||||
|
||||
# 年間サマリー
|
||||
year_agg = qs.aggregate(
|
||||
temp_mean_avg=Avg('temp_mean'),
|
||||
precip_total=Sum('precip_mm'),
|
||||
sunshine_total=Sum('sunshine_h'),
|
||||
)
|
||||
annual = {k: (round(v, 1) if v is not None else None) for k, v in year_agg.items()}
|
||||
annual['hot_days'] = qs.filter(temp_max__gte=35).count()
|
||||
annual['cold_days'] = qs.filter(temp_min__lt=0).count()
|
||||
|
||||
return Response({'year': int(year), 'monthly': monthly, 'annual': annual})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 積算温度 (GDD) (GET)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class WeatherGDDView(APIView):
|
||||
"""
|
||||
GET /api/weather/gdd/?start_date=2025-05-01&base_temp=10&end_date=2025-09-30
|
||||
|
||||
起算日から指定日(省略時は昨日)までの有効積算温度を返す。
|
||||
日積算温度 = max(0, 平均気温 - 基準温度)
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
start_date = request.query_params.get('start_date')
|
||||
if not start_date:
|
||||
return Response({'error': 'start_date is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
base_temp = float(request.query_params.get('base_temp', 0.0))
|
||||
end_date = request.query_params.get('end_date') or (
|
||||
datetime.date.today() - datetime.timedelta(days=1)
|
||||
).isoformat()
|
||||
|
||||
records = WeatherRecord.objects.filter(
|
||||
date__gte=start_date,
|
||||
date__lte=end_date,
|
||||
temp_mean__isnull=False,
|
||||
).values('date', 'temp_mean')
|
||||
|
||||
cumulative = 0.0
|
||||
daily_records = []
|
||||
for r in records:
|
||||
daily_gdd = max(0.0, r['temp_mean'] - base_temp)
|
||||
cumulative += daily_gdd
|
||||
daily_records.append({
|
||||
'date': r['date'],
|
||||
'temp_mean': r['temp_mean'],
|
||||
'daily_gdd': round(daily_gdd, 2),
|
||||
'cumulative_gdd': round(cumulative, 2),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'base_temp': base_temp,
|
||||
'total_gdd': round(cumulative, 2),
|
||||
'records': daily_records,
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 類似年分析 (GET)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _year_feature(records_qs, month_from, month_to, day_to):
|
||||
"""
|
||||
指定した月/日の範囲の特徴量ベクトルを返す。
|
||||
month_from=1, month_to=2, day_to=28 なら 1/1〜2/28 のデータを使う。
|
||||
返り値: (mean_temp, total_precip, total_sunshine) または None
|
||||
"""
|
||||
# 1/1 から month_to/day_to まで
|
||||
qs = records_qs.filter(
|
||||
Q(date__month__lt=month_to) |
|
||||
Q(date__month=month_to, date__day__lte=day_to)
|
||||
)
|
||||
agg = qs.aggregate(
|
||||
mean_temp=Avg('temp_mean'),
|
||||
total_precip=Sum('precip_mm'),
|
||||
total_sunshine=Sum('sunshine_h'),
|
||||
)
|
||||
if any(v is None for v in agg.values()):
|
||||
return None
|
||||
return (agg['mean_temp'], agg['total_precip'], agg['total_sunshine'])
|
||||
|
||||
|
||||
class WeatherSimilarityView(APIView):
|
||||
"""
|
||||
GET /api/weather/similarity/?year=2026
|
||||
|
||||
今年の 1/1〜今日 の気象パターンと過去年を比較し、
|
||||
最も似た上位3年とその年の残期間の実績データを返す。
|
||||
開花・収穫予測の参考情報として使う。
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
target_year = int(request.query_params.get('year', datetime.date.today().year))
|
||||
today = datetime.date.today()
|
||||
|
||||
# 比較基準日(今年の場合は昨日まで、過去年は12/31まで)
|
||||
if target_year == today.year:
|
||||
compare_end = today - datetime.timedelta(days=1)
|
||||
else:
|
||||
compare_end = datetime.date(target_year, 12, 31)
|
||||
|
||||
limit_month = compare_end.month
|
||||
limit_day = compare_end.day
|
||||
|
||||
# 対象年の特徴量
|
||||
target_qs = WeatherRecord.objects.filter(date__year=target_year)
|
||||
target_feat = _year_feature(target_qs, 1, limit_month, limit_day)
|
||||
if target_feat is None:
|
||||
return Response({'error': f'{target_year} 年のデータが不足しています'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 全年リスト(対象年を除く)
|
||||
all_years = (
|
||||
WeatherRecord.objects
|
||||
.exclude(date__year=target_year)
|
||||
.values_list('date__year', flat=True)
|
||||
.distinct()
|
||||
.order_by('date__year')
|
||||
)
|
||||
|
||||
year_features = {}
|
||||
for yr in all_years:
|
||||
qs = WeatherRecord.objects.filter(date__year=yr)
|
||||
feat = _year_feature(qs, 1, limit_month, limit_day)
|
||||
if feat is not None:
|
||||
year_features[yr] = feat
|
||||
|
||||
if not year_features:
|
||||
return Response({'error': '比較可能な過去データがありません'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# 正規化: 各特徴量の平均・標準偏差
|
||||
all_feats = list(year_features.values()) + [target_feat]
|
||||
n_features = 3
|
||||
feat_means = [mean(f[i] for f in all_feats) for i in range(n_features)]
|
||||
feat_stds = []
|
||||
for i in range(n_features):
|
||||
vals = [f[i] for f in all_feats]
|
||||
sd = stdev(vals) if len(vals) > 1 else 1.0
|
||||
feat_stds.append(sd if sd > 0 else 1.0)
|
||||
|
||||
def normalize(feat):
|
||||
return tuple((feat[i] - feat_means[i]) / feat_stds[i] for i in range(n_features))
|
||||
|
||||
target_norm = normalize(target_feat)
|
||||
|
||||
distances = {}
|
||||
for yr, feat in year_features.items():
|
||||
norm = normalize(feat)
|
||||
dist = math.sqrt(sum((target_norm[i] - norm[i]) ** 2 for i in range(n_features)))
|
||||
distances[yr] = dist
|
||||
|
||||
top3 = sorted(distances.items(), key=lambda x: x[1])[:3]
|
||||
|
||||
# 類似年それぞれの月別統計を返す
|
||||
result_years = []
|
||||
for yr, dist in top3:
|
||||
qs = WeatherRecord.objects.filter(date__year=yr)
|
||||
monthly = []
|
||||
for month in range(1, 13):
|
||||
mqs = qs.filter(date__month=month)
|
||||
agg = mqs.aggregate(
|
||||
temp_mean_avg=Avg('temp_mean'),
|
||||
temp_max_avg=Avg('temp_max'),
|
||||
precip_total=Sum('precip_mm'),
|
||||
sunshine_total=Sum('sunshine_h'),
|
||||
)
|
||||
hot_days = mqs.filter(temp_max__gte=35).count()
|
||||
storm_days = mqs.filter(wind_max__gte=10, precip_mm__gte=30).count()
|
||||
monthly.append({
|
||||
'month': month,
|
||||
**{k: (round(v, 1) if v is not None else None) for k, v in agg.items()},
|
||||
'hot_days': hot_days,
|
||||
'storm_days': storm_days,
|
||||
})
|
||||
|
||||
feat = year_features[yr]
|
||||
result_years.append({
|
||||
'year': yr,
|
||||
'distance': round(dist, 3),
|
||||
'features': {
|
||||
'mean_temp': round(feat[0], 2),
|
||||
'total_precip': round(feat[1], 1),
|
||||
'total_sunshine': round(feat[2], 1),
|
||||
},
|
||||
'monthly': monthly,
|
||||
})
|
||||
|
||||
target_feat_dict = {
|
||||
'mean_temp': round(target_feat[0], 2),
|
||||
'total_precip': round(target_feat[1], 1),
|
||||
'total_sunshine': round(target_feat[2], 1),
|
||||
}
|
||||
|
||||
return Response({
|
||||
'target_year': target_year,
|
||||
'comparison_period': f'1/1〜{limit_month}/{limit_day}',
|
||||
'target_features': target_feat_dict,
|
||||
'similar_years': result_years,
|
||||
})
|
||||
1
backend/apps/workrecords/__init__.py
Normal file
1
backend/apps/workrecords/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
backend/apps/workrecords/admin.py
Normal file
11
backend/apps/workrecords/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
@admin.register(WorkRecord)
|
||||
class WorkRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ['work_date', 'work_type', 'title', 'year', 'auto_created']
|
||||
list_filter = ['work_type', 'year', 'auto_created']
|
||||
search_fields = ['title']
|
||||
|
||||
8
backend/apps/workrecords/apps.py
Normal file
8
backend/apps/workrecords/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkrecordsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.workrecords'
|
||||
verbose_name = '作業記録'
|
||||
|
||||
36
backend/apps/workrecords/migrations/0001_initial.py
Normal file
36
backend/apps/workrecords/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('work_date', models.DateField(verbose_name='作業日')),
|
||||
('work_type', models.CharField(choices=[('fertilizer_delivery', '肥料運搬'), ('fertilizer_spreading', '肥料散布')], max_length=40, verbose_name='作業種別')),
|
||||
('title', models.CharField(max_length=200, verbose_name='タイトル')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('auto_created', models.BooleanField(default=True, verbose_name='自動生成')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('delivery_trip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.deliverytrip', verbose_name='運搬回')),
|
||||
('spreading_session', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.spreadingsession', verbose_name='散布実績')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '作業記録',
|
||||
'verbose_name_plural': '作業記録',
|
||||
'ordering': ['-work_date', '-updated_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2 on 2026-04-04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('levee_work', '0001_initial'),
|
||||
('workrecords', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workrecord',
|
||||
name='work_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('fertilizer_delivery', '肥料運搬'),
|
||||
('fertilizer_spreading', '肥料散布'),
|
||||
('levee_work', '畔塗'),
|
||||
],
|
||||
max_length=40,
|
||||
verbose_name='作業種別',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workrecord',
|
||||
name='levee_work_session',
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='work_record',
|
||||
to='levee_work.leveeworksession',
|
||||
verbose_name='畔塗記録',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
1
backend/apps/workrecords/migrations/__init__.py
Normal file
1
backend/apps/workrecords/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user