Compare commits
74 Commits
7825f0eb30
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de1ae70aa | ||
|
|
3901caf668 | ||
|
|
5f58c2c686 | ||
|
|
83525c2f59 | ||
|
|
627d7e4f59 | ||
|
|
9059b2b51e | ||
|
|
7d2eb1ebe2 | ||
|
|
3e2942b479 | ||
|
|
70fe3824b3 | ||
|
|
10f2b6f77f | ||
|
|
6dfcd0be06 | ||
|
|
1371eef648 | ||
|
|
ac0bc7b6a9 | ||
|
|
1c474e9692 | ||
|
|
0cd90e61db | ||
|
|
8de27de335 | ||
|
|
71b8258281 | ||
|
|
4516a74772 | ||
|
|
a42ccb5cda | ||
|
|
4a1db5ef27 | ||
|
|
c90c6210e1 | ||
|
|
c675b7b7ae | ||
|
|
ae0249be69 | ||
|
|
1d5bcc9dd6 | ||
|
|
98814299cf | ||
|
|
21fb2323eb | ||
|
|
5a9b6a053b | ||
|
|
429a98decb | ||
|
|
4299c6eb4b | ||
|
|
8dd680e28a | ||
|
|
3eb2852b78 | ||
|
|
5c2d17fe0a | ||
|
|
182ef5d83d | ||
|
|
0131982c34 | ||
|
|
491f05eee8 | ||
|
|
a38472e4a0 | ||
|
|
11b36b28a5 | ||
|
|
95c90dd699 | ||
|
|
9bcc5e5e21 | ||
|
|
0c57dd7886 | ||
|
|
f236fe2f90 | ||
|
|
b7b9818855 | ||
|
|
c773c7d3b8 | ||
|
|
edd2f2a274 | ||
|
|
00fd4a8cba | ||
|
|
13c21ed7de | ||
|
|
daae1a42e5 | ||
|
|
4e06318985 | ||
|
|
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 |
@@ -56,10 +56,33 @@
|
|||||||
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
||||||
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
|
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
|
||||||
"Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)",
|
"Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(xargs cat:*)",
|
||||||
|
"Bash(xargs grep:*)",
|
||||||
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_dd08c8854e486d12__ echo \"=== Fertilization Plans \\(check is_confirmed/confirmed_at\\) ===\" curl -s http://localhost:8000/api/fertilizer/plans/?year=2026 -H \"Authorization: Bearer $TOKEN\")",
|
||||||
|
"Bash(python -m json.tool)",
|
||||||
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_0b7fd53b80bd968a__ echo \"=== Stock summary \\(should show reserved\\) ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||||
|
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
|
||||||
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"mcp__serena__find_symbol",
|
||||||
|
"mcp__serena__get_symbols_overview",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(npx next:*)",
|
||||||
|
"mcp__butler__butler__list_skills",
|
||||||
|
"mcp__butler__butler__get_skill_usage",
|
||||||
|
"mcp__butler__inspect_runtime_config",
|
||||||
|
"mcp__butler__execute_task",
|
||||||
|
"Bash(git -C /home/akira/develop/keinasystem remote -v)",
|
||||||
|
"Bash(cat butler/skills/read_from_gitea*)",
|
||||||
|
"Bash(bash ~/.claude/scripts/gitea.sh GET /repos/akira/keinasystem/issues/11)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\akira\\AppData\\Local\\Temp"
|
"C:\\Users\\akira\\AppData\\Local\\Temp",
|
||||||
|
"C:\\Users\\akira\\Develop\\keinasystem_t02",
|
||||||
|
"/home/akira/develop",
|
||||||
|
"/home/akira/.docker",
|
||||||
|
"/tmp"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
db.sqlite3
|
||||||
postgres_data/
|
postgres_data/
|
||||||
nul
|
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: {}
|
||||||
554
CLAUDE.md
554
CLAUDE.md
@@ -1,517 +1,127 @@
|
|||||||
# Keina System - Claude 向けガイド
|
# Keina System - Claude 向けガイド
|
||||||
|
|
||||||
> **最終更新**: 2026-03-05
|
## プロジェクト概要
|
||||||
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
|
|
||||||
|
|
||||||
## 📌 このファイルの目的
|
|
||||||
|
|
||||||
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
|
|
||||||
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
|
|
||||||
|
|
||||||
## ⚠️ Claude への重要な指示
|
|
||||||
|
|
||||||
**このファイルは、セッションごとに必ず最初に読んでください。**
|
|
||||||
|
|
||||||
さらに、以下のルールを厳守してください:
|
|
||||||
|
|
||||||
### 📝 更新義務
|
|
||||||
|
|
||||||
**ドキュメントドリブンの徹底**
|
|
||||||
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
|
|
||||||
|
|
||||||
**機能追加・変更時は、必ずこのファイルを更新すること。**
|
|
||||||
|
|
||||||
- ✅ 新機能実装時 → 「実装状況」セクションを更新
|
|
||||||
- ✅ データモデル変更時 → 「データモデル概要」を更新
|
|
||||||
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
|
|
||||||
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
|
|
||||||
- ✅ 問題解決時 → 「トラブルシューティング」に追加
|
|
||||||
- ✅ 更新時は必ず「更新履歴」セクションに記録
|
|
||||||
|
|
||||||
|
|
||||||
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 プロジェクト概要(30秒で理解)
|
|
||||||
|
|
||||||
**何を作っているか:**
|
|
||||||
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
||||||
|
ユーザーは65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理。
|
||||||
|
|
||||||
**ユーザー:**
|
**技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
|
||||||
65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理
|
|
||||||
|
|
||||||
**技術スタック:**
|
**開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||||
- Backend: Django 5.2 + DRF + PostGIS
|
|
||||||
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
|
|
||||||
- Database: PostgreSQL 16 + PostGIS 3.4
|
|
||||||
|
|
||||||
**開発方針:**
|
|
||||||
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 プロジェクト構造
|
## 絶対に守るべき制約
|
||||||
|
|
||||||
|
1. **Field ↔ OfficialKyosaiField / OfficialChusankanField は M:N** — 決してFK (1:N) に戻さない
|
||||||
|
2. **年度+圃場の組み合わせは1つの Plan のみ** (`unique_together`)
|
||||||
|
3. **面積**: 表示=反(tan)、計算・保存=m2、変換: 1反=1000m2
|
||||||
|
4. **FertilizationEntry.fertilizer は PROTECT** — 使用中の肥料は削除不可
|
||||||
|
5. **3回同じコードを書くまでは抽象化しない**
|
||||||
|
6. **ドキュメントドリブン**: 仕様変更時はまず関連ドキュメントから更新する
|
||||||
|
|
||||||
|
## コーディング規約
|
||||||
|
|
||||||
|
- **Backend**: Django ベストプラクティス、日本語フィールドは `verbose_name` で対応
|
||||||
|
- **Frontend**: TypeScript strict mode、ESLint に従う
|
||||||
|
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## プロジェクト構造
|
||||||
|
|
||||||
```
|
```
|
||||||
keinasystem_t02/
|
keinasystem_t02/
|
||||||
├── CLAUDE.md # このファイル(Claude向けガイド)
|
├── CLAUDE.md # このファイル
|
||||||
├── .cursor/
|
├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
|
||||||
│ └── rules/
|
├── document/ # 設計書・マスタードキュメント
|
||||||
│ └── 30_Cursorガイド.md # Cursor専用ガイド
|
|
||||||
├── document/ # 詳細設計書(人間向け)
|
|
||||||
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
|
||||||
│ ├── 01_プロダクトビジョン.md
|
|
||||||
│ ├── 02_ユーザーストーリー.md
|
|
||||||
│ ├── 03_データ仕様書.md
|
|
||||||
│ ├── 04_画面設計書.md
|
|
||||||
│ └── 05_実装優先順位.md
|
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── keinasystem/ # Django設定
|
│ ├── keinasystem/ # Django設定 (settings.py, urls.py)
|
||||||
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
|
|
||||||
│ │ └── urls.py # ルートURL設定
|
|
||||||
│ └── apps/
|
│ └── apps/
|
||||||
│ ├── fields/ # 圃場管理アプリ
|
│ ├── fields/ # 圃場管理(Field, OfficialKyosaiField, OfficialChusankanField)
|
||||||
│ │ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
|
│ ├── plans/ # 作付け計画(Plan, Crop, Variety)
|
||||||
│ │ ├── views.py # インポート機能、CRUD API
|
│ ├── weather/ # 気象データ(WeatherRecord)
|
||||||
│ │ └── urls.py
|
│ ├── reports/ # 申請書PDF生成
|
||||||
│ ├── plans/ # 作付け計画アプリ
|
│ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
|
||||||
│ │ ├── models.py # Plan, Crop(+base_temp), Variety
|
│ ├── workrecords/ # 作業記録索引
|
||||||
│ │ └── views.py # 作付け計画API、集計API
|
│ └── mail/ # メールフィルタリング(Windmill連携)
|
||||||
│ ├── weather/ # 気象データアプリ
|
└── frontend/src/app/
|
||||||
│ │ ├── models.py # WeatherRecord (1日1行)
|
├── allocation/ # 作付け計画編集(メイン画面)
|
||||||
│ │ ├── views.py # sync(APIキー), records, summary, gdd, similarity
|
├── fields/ # 圃場一覧・詳細
|
||||||
│ │ ├── urls.py
|
├── fertilizer/ # 施肥計画・散布実績
|
||||||
│ │ └── management/commands/fetch_weather.py # 初回一括取得・差分取得
|
├── distribution/ # 運搬計画
|
||||||
│ └── reports/ # 申請書生成アプリ
|
├── weather/ # 気象データ
|
||||||
│ ├── views.py # PDF生成API
|
├── reports/ # 申請書DL
|
||||||
│ └── templates/ # PDF用HTMLテンプレート
|
├── import/ # データ取込
|
||||||
└── frontend/
|
├── mail/ # メール管理
|
||||||
└── src/app/
|
└── settings/ # パスワード変更
|
||||||
├── allocation/ # 作付け計画編集画面(メイン)
|
|
||||||
├── fields/ # 圃場一覧・詳細
|
|
||||||
├── reports/ # 申請書ダウンロード
|
|
||||||
├── import/ # データ取込画面
|
|
||||||
├── mail/
|
|
||||||
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
|
|
||||||
│ ├── history/ # メール処理履歴
|
|
||||||
│ └── rules/ # 送信者ルール管理
|
|
||||||
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
|
|
||||||
└── settings/
|
|
||||||
└── password/ # パスワード変更
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🗄️ データモデル概要
|
## よくある作業パターン
|
||||||
|
|
||||||
### コアエンティティ
|
|
||||||
|
|
||||||
```
|
|
||||||
Field (実圃場)
|
|
||||||
├── 39筆の実際の農地
|
|
||||||
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
|
|
||||||
├── group_name, display_order (グループ分け・表示順)
|
|
||||||
└── ManyToMany関係
|
|
||||||
├── kyosai_fields (共済マスタ、M:N)
|
|
||||||
└── chusankan_fields (中山間マスタ、M:N)
|
|
||||||
|
|
||||||
OfficialKyosaiField (共済マスタ)
|
|
||||||
└── 31区画(水稲共済細目書用)
|
|
||||||
|
|
||||||
OfficialChusankanField (中山間マスタ)
|
|
||||||
├── 71区画(中山間地域等直接支払交付金用)
|
|
||||||
└── 17フィールド: c_id, chusankan_flag, oaza, aza, chiban,
|
|
||||||
branch_num, land_type, area, planting_area,
|
|
||||||
original_crop, manager, owner, slope,
|
|
||||||
base_amount, steep_slope_addition, smart_agri_addition,
|
|
||||||
payment_amount
|
|
||||||
|
|
||||||
Plan (作付け計画)
|
|
||||||
├── field (FK to Field)
|
|
||||||
├── year (年度)
|
|
||||||
├── crop (FK to Crop)
|
|
||||||
├── variety (FK to Variety, nullable)
|
|
||||||
└── unique_together = ['field', 'year']
|
|
||||||
|
|
||||||
Crop (作物マスタ)
|
|
||||||
├── name(米、トウモロコシ、エンドウ、野菜、その他)
|
|
||||||
└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加
|
|
||||||
|
|
||||||
Variety (品種マスタ)
|
|
||||||
├── crop (FK to Crop)
|
|
||||||
├── name (品種名)
|
|
||||||
└── unique_together = ['crop', 'name']
|
|
||||||
|
|
||||||
MailSender (送信者ルール)
|
|
||||||
├── email (EmailField, nullable)
|
|
||||||
├── domain (CharField, nullable)
|
|
||||||
├── rule ('never_notify' | 'always_notify')
|
|
||||||
└── ConstraintCheck: email/domain どちらか一方のみ
|
|
||||||
|
|
||||||
MailEmail (受信メール記録)
|
|
||||||
├── account (gmail / gmail_service / hotmail / xserver1〜xserver6、旧データxserver)
|
|
||||||
├── message_id (unique)
|
|
||||||
├── sender_email, sender_domain
|
|
||||||
├── subject, body_preview
|
|
||||||
├── received_at, llm_verdict (important/not_important)
|
|
||||||
├── notified_at (LINE通知日時、nullable)
|
|
||||||
└── feedback (important/not_important/never_notify/always_notify, nullable)
|
|
||||||
|
|
||||||
MailNotificationToken (フィードバックURL用トークン)
|
|
||||||
├── email (OneToOne FK to MailEmail)
|
|
||||||
└── token (UUID, unique)
|
|
||||||
|
|
||||||
WeatherRecord (日次気象記録)
|
|
||||||
├── date (DateField, unique)
|
|
||||||
├── temp_mean, temp_max, temp_min (気温℃)
|
|
||||||
├── sunshine_h (日照時間h)
|
|
||||||
├── precip_mm (降水量mm)
|
|
||||||
├── wind_max (最大風速m/s)
|
|
||||||
└── pressure_min (最低気圧hPa)
|
|
||||||
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
|
||||||
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
|
||||||
|
|
||||||
Fertilizer (肥料マスタ)
|
|
||||||
├── name(肥料名、必須・unique)
|
|
||||||
├── maker(メーカー、任意)
|
|
||||||
├── capacity_kg(1袋重量kg、任意)
|
|
||||||
├── nitrogen_pct / phosphorus_pct / potassium_pct(成分%、任意)
|
|
||||||
└── notes(備考、任意)
|
|
||||||
|
|
||||||
FertilizationPlan (施肥計画)
|
|
||||||
├── name(計画名)
|
|
||||||
├── year(年度)
|
|
||||||
└── variety (FK to plans.Variety)
|
|
||||||
|
|
||||||
FertilizationEntry (施肥エントリ・中間テーブル)
|
|
||||||
├── plan (FK to FertilizationPlan, CASCADE)
|
|
||||||
├── field (FK to fields.Field, CASCADE)
|
|
||||||
├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可
|
|
||||||
├── bags(袋数、Decimal)
|
|
||||||
└── unique_together = ['plan', 'field', 'fertilizer']
|
|
||||||
|
|
||||||
DistributionPlan (分配計画)
|
|
||||||
├── fertilization_plan (FK to FertilizationPlan, CASCADE)
|
|
||||||
├── name(計画名)
|
|
||||||
└── groups → DistributionGroup
|
|
||||||
|
|
||||||
DistributionGroup (分配グループ)
|
|
||||||
├── distribution_plan (FK to DistributionPlan, CASCADE)
|
|
||||||
├── name(グループ名)
|
|
||||||
├── order(表示順)
|
|
||||||
└── unique_together = ['distribution_plan', 'name']
|
|
||||||
|
|
||||||
DistributionGroupField (グループ圃場割り当て)
|
|
||||||
├── distribution_plan (FK to DistributionPlan, CASCADE) ← 一意制約用
|
|
||||||
├── group (FK to DistributionGroup, CASCADE)
|
|
||||||
├── field (FK to fields.Field, PROTECT)
|
|
||||||
└── unique_together = ['distribution_plan', 'field'] ← 1圃場=1グループ/1計画
|
|
||||||
```
|
|
||||||
|
|
||||||
### 重要な設計判断
|
|
||||||
|
|
||||||
1. **M:N関係に変更**: 当初はM:1だったが、実運用で「1つの実圃場が複数の申請区画に紐づく」ケースが判明し、ManyToManyに変更(マイグレーション0003で実施)
|
|
||||||
|
|
||||||
2. **面積単位の二重管理**:
|
|
||||||
- DB内部は `area_m2` (整数) で保存
|
|
||||||
- 表示用に `area_tan` (反, Decimal) も保持
|
|
||||||
- 理由: 申請書ではm2、農家の感覚では反
|
|
||||||
|
|
||||||
3. **品種は全作物で統一**:
|
|
||||||
- 「作付けしない」も「その他」作物の品種として扱う
|
|
||||||
- UI操作を統一するため
|
|
||||||
|
|
||||||
4. **グループ機能**:
|
|
||||||
- `group_name` (エリアや用途によるグループ分け)
|
|
||||||
- `display_order` (リスト表示時の順序)
|
|
||||||
- マイグレーション0004で追加
|
|
||||||
|
|
||||||
5. **年度管理の設計方針**(⚠️ Phase 2 で必ず参照):
|
|
||||||
- **作付け計画**: 年度セレクタで独立して来年度も選べる。選んだ年度はlocalStorageに保存して維持
|
|
||||||
- **過去年度**: 「参照モード」として視覚的に区別(背景色・バナー)
|
|
||||||
- **Phase 2 の栽培管理・販売管理**: グローバル作業年度を導入し、基本は今年度に従う
|
|
||||||
- **栽培記録・作業日誌**: 日付中心設計、年度は日付から自動算出
|
|
||||||
- 参考: ソリマチ農業簿記の年度管理方式(明示的に年度を選択、変更するまで固定)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔑 重要な制約・ルール
|
|
||||||
|
|
||||||
### 絶対に守るべきこと
|
|
||||||
|
|
||||||
1. **データの整合性**
|
|
||||||
- 年度 + 圃場の組み合わせは1つの Plan のみ (`unique_together`)
|
|
||||||
- 作物 + 品種名の組み合わせは一意 (`unique_together`)
|
|
||||||
|
|
||||||
2. **面積の扱い**
|
|
||||||
- 表示: 反 (tan)
|
|
||||||
- 計算・保存: m2
|
|
||||||
- 変換: 1反 = 1000m2 (正確には991.736m2だが、実運用では1000で統一)
|
|
||||||
|
|
||||||
3. **M:N関係の重要性**
|
|
||||||
- Field と OfficialKyosaiField は M:N
|
|
||||||
- Field と OfficialChusankanField は M:N
|
|
||||||
- 決して FK (1:N) に戻さない
|
|
||||||
|
|
||||||
4. **シンプルさ優先**
|
|
||||||
- 過度な抽象化を避ける
|
|
||||||
- 3回同じコードを書くまでは抽象化しない
|
|
||||||
- ユーザーは1人、パフォーマンス最適化は後回し
|
|
||||||
|
|
||||||
### コーディング規約
|
|
||||||
|
|
||||||
- **Backend**: Django のベストプラクティスに従う
|
|
||||||
- **Frontend**: TypeScript strict mode、ESLint に従う
|
|
||||||
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
|
||||||
- **命名**: 日本語のフィールドは `verbose_name` で対応
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📍 現在の実装状況
|
|
||||||
|
|
||||||
### ✅ 実装済み(Phase 1 - MVP)
|
|
||||||
|
|
||||||
1. **認証**: JWT認証(アクセストークン24h、リフレッシュトークン7日)
|
|
||||||
2. **圃場管理**:
|
|
||||||
- CRUD API (`/api/fields/`)
|
|
||||||
- ODS/Excelインポート (`/api/fields/import/`)
|
|
||||||
- グループ機能(マイグレーション0004)
|
|
||||||
3. **作付け計画**:
|
|
||||||
- 年度別の作付け計画 CRUD (`/api/plans/?year=2025`)
|
|
||||||
- 前年度コピー機能 (`/api/plans/copy_from_previous_year/`)
|
|
||||||
- 一括更新 (`/api/plans/bulk_update/`)
|
|
||||||
- 集計API (`/api/plans/summary/?year=2025`)
|
|
||||||
4. **申請書生成**:
|
|
||||||
- 水稲共済細目書 PDF (`/api/reports/kyosai/?year=2025`)
|
|
||||||
- 中山間交付金 PDF (`/api/reports/chusankan/?year=2025`)
|
|
||||||
5. **フロントエンド**:
|
|
||||||
- 作付け計画編集画面(集計サイドバー付き)
|
|
||||||
- 圃場一覧・詳細・新規作成
|
|
||||||
- データ取込画面
|
|
||||||
- 申請書ダウンロード画面
|
|
||||||
- ダッシュボード画面(概要サマリー、作物別集計、クイックアクセス)
|
|
||||||
6. **対応付け可視化・紐づけ管理** (E-2):
|
|
||||||
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
|
|
||||||
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
|
|
||||||
- 共通 LinkModal コンポーネント
|
|
||||||
7. **メールフィルタリング機能**(Windmill連携):
|
|
||||||
- Django `apps/mail` アプリ(MailSender, MailEmail, MailNotificationToken)
|
|
||||||
- Windmill向けAPI(APIキー認証): `GET /api/mail/sender-rule/`, `GET /api/mail/sender-context/`, `POST /api/mail/emails/`, `GET /api/mail/stats/`
|
|
||||||
- フィードバックAPI(認証不要・UUIDトークン): `GET/POST /api/mail/feedback/<token>/`
|
|
||||||
- ルール管理API(JWT認証): `GET/POST/DELETE /api/mail/senders/`, `PATCH /api/mail/emails/<pk>/feedback/`
|
|
||||||
- フィードバックページ: `/mail/feedback/[token]`(LINEからタップ一発、認証不要)
|
|
||||||
- ルール管理ページ: `/mail/rules/`
|
|
||||||
- 処理履歴ページ: `/mail/history/`
|
|
||||||
- 対応アカウント: Gmail × 2、Xserver × 6(本番稼働中、`account` は `xserver1`〜`xserver6` で識別)
|
|
||||||
- Windmill フロー: `f/mail/mail_filter`(本番: windmill.keinafarm.net にデプロイ済み、10分間隔スケジュール)
|
|
||||||
- To: ヘッダー宛先補正を実装(Gmail先行取り込み時も @keinafarm.com 宛は xserver1〜xserver6 として記録/通知)
|
|
||||||
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
|
|
||||||
8. **パスワード変更機能**:
|
|
||||||
- Backend: `POST /api/auth/change-password/`(JWT認証、`ChangePasswordView` in `keinasystem/urls.py`)
|
|
||||||
- Frontend: `/settings/password` ページ
|
|
||||||
- Navbar: KeyRound アイコンボタン(ログアウトボタンの左隣)
|
|
||||||
9. **気象データ基盤**(Windmill連携):
|
|
||||||
- Django `apps/weather` アプリ(WeatherRecord: 1日1行、2016-01-01〜)
|
|
||||||
- データソース: Open-Meteo archive API(窪川 lat=33.213, lon=133.133)
|
|
||||||
- Windmill向けAPI(APIキー認証): `POST /api/weather/sync/`(upsert、単一/リスト両対応)
|
|
||||||
- フロントエンド向けAPI(JWT認証):
|
|
||||||
- `GET /api/weather/records/?year=&start=&end=` 日次レコード一覧
|
|
||||||
- `GET /api/weather/summary/?year=` 月別・年間サマリー(猛暑日・冬日数含む)
|
|
||||||
- `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度(GDD)
|
|
||||||
- `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較)
|
|
||||||
- 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]`
|
|
||||||
- Windmill フロー: `f/weather/weather_sync`(本番稼働中、毎朝6時 Asia/Tokyo)
|
|
||||||
- `Crop.base_temp`(GDD計算の基準温度、default=0.0℃)をCropモデルに追加
|
|
||||||
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
|
|
||||||
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts)
|
|
||||||
- **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測)
|
|
||||||
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
|
||||||
10. **施肥計画機能**(本番稼働中):
|
|
||||||
- Django `apps/fertilizer` アプリ(Fertilizer, FertilizationPlan, FertilizationEntry)
|
|
||||||
- API: `/api/fertilizer/fertilizers/`, `/api/fertilizer/plans/`, `/api/fertilizer/calculate/`, `/api/fertilizer/candidate_fields/`
|
|
||||||
- PDF出力: `/api/fertilizer/plans/{id}/pdf/`(WeasyPrint、A4横向き)
|
|
||||||
- FertilizationEntry.fertilizer は PROTECT(使用中の肥料は削除不可・migration 0002)
|
|
||||||
- 自動計算3方式: per_tan(反当袋数)/ even(均等配分)/ nitrogen(反当チッソ)
|
|
||||||
- 四捨五入トグル: `≈`(丸め)/ `↩`(元の計算値に戻す)
|
|
||||||
- フロントエンド: `/fertilizer`(一覧)、`/fertilizer/masters`(肥料マスタ)、`/fertilizer/new`・`/fertilizer/[id]/edit`(編集)
|
|
||||||
- 施肥機能全体で alert/confirm を廃止し、React インラインバナーでエラー表示
|
|
||||||
- マスタードキュメント: `document/13_マスタードキュメント_施肥計画編.md`
|
|
||||||
10. **施肥計画機能**:
|
|
||||||
- Django `apps/fertilizer` アプリ(Fertilizer, FertilizationPlan, FertilizationEntry)
|
|
||||||
- API(JWT認証): `GET/POST /api/fertilizer/fertilizers/`, `GET/POST /api/fertilizer/plans/?year=`, `GET /api/fertilizer/plans/{id}/pdf/`, `GET /api/fertilizer/candidate_fields/?year=&variety_id=`, `POST /api/fertilizer/calculate/`
|
|
||||||
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
|
|
||||||
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new`・`/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
|
|
||||||
- スコープ外(将来): 購入管理
|
|
||||||
11. **分配計画機能**(2026-03-02 実装):
|
|
||||||
- Django `apps/fertilizer` アプリに3モデル追加(DistributionPlan, DistributionGroup, DistributionGroupField)
|
|
||||||
- API(JWT認証): `GET/POST /api/fertilizer/distribution/?year=`, `GET/PUT/DELETE /api/fertilizer/distribution/{id}/`, `GET /api/fertilizer/distribution/{id}/pdf/`
|
|
||||||
- 施肥計画を元に圃場をカスタムグループに割り当て、グループ×肥料の集計表を生成
|
|
||||||
- PDF出力(A4横向き・グループ合計行★+圃場サブ行)
|
|
||||||
- フロントエンド: `/distribution/`(一覧), `/distribution/new`・`/distribution/[id]/edit`(編集)
|
|
||||||
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
|
|
||||||
|
|
||||||
### 🚧 既知の課題・技術的負債
|
|
||||||
|
|
||||||
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
|
|
||||||
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
|
||||||
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
|
||||||
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
|
||||||
### 🔜 次の実装タスク(優先順)
|
|
||||||
|
|
||||||
差異レポートの全タスク(A-1〜A-8, B-1〜B-5, C-1〜C-8, D-1〜D-4, E-1〜E-2)は全件完了。
|
|
||||||
Phase 2 のタスクに進む段階。
|
|
||||||
|
|
||||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
|
||||||
|
|
||||||
### 📅 次のマイルストーン(Phase 2)
|
|
||||||
|
|
||||||
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
|
|
||||||
- 作業予定のカレンダー表示
|
|
||||||
- モバイル対応の改善(スマホでの記録入力)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ よくある作業パターン
|
|
||||||
|
|
||||||
### 新しいモデルを追加する場合
|
### 新しいモデルを追加する場合
|
||||||
|
|
||||||
1. `apps/<app_name>/models.py` にモデルクラスを追加
|
1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
|
||||||
2. `python manage.py makemigrations`
|
5. Serializer → 6. ViewSet → 7. URL登録
|
||||||
3. `python manage.py migrate`
|
|
||||||
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
|
|
||||||
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
|
|
||||||
6. ViewSet 作成 (`apps/<app_name>/views.py`)
|
|
||||||
7. URL登録 (`apps/<app_name>/urls.py`)
|
|
||||||
|
|
||||||
### 新しいAPI エンドポイントを追加する場合
|
### 新しいAPI / 画面を追加する場合
|
||||||
|
|
||||||
1. `apps/<app_name>/views.py` にビューを追加
|
- API: `views.py` → `urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
|
||||||
2. `apps/<app_name>/urls.py` にパスを追加
|
- 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
|
||||||
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
|
|
||||||
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
|
|
||||||
|
|
||||||
### 新しい画面を追加する場合
|
|
||||||
|
|
||||||
1. `frontend/src/app/<page_name>/page.tsx` を作成
|
|
||||||
2. 必要に応じてレイアウト調整 (`layout.tsx`)
|
|
||||||
3. API呼び出しは `useEffect` + `fetch` で実装
|
|
||||||
4. ローディング状態、エラー状態を適切に処理
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 トラブルシューティング
|
## デプロイ・トラブルシューティング
|
||||||
|
|
||||||
### 本番デプロイコマンド(必須)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ⚠️ --env-file .env.production を必ず付けること(省略するとSECRET_KEYが空でbackendが起動しない)
|
# 本番デプロイ(git pull → build → up -d を一括実行)
|
||||||
# ⚠️ 本番ファイルは keinasystem ユーザー所有。git pull は sudo -u keinasystem で実行
|
ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
|
||||||
ssh keinafarm-claude 'sudo -u keinasystem git -C /home/keinasystem/keinasystem_t02 pull origin main && \
|
|
||||||
cd /home/keinasystem/keinasystem_t02 && \
|
|
||||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production build && \
|
|
||||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production up -d'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本番確認手順(デプロイ後の必須チェック)
|
# 本番ヘルスチェック(9項目、curlベース)
|
||||||
|
|
||||||
**⚠️ Playwright(ビジュアルテスト)を使う前に、必ずcurlで先に確認すること。**
|
|
||||||
curlはキャッシュの影響を受けず、偽装不可能な確認手段。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ステップ1: curlヘルスチェック(全9項目、所要約10秒)
|
|
||||||
bash scripts/check_prod.sh claude keina1234
|
bash scripts/check_prod.sh claude keina1234
|
||||||
# → 全 9 項目 PASS が出れば本番が正常稼働中
|
|
||||||
|
|
||||||
# ステップ2(任意): Playwrightでビジュアル確認する場合のプロンプト原則
|
# 本番マイグレーション(バックエンド変更時のみ)
|
||||||
# - 「認証できなければ即中止して報告せよ」を必ず明記
|
|
||||||
# - 「スクリーンショットには今日の日付が画面内に見えること」を要求
|
|
||||||
# - 「成功の証跡(HTTP レスポンスの実テキスト)を必ず添付すること」を要求
|
|
||||||
```
|
|
||||||
|
|
||||||
**本番バックエンドのマイグレーション適用(バックエンド変更時のみ):**
|
|
||||||
```bash
|
|
||||||
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
||||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production build backend && \
|
sudo -u keinasystem docker compose build backend && \
|
||||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production up -d && \
|
sudo -u keinasystem docker compose up -d && sleep 5 && \
|
||||||
sleep 5 && \
|
sudo -u keinasystem docker compose exec backend python manage.py migrate'
|
||||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production exec backend python manage.py migrate'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### マイグレーションエラー
|
- **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
|
||||||
|
- **CORS**: `settings.py` の `CORS_ALLOWED_ORIGINS`(localhost:3000 許可済み)
|
||||||
```bash
|
- **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
|
||||||
# マイグレーションをリセット(開発環境のみ!)
|
|
||||||
docker-compose exec backend python manage.py migrate <app_name> zero
|
|
||||||
docker-compose exec backend python manage.py makemigrations
|
|
||||||
docker-compose exec backend python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORS エラー
|
|
||||||
|
|
||||||
- `backend/keinasystem/settings.py` の `CORS_ALLOWED_ORIGINS` を確認
|
|
||||||
- 現在は `http://localhost:3000` と `http://127.0.0.1:3000` を許可
|
|
||||||
|
|
||||||
### JWT トークンエラー
|
|
||||||
|
|
||||||
- トークンの有効期限を確認(アクセストークン: 24時間)
|
|
||||||
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`)
|
|
||||||
|
|
||||||
### PDF 生成エラー
|
|
||||||
|
|
||||||
- WeasyPrint のインストールを確認
|
|
||||||
- 日本語フォントの設定を確認(HTMLテンプレートのCSS)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 詳細情報へのリンク
|
## マスタードキュメント(機能別リファレンス)
|
||||||
|
|
||||||
### マスタードキュメント(機能別の網羅的リファレンス)
|
特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
|
||||||
|
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
|
||||||
|
|
||||||
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。**
|
| 機能 | ドキュメント |
|
||||||
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が
|
|------|------------|
|
||||||
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
|
| 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
|
||||||
|
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
|
||||||
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
|
||||||
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
|
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
|
||||||
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
|
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
|
||||||
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md`
|
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
|
||||||
- **分配計画機能**: `document/14_マスタードキュメント_分配計画編.md`
|
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
|
||||||
|
| データモデル全体 | `document/03_データ仕様書.md` |
|
||||||
### 設計ドキュメント(プロジェクト横断)
|
|
||||||
|
|
||||||
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
|
|
||||||
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
|
|
||||||
- **データモデル詳細**: `document/03_データ仕様書.md`
|
|
||||||
- **画面設計**: `document/04_画面設計書.md`
|
|
||||||
- **実装手順**: `document/00_Gemini向け統合指示書.md`
|
|
||||||
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💡 新しいセッションでの推奨フロー
|
## セッション開始・終了フロー
|
||||||
|
|
||||||
|
### 開始時
|
||||||
1. この `CLAUDE.md` を読む
|
1. この `CLAUDE.md` を読む
|
||||||
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md`)
|
2. `HANDOVER.md` で前回の引き継ぎを確認する
|
||||||
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照
|
3. `TASK_CONTEXT.md` で現在の状況を把握する
|
||||||
4. 実装・修正を行う
|
4. タスク対象の**マスタードキュメント**を読む
|
||||||
5. 重要な設計判断があれば、この `CLAUDE.md` と該当マスタードキュメントを更新
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 更新履歴
|
|
||||||
|
|
||||||
- 2026-03-05: メール通知機能を更新。MailEmail.account を xserver1〜xserver6 で識別可能に変更。Windmill mail_filter に To ヘッダー宛先補正を追加し、Gmail先行取り込みでも Xserver 宛先ラベルが崩れないよう修正。マスタードキュメント/仕様書を同期。
|
|
||||||
|
|
||||||
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除
|
|
||||||
- 2026-03-02: 分配計画機能を実装。`apps/fertilizer` に DistributionPlan/DistributionGroup/DistributionGroupField 追加、API `/api/fertilizer/distribution/`、PDF出力(A4横・グループ★行+圃場サブ行)、フロントエンド `/distribution/`。マスタードキュメント `document/14_マスタードキュメント_分配計画編.md` 追加
|
|
||||||
- 2026-03-01: 施肥計画機能を実装・本番稼働。`apps/fertilizer`(Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力, PROTECT migration 0002)、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。施肥機能全体で alert/confirm 廃止・インラインバナーに統一。マスタードキュメント `document/13_マスタードキュメント_施肥計画編.md` 追加
|
|
||||||
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`(WeatherRecord, 5 API)、Windmill `f/weather/weather_sync`(毎朝6時)、フロントエンド `/weather`(年別集計・期間指定・Rechartsグラフ)。`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
|
|
||||||
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
|
|
||||||
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
|
||||||
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
|
|
||||||
- 2026-02-18: E-2(対応付け可視化・紐づけ管理)仕様追加。画面設計書・差異レポート・次タスク一覧を更新。完了済みタスク(A-8, D-1〜D-4, E-1)を既知の課題から除外
|
|
||||||
- 2026-02-17: ドキュメント一斉更新(差異レポートA〜E反映、CSV→PDF統一、M:N関係、中山間モデル17列化、インライン編集方式、Navbar追加、既知の課題・次タスク一覧追加)
|
|
||||||
- 2026-02-16: 初版作成(ハイブリッドアプローチの方針決定)
|
|
||||||
|
|
||||||
|
|
||||||
|
### 終了時(または作業の区切りで必ず実行)
|
||||||
|
1. `HANDOVER.md` を定型フォーマットで更新する
|
||||||
|
2. 重要な設計判断があれば `CLAUDE.md` と該当マスタードキュメントを更新
|
||||||
|
3. 実装状況に変化があれば `TASK_CONTEXT.md` を更新
|
||||||
|
|||||||
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
@@ -1,5 +1,9 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
|
from .models import (
|
||||||
|
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||||
|
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||||
|
SpreadingSession, SpreadingSessionItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Fertilizer)
|
@admin.register(Fertilizer)
|
||||||
@@ -14,30 +18,58 @@ class FertilizationEntryInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(FertilizationPlan)
|
@admin.register(FertilizationPlan)
|
||||||
class FertilizationPlanAdmin(admin.ModelAdmin):
|
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'year', 'variety']
|
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
|
||||||
list_filter = ['year']
|
list_filter = ['year']
|
||||||
inlines = [FertilizationEntryInline]
|
inlines = [FertilizationEntryInline]
|
||||||
|
|
||||||
|
|
||||||
class DistributionGroupFieldInline(admin.TabularInline):
|
class DeliveryGroupFieldInline(admin.TabularInline):
|
||||||
model = DistributionGroupField
|
model = DeliveryGroupField
|
||||||
extra = 0
|
extra = 0
|
||||||
readonly_fields = ['distribution_plan']
|
readonly_fields = ['delivery_plan']
|
||||||
|
|
||||||
|
|
||||||
class DistributionGroupInline(admin.TabularInline):
|
class DeliveryGroupInline(admin.TabularInline):
|
||||||
model = DistributionGroup
|
model = DeliveryGroup
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
@admin.register(DistributionPlan)
|
class DeliveryTripItemInline(admin.TabularInline):
|
||||||
class DistributionPlanAdmin(admin.ModelAdmin):
|
model = DeliveryTripItem
|
||||||
list_display = ['name', 'fertilization_plan', 'created_at']
|
extra = 0
|
||||||
list_filter = ['fertilization_plan__year']
|
|
||||||
inlines = [DistributionGroupInline]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(DistributionGroup)
|
class DeliveryTripInline(admin.TabularInline):
|
||||||
class DistributionGroupAdmin(admin.ModelAdmin):
|
model = DeliveryTrip
|
||||||
list_display = ['name', 'distribution_plan', 'order']
|
extra = 0
|
||||||
inlines = [DistributionGroupFieldInline]
|
|
||||||
|
|
||||||
|
@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]
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -17,6 +17,14 @@ class Fertilizer(models.Model):
|
|||||||
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
||||||
)
|
)
|
||||||
notes = models.TextField(blank=True, null=True, verbose_name='備考')
|
notes = models.TextField(blank=True, null=True, verbose_name='備考')
|
||||||
|
material = models.OneToOneField(
|
||||||
|
'materials.Material',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='legacy_fertilizer',
|
||||||
|
verbose_name='資材マスタ',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '肥料マスタ'
|
verbose_name = '肥料マスタ'
|
||||||
@@ -35,6 +43,8 @@ class FertilizationPlan(models.Model):
|
|||||||
related_name='fertilization_plans', verbose_name='品種'
|
related_name='fertilization_plans', verbose_name='品種'
|
||||||
)
|
)
|
||||||
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
|
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
|
||||||
|
is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み')
|
||||||
|
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -59,6 +69,13 @@ class FertilizationEntry(models.Model):
|
|||||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||||
)
|
)
|
||||||
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||||
|
actual_bags = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=4,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='実績袋数',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '施肥エントリ'
|
verbose_name = '施肥エントリ'
|
||||||
@@ -70,51 +87,48 @@ class FertilizationEntry(models.Model):
|
|||||||
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋"
|
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋"
|
||||||
|
|
||||||
|
|
||||||
class DistributionPlan(models.Model):
|
class DeliveryPlan(models.Model):
|
||||||
"""分配計画:施肥計画の圃場をカスタムグループに割り当て、配置場所単位で集計する"""
|
"""運搬計画:施肥計画の肥料を軽トラで運ぶ単位で計画・記録する"""
|
||||||
fertilization_plan = models.ForeignKey(
|
year = models.IntegerField(verbose_name='年度')
|
||||||
FertilizationPlan, on_delete=models.CASCADE,
|
|
||||||
related_name='distribution_plans', verbose_name='施肥計画'
|
|
||||||
)
|
|
||||||
name = models.CharField(max_length=200, verbose_name='計画名')
|
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '分配計画'
|
verbose_name = '運搬計画'
|
||||||
verbose_name_plural = '分配計画'
|
verbose_name_plural = '運搬計画'
|
||||||
ordering = ['-fertilization_plan__year', 'name']
|
ordering = ['-year', 'name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.fertilization_plan.year} {self.name}"
|
return f"{self.year} {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class DistributionGroup(models.Model):
|
class DeliveryGroup(models.Model):
|
||||||
"""分配グループ:ある場所にまとめて置く圃場のグループ"""
|
"""配送先グループ:まとめて運ぶ圃場のグループ"""
|
||||||
distribution_plan = models.ForeignKey(
|
delivery_plan = models.ForeignKey(
|
||||||
DistributionPlan, on_delete=models.CASCADE,
|
DeliveryPlan, on_delete=models.CASCADE,
|
||||||
related_name='groups', verbose_name='分配計画'
|
related_name='groups', verbose_name='運搬計画'
|
||||||
)
|
)
|
||||||
name = models.CharField(max_length=100, verbose_name='グループ名')
|
name = models.CharField(max_length=100, verbose_name='グループ名')
|
||||||
order = models.PositiveIntegerField(default=0, verbose_name='表示順')
|
order = models.PositiveIntegerField(default=0, verbose_name='表示順')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '分配グループ'
|
verbose_name = '配送先グループ'
|
||||||
verbose_name_plural = '分配グループ'
|
verbose_name_plural = '配送先グループ'
|
||||||
unique_together = [['distribution_plan', 'name']]
|
unique_together = [['delivery_plan', 'name']]
|
||||||
ordering = ['order', 'id']
|
ordering = ['order', 'id']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.distribution_plan} / {self.name}"
|
return f"{self.delivery_plan} / {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class DistributionGroupField(models.Model):
|
class DeliveryGroupField(models.Model):
|
||||||
"""圃場のグループへの割り当て(1圃場=1グループ/1分配計画)"""
|
"""圃場のグループへの割り当て(1圃場=1グループ/1運搬計画)"""
|
||||||
distribution_plan = models.ForeignKey(
|
delivery_plan = models.ForeignKey(
|
||||||
DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
|
DeliveryPlan, on_delete=models.CASCADE, verbose_name='運搬計画'
|
||||||
)
|
)
|
||||||
group = models.ForeignKey(
|
group = models.ForeignKey(
|
||||||
DistributionGroup, on_delete=models.CASCADE,
|
DeliveryGroup, on_delete=models.CASCADE,
|
||||||
related_name='field_assignments', verbose_name='グループ'
|
related_name='field_assignments', verbose_name='グループ'
|
||||||
)
|
)
|
||||||
field = models.ForeignKey(
|
field = models.ForeignKey(
|
||||||
@@ -124,8 +138,111 @@ class DistributionGroupField(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'グループ圃場割り当て'
|
verbose_name = 'グループ圃場割り当て'
|
||||||
verbose_name_plural = 'グループ圃場割り当て'
|
verbose_name_plural = 'グループ圃場割り当て'
|
||||||
unique_together = [['distribution_plan', 'field']]
|
unique_together = [['delivery_plan', 'field']]
|
||||||
ordering = ['field__display_order', 'field__id']
|
ordering = ['field__display_order', 'field__id']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.group.name} / {self.field.name}"
|
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}袋'
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db.models import Sum
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
|
|
||||||
|
from apps.workrecords.services import sync_delivery_work_record
|
||||||
|
from .models import (
|
||||||
|
DeliveryGroup,
|
||||||
|
DeliveryGroupField,
|
||||||
|
DeliveryPlan,
|
||||||
|
DeliveryTrip,
|
||||||
|
DeliveryTripItem,
|
||||||
|
FertilizationEntry,
|
||||||
|
FertilizationPlan,
|
||||||
|
Fertilizer,
|
||||||
|
SpreadingSession,
|
||||||
|
SpreadingSessionItem,
|
||||||
|
)
|
||||||
|
from .services import sync_actual_bags_for_pairs, sync_spreading_session_side_effects
|
||||||
|
|
||||||
|
|
||||||
class FertilizerSerializer(serializers.ModelSerializer):
|
class FertilizerSerializer(serializers.ModelSerializer):
|
||||||
|
material_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Fertilizer
|
model = Fertilizer
|
||||||
fields = '__all__'
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'maker',
|
||||||
|
'capacity_kg',
|
||||||
|
'nitrogen_pct',
|
||||||
|
'phosphorus_pct',
|
||||||
|
'potassium_pct',
|
||||||
|
'notes',
|
||||||
|
'material',
|
||||||
|
'material_id',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_material_id(self, obj):
|
||||||
|
return obj.material_id
|
||||||
|
|
||||||
|
|
||||||
class FertilizationEntrySerializer(serializers.ModelSerializer):
|
class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||||
@@ -17,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationEntry
|
model = FertilizationEntry
|
||||||
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
|
fields = [
|
||||||
|
'id',
|
||||||
|
'field',
|
||||||
|
'field_name',
|
||||||
|
'field_area_tan',
|
||||||
|
'fertilizer',
|
||||||
|
'fertilizer_name',
|
||||||
|
'bags',
|
||||||
|
'actual_bags',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||||
@@ -26,12 +68,36 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||||
field_count = serializers.SerializerMethodField()
|
field_count = serializers.SerializerMethodField()
|
||||||
fertilizer_count = serializers.SerializerMethodField()
|
fertilizer_count = serializers.SerializerMethodField()
|
||||||
|
planned_total_bags = serializers.SerializerMethodField()
|
||||||
|
spread_total_bags = serializers.SerializerMethodField()
|
||||||
|
remaining_total_bags = serializers.SerializerMethodField()
|
||||||
|
spread_status = serializers.SerializerMethodField()
|
||||||
|
is_confirmed = serializers.BooleanField(read_only=True)
|
||||||
|
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||||
|
is_variety_change_plan = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationPlan
|
model = FertilizationPlan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
'id',
|
||||||
'calc_settings', 'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
|
'name',
|
||||||
|
'year',
|
||||||
|
'variety',
|
||||||
|
'variety_name',
|
||||||
|
'crop_name',
|
||||||
|
'calc_settings',
|
||||||
|
'entries',
|
||||||
|
'field_count',
|
||||||
|
'fertilizer_count',
|
||||||
|
'planned_total_bags',
|
||||||
|
'spread_total_bags',
|
||||||
|
'remaining_total_bags',
|
||||||
|
'spread_status',
|
||||||
|
'is_confirmed',
|
||||||
|
'confirmed_at',
|
||||||
|
'is_variety_change_plan',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_variety_name(self, obj):
|
def get_variety_name(self, obj):
|
||||||
@@ -46,9 +112,35 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
def get_fertilizer_count(self, obj):
|
def get_fertilizer_count(self, obj):
|
||||||
return obj.entries.values('fertilizer').distinct().count()
|
return obj.entries.values('fertilizer').distinct().count()
|
||||||
|
|
||||||
|
def get_planned_total_bags(self, obj):
|
||||||
|
total = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||||
|
return str(total)
|
||||||
|
|
||||||
|
def get_spread_total_bags(self, obj):
|
||||||
|
total = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||||
|
return str(total)
|
||||||
|
|
||||||
|
def get_remaining_total_bags(self, obj):
|
||||||
|
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||||
|
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||||
|
return str(planned - actual)
|
||||||
|
|
||||||
|
def get_spread_status(self, obj):
|
||||||
|
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||||
|
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||||
|
if actual <= 0:
|
||||||
|
return 'unspread'
|
||||||
|
if actual > planned:
|
||||||
|
return 'over_applied'
|
||||||
|
if actual < planned:
|
||||||
|
return 'partial'
|
||||||
|
return 'completed'
|
||||||
|
|
||||||
|
def get_is_variety_change_plan(self, obj):
|
||||||
|
return obj.name.endswith('(品種変更移動)')
|
||||||
|
|
||||||
|
|
||||||
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||||
"""保存用(entries を一括で受け取る)"""
|
|
||||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -58,7 +150,8 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
|||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
entries_data = validated_data.pop('entries', [])
|
entries_data = validated_data.pop('entries', [])
|
||||||
plan = FertilizationPlan.objects.create(**validated_data)
|
plan = FertilizationPlan.objects.create(**validated_data)
|
||||||
self._save_entries(plan, entries_data)
|
pairs = self._save_entries(plan, entries_data)
|
||||||
|
sync_actual_bags_for_pairs(plan.year, pairs)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
@@ -68,22 +161,24 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
|||||||
instance.save()
|
instance.save()
|
||||||
if entries_data is not None:
|
if entries_data is not None:
|
||||||
instance.entries.all().delete()
|
instance.entries.all().delete()
|
||||||
self._save_entries(instance, entries_data)
|
pairs = self._save_entries(instance, entries_data)
|
||||||
|
sync_actual_bags_for_pairs(instance.year, pairs)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def _save_entries(self, plan, entries_data):
|
def _save_entries(self, plan, entries_data):
|
||||||
|
pairs = set()
|
||||||
for entry in entries_data:
|
for entry in entries_data:
|
||||||
|
pairs.add((entry['field_id'], entry['fertilizer_id']))
|
||||||
FertilizationEntry.objects.create(
|
FertilizationEntry.objects.create(
|
||||||
plan=plan,
|
plan=plan,
|
||||||
field_id=entry['field_id'],
|
field_id=entry['field_id'],
|
||||||
fertilizer_id=entry['fertilizer_id'],
|
fertilizer_id=entry['fertilizer_id'],
|
||||||
bags=entry['bags'],
|
bags=entry['bags'],
|
||||||
)
|
)
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
# ─── 分配計画 ────────────────────────────────────────────────────────────
|
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class DistributionGroupFieldSerializer(serializers.ModelSerializer):
|
|
||||||
id = serializers.IntegerField(source='field.id', read_only=True)
|
id = serializers.IntegerField(source='field.id', read_only=True)
|
||||||
name = serializers.CharField(source='field.name', read_only=True)
|
name = serializers.CharField(source='field.name', read_only=True)
|
||||||
area_tan = serializers.DecimalField(
|
area_tan = serializers.DecimalField(
|
||||||
@@ -91,128 +186,307 @@ class DistributionGroupFieldSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DistributionGroupField
|
model = DeliveryGroupField
|
||||||
fields = ['id', 'name', 'area_tan']
|
fields = ['id', 'name', 'area_tan']
|
||||||
|
|
||||||
|
|
||||||
class DistributionGroupReadSerializer(serializers.ModelSerializer):
|
class DeliveryGroupReadSerializer(serializers.ModelSerializer):
|
||||||
fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
|
fields = DeliveryGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DistributionGroup
|
model = DeliveryGroup
|
||||||
fields = ['id', 'name', 'order', 'fields']
|
fields = ['id', 'name', 'order', 'fields']
|
||||||
|
|
||||||
|
|
||||||
class FertilizationPlanForDistributionSerializer(serializers.ModelSerializer):
|
class DeliveryTripItemSerializer(serializers.ModelSerializer):
|
||||||
"""分配計画詳細に埋め込む施肥計画情報(肥料一覧・entries 含む)"""
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
variety_name = serializers.SerializerMethodField()
|
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||||
crop_name = serializers.SerializerMethodField()
|
spread_bags = serializers.SerializerMethodField()
|
||||||
fertilizers = serializers.SerializerMethodField()
|
remaining_bags = serializers.SerializerMethodField()
|
||||||
entries = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationPlan
|
model = DeliveryTripItem
|
||||||
fields = ['id', 'name', 'year', 'variety_name', 'crop_name', 'fertilizers', 'entries']
|
|
||||||
|
|
||||||
def get_variety_name(self, obj):
|
|
||||||
return obj.variety.name
|
|
||||||
|
|
||||||
def get_crop_name(self, obj):
|
|
||||||
return obj.variety.crop.name
|
|
||||||
|
|
||||||
def get_fertilizers(self, obj):
|
|
||||||
fert_ids = obj.entries.values_list('fertilizer_id', flat=True).distinct()
|
|
||||||
from .models import Fertilizer as F
|
|
||||||
fertilizers = F.objects.filter(id__in=fert_ids).order_by('name')
|
|
||||||
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
|
||||||
|
|
||||||
def get_entries(self, obj):
|
|
||||||
return [
|
|
||||||
{'field': e.field_id, 'fertilizer': e.fertilizer_id, 'bags': str(e.bags)}
|
|
||||||
for e in obj.entries.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class DistributionPlanListSerializer(serializers.ModelSerializer):
|
|
||||||
fertilization_plan_id = serializers.IntegerField(source='fertilization_plan.id', read_only=True)
|
|
||||||
fertilization_plan_name = serializers.CharField(source='fertilization_plan.name', read_only=True)
|
|
||||||
year = serializers.IntegerField(source='fertilization_plan.year', read_only=True)
|
|
||||||
variety_name = serializers.SerializerMethodField()
|
|
||||||
crop_name = serializers.SerializerMethodField()
|
|
||||||
group_count = serializers.SerializerMethodField()
|
|
||||||
field_count = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = DistributionPlan
|
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
|
'id',
|
||||||
'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
|
'field',
|
||||||
'created_at', 'updated_at',
|
'field_name',
|
||||||
|
'fertilizer',
|
||||||
|
'fertilizer_name',
|
||||||
|
'bags',
|
||||||
|
'spread_bags',
|
||||||
|
'remaining_bags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_variety_name(self, obj):
|
def get_spread_bags(self, obj):
|
||||||
return obj.fertilization_plan.variety.name
|
total = (
|
||||||
|
SpreadingSessionItem.objects.filter(
|
||||||
|
session__year=obj.trip.delivery_plan.year,
|
||||||
|
field_id=obj.field_id,
|
||||||
|
fertilizer_id=obj.fertilizer_id,
|
||||||
|
).aggregate(total=Sum('actual_bags'))['total']
|
||||||
|
)
|
||||||
|
return str(total or Decimal('0'))
|
||||||
|
|
||||||
def get_crop_name(self, obj):
|
def get_remaining_bags(self, obj):
|
||||||
return obj.fertilization_plan.variety.crop.name
|
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):
|
def get_group_count(self, obj):
|
||||||
return obj.groups.count()
|
return obj.groups.count()
|
||||||
|
|
||||||
def get_field_count(self, obj):
|
def get_trip_count(self, obj):
|
||||||
return obj.distributiongroupfield_set.count()
|
return obj.trips.count()
|
||||||
|
|
||||||
|
|
||||||
class DistributionPlanReadSerializer(serializers.ModelSerializer):
|
class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
||||||
fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
|
groups = DeliveryGroupReadSerializer(many=True, read_only=True)
|
||||||
groups = DistributionGroupReadSerializer(many=True, read_only=True)
|
trips = DeliveryTripReadSerializer(many=True, read_only=True)
|
||||||
unassigned_fields = serializers.SerializerMethodField()
|
unassigned_fields = serializers.SerializerMethodField()
|
||||||
|
available_fertilizers = serializers.SerializerMethodField()
|
||||||
|
all_entries = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DistributionPlan
|
model = DeliveryPlan
|
||||||
fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
|
fields = [
|
||||||
|
'id',
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'groups',
|
||||||
|
'trips',
|
||||||
|
'unassigned_fields',
|
||||||
|
'available_fertilizers',
|
||||||
|
'all_entries',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
def get_unassigned_fields(self, obj):
|
def get_unassigned_fields(self, obj):
|
||||||
assigned_ids = obj.distributiongroupfield_set.values_list('field_id', flat=True)
|
assigned_ids = DeliveryGroupField.objects.filter(
|
||||||
plan_field_ids = obj.fertilization_plan.entries.values_list('field_id', flat=True).distinct()
|
delivery_plan=obj
|
||||||
from apps.fields.models import Field as F
|
).values_list('field_id', flat=True)
|
||||||
unassigned = F.objects.filter(id__in=plan_field_ids).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
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]
|
return [{'id': f.id, 'name': f.name, 'area_tan': str(f.area_tan)} for f in unassigned]
|
||||||
|
|
||||||
|
def get_available_fertilizers(self, obj):
|
||||||
|
fert_ids = FertilizationEntry.objects.filter(
|
||||||
|
plan__year=obj.year
|
||||||
|
).values_list('fertilizer_id', flat=True).distinct()
|
||||||
|
fertilizers = Fertilizer.objects.filter(id__in=fert_ids).order_by('name')
|
||||||
|
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
||||||
|
|
||||||
class DistributionPlanWriteSerializer(serializers.ModelSerializer):
|
def get_all_entries(self, obj):
|
||||||
fertilization_plan_id = serializers.IntegerField(write_only=True)
|
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)
|
groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
trips = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DistributionPlan
|
model = DeliveryPlan
|
||||||
fields = ['id', 'name', 'fertilization_plan_id', 'groups']
|
fields = ['id', 'year', 'name', 'groups', 'trips']
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
groups_data = validated_data.pop('groups', [])
|
groups_data = validated_data.pop('groups', [])
|
||||||
plan = DistributionPlan.objects.create(**validated_data)
|
trips_data = validated_data.pop('trips', [])
|
||||||
|
plan = DeliveryPlan.objects.create(**validated_data)
|
||||||
self._save_groups(plan, groups_data)
|
self._save_groups(plan, groups_data)
|
||||||
|
self._save_trips(plan, trips_data)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
groups_data = validated_data.pop('groups', None)
|
groups_data = validated_data.pop('groups', None)
|
||||||
|
trips_data = validated_data.pop('trips', None)
|
||||||
instance.name = validated_data.get('name', instance.name)
|
instance.name = validated_data.get('name', instance.name)
|
||||||
|
instance.year = validated_data.get('year', instance.year)
|
||||||
instance.save()
|
instance.save()
|
||||||
if groups_data is not None:
|
if groups_data is not None:
|
||||||
instance.groups.all().delete()
|
instance.groups.all().delete()
|
||||||
self._save_groups(instance, groups_data)
|
self._save_groups(instance, groups_data)
|
||||||
|
if trips_data is not None:
|
||||||
|
instance.trips.all().delete()
|
||||||
|
self._save_trips(instance, trips_data)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def _save_groups(self, plan, groups_data):
|
def _save_groups(self, plan, groups_data):
|
||||||
for g_data in groups_data:
|
for group_data in groups_data:
|
||||||
group = DistributionGroup.objects.create(
|
group = DeliveryGroup.objects.create(
|
||||||
distribution_plan=plan,
|
delivery_plan=plan,
|
||||||
name=g_data['name'],
|
name=group_data['name'],
|
||||||
order=g_data.get('order', 0),
|
order=group_data.get('order', 0),
|
||||||
)
|
)
|
||||||
for field_id in g_data.get('field_ids', []):
|
for field_id in group_data.get('field_ids', []):
|
||||||
DistributionGroupField.objects.create(
|
DeliveryGroupField.objects.create(
|
||||||
distribution_plan=plan,
|
delivery_plan=plan,
|
||||||
group=group,
|
group=group,
|
||||||
field_id=field_id,
|
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
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())
|
||||||
@@ -5,10 +5,12 @@ from . import views
|
|||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||||
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||||
router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
|
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
||||||
|
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
|
||||||
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||||
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||||
|
path('spreading/candidates/', views.SpreadingCandidatesView.as_view(), name='spreading-candidates'),
|
||||||
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from django.db.models import Sum
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
@@ -10,15 +11,31 @@ from rest_framework.views import APIView
|
|||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
|
||||||
from apps.fields.models import Field
|
from apps.fields.models import Field
|
||||||
from apps.plans.models import Plan, Variety
|
from apps.materials.stock_service import (
|
||||||
from .models import Fertilizer, FertilizationPlan, DistributionPlan
|
create_reserves_for_plan,
|
||||||
|
delete_reserves_for_plan,
|
||||||
|
)
|
||||||
|
from apps.plans.models import Plan
|
||||||
|
from .models import (
|
||||||
|
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||||
|
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||||
|
SpreadingSession, SpreadingSessionItem,
|
||||||
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
FertilizerSerializer,
|
FertilizerSerializer,
|
||||||
FertilizationPlanSerializer,
|
FertilizationPlanSerializer,
|
||||||
FertilizationPlanWriteSerializer,
|
FertilizationPlanWriteSerializer,
|
||||||
DistributionPlanListSerializer,
|
DeliveryPlanListSerializer,
|
||||||
DistributionPlanReadSerializer,
|
DeliveryPlanReadSerializer,
|
||||||
DistributionPlanWriteSerializer,
|
DeliveryPlanWriteSerializer,
|
||||||
|
SpreadingSessionSerializer,
|
||||||
|
SpreadingSessionWriteSerializer,
|
||||||
|
)
|
||||||
|
from .services import (
|
||||||
|
FertilizationPlanMergeConflict,
|
||||||
|
FertilizationPlanMergeError,
|
||||||
|
merge_fertilization_plan_into,
|
||||||
|
sync_actual_bags_for_pairs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +50,7 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
||||||
'entries', 'entries__field', 'entries__fertilizer'
|
'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
|
||||||
)
|
)
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
@@ -45,6 +62,18 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
return FertilizationPlanWriteSerializer
|
return FertilizationPlanWriteSerializer
|
||||||
return FertilizationPlanSerializer
|
return FertilizationPlanSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
instance = serializer.save()
|
||||||
|
create_reserves_for_plan(instance)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
instance = serializer.save()
|
||||||
|
create_reserves_for_plan(instance)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
delete_reserves_for_plan(instance)
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def pdf(self, request, pk=None):
|
def pdf(self, request, pk=None):
|
||||||
plan = self.get_object()
|
plan = self.get_object()
|
||||||
@@ -99,6 +128,54 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def merge_targets(self, request, pk=None):
|
||||||
|
source_plan = self.get_object()
|
||||||
|
targets = (
|
||||||
|
FertilizationPlan.objects
|
||||||
|
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
|
||||||
|
.exclude(id=source_plan.id)
|
||||||
|
.prefetch_related('entries')
|
||||||
|
.order_by('-updated_at', 'id')
|
||||||
|
)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': plan.id,
|
||||||
|
'name': plan.name,
|
||||||
|
'field_count': plan.entries.values('field').distinct().count(),
|
||||||
|
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
|
||||||
|
'is_confirmed': plan.is_confirmed,
|
||||||
|
}
|
||||||
|
for plan in targets
|
||||||
|
]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def merge_into(self, request, pk=None):
|
||||||
|
source_plan = self.get_object()
|
||||||
|
target_plan_id = request.data.get('target_plan_id')
|
||||||
|
if not target_plan_id:
|
||||||
|
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
|
||||||
|
except FertilizationPlan.DoesNotExist:
|
||||||
|
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = merge_fertilization_plan_into(source_plan, target_plan)
|
||||||
|
except FertilizationPlanMergeConflict as exc:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error': '競合する圃場・肥料があるためマージできません。',
|
||||||
|
'conflicts': exc.conflicts,
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
except FertilizationPlanMergeError as exc:
|
||||||
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
class CandidateFieldsView(APIView):
|
class CandidateFieldsView(APIView):
|
||||||
"""作付け計画から圃場候補を返す"""
|
"""作付け計画から圃場候補を返す"""
|
||||||
@@ -199,126 +276,373 @@ class CalculateView(APIView):
|
|||||||
return Response(results)
|
return Response(results)
|
||||||
|
|
||||||
|
|
||||||
class DistributionPlanViewSet(viewsets.ModelViewSet):
|
class DeliveryPlanViewSet(viewsets.ModelViewSet):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = DistributionPlan.objects.select_related(
|
qs = DeliveryPlan.objects.prefetch_related(
|
||||||
'fertilization_plan', 'fertilization_plan__variety', 'fertilization_plan__variety__crop'
|
|
||||||
).prefetch_related(
|
|
||||||
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
|
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
|
||||||
'fertilization_plan__entries', 'fertilization_plan__entries__field',
|
'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
|
||||||
'fertilization_plan__entries__fertilizer',
|
|
||||||
'distributiongroupfield_set',
|
|
||||||
)
|
)
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
qs = qs.filter(fertilization_plan__year=year)
|
qs = qs.filter(year=year)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action in ['create', 'update', 'partial_update']:
|
if self.action in ['create', 'update', 'partial_update']:
|
||||||
return DistributionPlanWriteSerializer
|
return DeliveryPlanWriteSerializer
|
||||||
if self.action == 'list':
|
if self.action == 'list':
|
||||||
return DistributionPlanListSerializer
|
return DeliveryPlanListSerializer
|
||||||
return DistributionPlanReadSerializer
|
return DeliveryPlanReadSerializer
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def pdf(self, request, pk=None):
|
def pdf(self, request, pk=None):
|
||||||
dist_plan = self.get_object()
|
plan = self.get_object()
|
||||||
fert_plan = dist_plan.fertilization_plan
|
|
||||||
|
|
||||||
# 施肥計画の肥料一覧(名前順)
|
# 全tripのitemから使用肥料を収集
|
||||||
fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
|
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(
|
fertilizers = sorted(
|
||||||
Fertilizer.objects.filter(id__in=fert_ids),
|
Fertilizer.objects.filter(id__in=fert_ids),
|
||||||
key=lambda f: f.name
|
key=lambda f: f.name
|
||||||
)
|
)
|
||||||
|
|
||||||
# entries を (field_id, fertilizer_id) → bags のマトリクスに変換
|
# グループ情報: field_id → group_name
|
||||||
entry_map = {}
|
field_group_map = {}
|
||||||
for e in fert_plan.entries.all():
|
for gf in DeliveryGroupField.objects.filter(
|
||||||
entry_map[(e.field_id, e.fertilizer_id)] = e.bags
|
delivery_plan=plan
|
||||||
|
).select_related('group', 'field'):
|
||||||
|
field_group_map[gf.field_id] = gf.group
|
||||||
|
|
||||||
# グループ行の構築
|
# 回ごとにページを構築
|
||||||
groups = dist_plan.groups.prefetch_related('field_assignments__field').all()
|
trip_pages = []
|
||||||
group_rows = []
|
for trip in plan.trips.prefetch_related('items__field', 'items__fertilizer').all():
|
||||||
for group in groups:
|
items = trip.items.all()
|
||||||
fields_in_group = [
|
if not items:
|
||||||
a.field for a in group.field_assignments.select_related('field').order_by('field__display_order', 'field__id')
|
continue
|
||||||
|
|
||||||
|
# この回の肥料一覧
|
||||||
|
trip_fert_ids = set(item.fertilizer_id for item in items)
|
||||||
|
trip_fertilizers = [f for f in fertilizers if f.id in trip_fert_ids]
|
||||||
|
|
||||||
|
# items を (field_id, fertilizer_id) → bags のマトリクスに変換
|
||||||
|
item_map = {}
|
||||||
|
for item in items:
|
||||||
|
item_map[(item.field_id, item.fertilizer_id)] = item.bags
|
||||||
|
|
||||||
|
# グループごとにまとめる
|
||||||
|
groups_dict = {} # group_name → {'group': group, 'fields': [field, ...]}
|
||||||
|
ungrouped_fields = []
|
||||||
|
for item in items:
|
||||||
|
group = field_group_map.get(item.field_id)
|
||||||
|
if group:
|
||||||
|
if group.name not in groups_dict:
|
||||||
|
groups_dict[group.name] = {'group': group, 'fields': []}
|
||||||
|
if item.field not in groups_dict[group.name]['fields']:
|
||||||
|
groups_dict[group.name]['fields'].append(item.field)
|
||||||
|
else:
|
||||||
|
if item.field not in ungrouped_fields:
|
||||||
|
ungrouped_fields.append(item.field)
|
||||||
|
|
||||||
|
# グループを order 順にソート
|
||||||
|
sorted_groups = sorted(groups_dict.values(), key=lambda g: (g['group'].order, g['group'].id))
|
||||||
|
|
||||||
|
group_rows = []
|
||||||
|
for g_data in sorted_groups:
|
||||||
|
fields_in_group = sorted(g_data['fields'], key=lambda f: (f.display_order, f.id))
|
||||||
|
group_totals = []
|
||||||
|
for fert in trip_fertilizers:
|
||||||
|
total = sum(
|
||||||
|
item_map.get((f.id, fert.id), Decimal('0'))
|
||||||
|
for f in fields_in_group
|
||||||
|
)
|
||||||
|
group_totals.append(total)
|
||||||
|
|
||||||
|
field_rows = []
|
||||||
|
for field in fields_in_group:
|
||||||
|
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
|
||||||
|
field_rows.append({'field': field, 'cells': cells})
|
||||||
|
|
||||||
|
group_rows.append({
|
||||||
|
'name': g_data['group'].name,
|
||||||
|
'totals': group_totals,
|
||||||
|
'field_rows': field_rows,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 未グループ圃場
|
||||||
|
if ungrouped_fields:
|
||||||
|
ungrouped_fields = sorted(ungrouped_fields, key=lambda f: (f.display_order, f.id))
|
||||||
|
ua_totals = [
|
||||||
|
sum(item_map.get((f.id, fert.id), Decimal('0')) for f in ungrouped_fields)
|
||||||
|
for fert in trip_fertilizers
|
||||||
|
]
|
||||||
|
field_rows = []
|
||||||
|
for field in ungrouped_fields:
|
||||||
|
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
|
||||||
|
field_rows.append({'field': field, 'cells': cells})
|
||||||
|
group_rows.append({
|
||||||
|
'name': '未グループ',
|
||||||
|
'totals': ua_totals,
|
||||||
|
'field_rows': field_rows,
|
||||||
|
})
|
||||||
|
|
||||||
|
fert_totals = [
|
||||||
|
sum(r['totals'][i] for r in group_rows)
|
||||||
|
for i in range(len(trip_fertilizers))
|
||||||
]
|
]
|
||||||
# グループ合計(肥料ごと)
|
|
||||||
group_totals = []
|
|
||||||
for fert in fertilizers:
|
|
||||||
total = sum(
|
|
||||||
entry_map.get((f.id, fert.id), Decimal('0'))
|
|
||||||
for f in fields_in_group
|
|
||||||
)
|
|
||||||
group_totals.append(total)
|
|
||||||
group_row_total = sum(group_totals)
|
|
||||||
|
|
||||||
# 圃場サブ行
|
trip_pages.append({
|
||||||
field_rows = []
|
'trip': trip,
|
||||||
for field in fields_in_group:
|
'fertilizers': trip_fertilizers,
|
||||||
cells = [entry_map.get((field.id, fert.id), '') for fert in fertilizers]
|
'group_rows': group_rows,
|
||||||
row_total = sum(v for v in cells if v != '')
|
'fert_totals': fert_totals,
|
||||||
field_rows.append({'field': field, 'cells': cells, 'total': row_total})
|
|
||||||
|
|
||||||
group_rows.append({
|
|
||||||
'name': group.name,
|
|
||||||
'totals': group_totals,
|
|
||||||
'row_total': group_row_total,
|
|
||||||
'field_rows': field_rows,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# 未割り当て圃場
|
|
||||||
assigned_ids = dist_plan.distributiongroupfield_set.values_list('field_id', flat=True)
|
|
||||||
plan_field_ids = fert_plan.entries.values_list('field_id', flat=True).distinct()
|
|
||||||
unassigned_fields = Field.objects.filter(
|
|
||||||
id__in=plan_field_ids
|
|
||||||
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
|
||||||
|
|
||||||
unassigned_rows = []
|
|
||||||
if unassigned_fields.exists():
|
|
||||||
ua_totals = []
|
|
||||||
for fert in fertilizers:
|
|
||||||
total = sum(
|
|
||||||
entry_map.get((f.id, fert.id), Decimal('0'))
|
|
||||||
for f in unassigned_fields
|
|
||||||
)
|
|
||||||
ua_totals.append(total)
|
|
||||||
unassigned_rows = [{
|
|
||||||
'name': '未割り当て',
|
|
||||||
'totals': ua_totals,
|
|
||||||
'row_total': sum(ua_totals),
|
|
||||||
'field_rows': [
|
|
||||||
{
|
|
||||||
'field': f,
|
|
||||||
'cells': [entry_map.get((f.id, fert.id), '') for fert in fertilizers],
|
|
||||||
'total': sum(entry_map.get((f.id, fert.id), Decimal('0')) for fert in fertilizers),
|
|
||||||
}
|
|
||||||
for f in unassigned_fields
|
|
||||||
],
|
|
||||||
}]
|
|
||||||
|
|
||||||
all_group_rows = group_rows + unassigned_rows
|
|
||||||
fert_totals = [
|
|
||||||
sum(r['totals'][i] for r in all_group_rows)
|
|
||||||
for i in range(len(fertilizers))
|
|
||||||
]
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'dist_plan': dist_plan,
|
'plan': plan,
|
||||||
'fert_plan': fert_plan,
|
'trip_pages': trip_pages,
|
||||||
'fertilizers': fertilizers,
|
|
||||||
'group_rows': all_group_rows,
|
|
||||||
'fert_totals': fert_totals,
|
|
||||||
'grand_total': sum(fert_totals),
|
|
||||||
}
|
}
|
||||||
html_string = render_to_string('fertilizer/distribution_pdf.html', context)
|
html_string = render_to_string('fertilizer/delivery_pdf.html', context)
|
||||||
pdf_file = HTML(string=html_string).write_pdf()
|
pdf_file = HTML(string=html_string).write_pdf()
|
||||||
response = HttpResponse(pdf_file, content_type='application/pdf')
|
response = HttpResponse(pdf_file, content_type='application/pdf')
|
||||||
response['Content-Disposition'] = (
|
response['Content-Disposition'] = (
|
||||||
f'attachment; filename="distribution_{fert_plan.year}_{dist_plan.id}.pdf"'
|
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
|
||||||
)
|
)
|
||||||
return response
|
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)
|
||||||
|
|
||||||
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Generated by Django 5.2 on 2026-04-04 00:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fields', '0006_e1c_chusankan_17_fields'),
|
||||||
|
('plans', '0004_crop_base_temp'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='crop',
|
||||||
|
name='seed_inventory_kg',
|
||||||
|
field=models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='種もみ在庫(kg)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='variety',
|
||||||
|
name='default_seedling_boxes_per_tan',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数デフォルト'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RiceTransplantPlan',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200, verbose_name='計画名')),
|
||||||
|
('year', models.IntegerField(verbose_name='年度')),
|
||||||
|
('default_seed_grams_per_box', models.DecimalField(decimal_places=2, default=0, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)デフォルト')),
|
||||||
|
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rice_transplant_plans', to='plans.variety', verbose_name='品種')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '田植え計画',
|
||||||
|
'verbose_name_plural': '田植え計画',
|
||||||
|
'ordering': ['-year', 'variety'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RiceTransplantEntry',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('seedling_boxes_per_tan', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='反当苗箱枚数')),
|
||||||
|
('seed_grams_per_box', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)')),
|
||||||
|
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rice_transplant_entries', to='fields.field', verbose_name='圃場')),
|
||||||
|
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='plans.ricetransplantplan', verbose_name='田植え計画')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '田植え計画エントリ',
|
||||||
|
'verbose_name_plural': '田植え計画エントリ',
|
||||||
|
'ordering': ['field__display_order', 'field__id'],
|
||||||
|
'unique_together': {('plan', 'field')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('plans', '0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='ricetransplantentry',
|
||||||
|
old_name='seedling_boxes_per_tan',
|
||||||
|
new_name='installed_seedling_boxes',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('plans', '0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ricetransplantplan',
|
||||||
|
name='seedling_boxes_per_tan',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数'),
|
||||||
|
),
|
||||||
|
]
|
||||||
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('materials', '0005_material_seed_type'),
|
||||||
|
('plans', '0007_ricetransplantplan_seedling_boxes_per_tan'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='variety',
|
||||||
|
name='seed_material',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
limit_choices_to={'material_type': 'seed'},
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='varieties',
|
||||||
|
to='materials.material',
|
||||||
|
verbose_name='種子在庫資材',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('plans', '0008_variety_seed_material'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ricetransplantentry',
|
||||||
|
name='installed_seedling_boxes',
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2,
|
||||||
|
max_digits=8,
|
||||||
|
verbose_name='設置苗箱枚数',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fields', '0006_e1c_chusankan_17_fields'),
|
||||||
|
('plans', '0009_alter_ricetransplantentry_installed_seedling_boxes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PlanVarietyChange',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('year', models.IntegerField(verbose_name='作付年度')),
|
||||||
|
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='変更日時')),
|
||||||
|
('reason', models.TextField(blank=True, default='', verbose_name='変更理由')),
|
||||||
|
('fertilizer_moved_entry_count', models.IntegerField(default=0, verbose_name='施肥移動エントリ数')),
|
||||||
|
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='plan_variety_changes', to='fields.field', verbose_name='圃場')),
|
||||||
|
('new_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='new_plan_variety_changes', to='plans.variety', verbose_name='変更後品種')),
|
||||||
|
('old_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='old_plan_variety_changes', to='plans.variety', verbose_name='変更前品種')),
|
||||||
|
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variety_changes', to='plans.plan', verbose_name='作付け計画')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '作付け計画品種変更履歴',
|
||||||
|
'verbose_name_plural': '作付け計画品種変更履歴',
|
||||||
|
'ordering': ['-changed_at', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,6 +5,12 @@ from apps.fields.models import Field
|
|||||||
class Crop(models.Model):
|
class Crop(models.Model):
|
||||||
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
||||||
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
|
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
|
||||||
|
seed_inventory_kg = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=3,
|
||||||
|
default=0,
|
||||||
|
verbose_name="種もみ在庫(kg)",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "作物マスタ"
|
verbose_name = "作物マスタ"
|
||||||
@@ -17,6 +23,21 @@ class Crop(models.Model):
|
|||||||
class Variety(models.Model):
|
class Variety(models.Model):
|
||||||
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
||||||
name = models.CharField(max_length=100, verbose_name="品種名")
|
name = models.CharField(max_length=100, verbose_name="品種名")
|
||||||
|
default_seedling_boxes_per_tan = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="反当苗箱枚数デフォルト",
|
||||||
|
)
|
||||||
|
seed_material = models.ForeignKey(
|
||||||
|
'materials.Material',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='varieties',
|
||||||
|
verbose_name='種子在庫資材',
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
limit_choices_to={'material_type': 'seed'},
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "品種マスタ"
|
verbose_name = "品種マスタ"
|
||||||
@@ -42,3 +63,116 @@ class Plan(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.field.name} - {self.year} - {self.crop.name}"
|
return f"{self.field.name} - {self.year} - {self.crop.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanVarietyChange(models.Model):
|
||||||
|
field = models.ForeignKey(
|
||||||
|
Field,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='plan_variety_changes',
|
||||||
|
verbose_name='圃場',
|
||||||
|
)
|
||||||
|
year = models.IntegerField(verbose_name='作付年度')
|
||||||
|
plan = models.ForeignKey(
|
||||||
|
Plan,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='variety_changes',
|
||||||
|
verbose_name='作付け計画',
|
||||||
|
)
|
||||||
|
changed_at = models.DateTimeField(auto_now_add=True, verbose_name='変更日時')
|
||||||
|
old_variety = models.ForeignKey(
|
||||||
|
Variety,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='old_plan_variety_changes',
|
||||||
|
verbose_name='変更前品種',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
new_variety = models.ForeignKey(
|
||||||
|
Variety,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='new_plan_variety_changes',
|
||||||
|
verbose_name='変更後品種',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
reason = models.TextField(blank=True, default='', verbose_name='変更理由')
|
||||||
|
fertilizer_moved_entry_count = models.IntegerField(default=0, verbose_name='施肥移動エントリ数')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '作付け計画品種変更履歴'
|
||||||
|
verbose_name_plural = '作付け計画品種変更履歴'
|
||||||
|
ordering = ['-changed_at', '-id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
old_name = self.old_variety.name if self.old_variety else '未設定'
|
||||||
|
new_name = self.new_variety.name if self.new_variety else '未設定'
|
||||||
|
return f'{self.field.name} {self.year}: {old_name} -> {new_name}'
|
||||||
|
|
||||||
|
|
||||||
|
class RiceTransplantPlan(models.Model):
|
||||||
|
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||||
|
year = models.IntegerField(verbose_name='年度')
|
||||||
|
variety = models.ForeignKey(
|
||||||
|
Variety,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='rice_transplant_plans',
|
||||||
|
verbose_name='品種',
|
||||||
|
)
|
||||||
|
default_seed_grams_per_box = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
|
||||||
|
)
|
||||||
|
seedling_boxes_per_tan = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name='反当苗箱枚数',
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '田植え計画'
|
||||||
|
verbose_name_plural = '田植え計画'
|
||||||
|
ordering = ['-year', 'variety']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.year} {self.name}'
|
||||||
|
|
||||||
|
|
||||||
|
class RiceTransplantEntry(models.Model):
|
||||||
|
plan = models.ForeignKey(
|
||||||
|
RiceTransplantPlan,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='entries',
|
||||||
|
verbose_name='田植え計画',
|
||||||
|
)
|
||||||
|
field = models.ForeignKey(
|
||||||
|
Field,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='rice_transplant_entries',
|
||||||
|
verbose_name='圃場',
|
||||||
|
)
|
||||||
|
installed_seedling_boxes = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=2,
|
||||||
|
verbose_name='設置苗箱枚数',
|
||||||
|
)
|
||||||
|
seed_grams_per_box = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=2,
|
||||||
|
verbose_name='苗箱1枚あたり種もみ(g)',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '田植え計画エントリ'
|
||||||
|
verbose_name_plural = '田植え計画エントリ'
|
||||||
|
unique_together = [['plan', 'field']]
|
||||||
|
ordering = ['field__display_order', 'field__id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.plan} / {self.field} / {self.installed_seedling_boxes}枚'
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from apps.fields.models import Field
|
||||||
|
from apps.materials.models import StockTransaction
|
||||||
from .models import Crop, Variety, Plan
|
from .models import Crop, Variety, Plan
|
||||||
|
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||||
|
from .services import NO_CHANGE, update_plan_with_variety_tracking
|
||||||
|
|
||||||
|
|
||||||
class VarietySerializer(serializers.ModelSerializer):
|
class VarietySerializer(serializers.ModelSerializer):
|
||||||
|
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Variety
|
model = Variety
|
||||||
fields = '__all__'
|
fields = [
|
||||||
|
'id',
|
||||||
|
'crop',
|
||||||
|
'name',
|
||||||
|
'default_seedling_boxes_per_tan',
|
||||||
|
'seed_material',
|
||||||
|
'seed_material_name',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CropSerializer(serializers.ModelSerializer):
|
class CropSerializer(serializers.ModelSerializer):
|
||||||
@@ -20,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||||
field_name = serializers.ReadOnlyField(source='field.name')
|
field_name = serializers.ReadOnlyField(source='field.name')
|
||||||
|
variety_change_count = serializers.SerializerMethodField()
|
||||||
|
latest_variety_change = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
@@ -30,7 +47,215 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
return Plan.objects.create(**validated_data)
|
return Plan.objects.create(**validated_data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
return update_plan_with_variety_tracking(
|
||||||
|
instance,
|
||||||
|
crop=validated_data.get('crop', NO_CHANGE),
|
||||||
|
variety=validated_data.get('variety', NO_CHANGE),
|
||||||
|
notes=validated_data.get('notes', NO_CHANGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_variety_change_count(self, obj):
|
||||||
|
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||||
|
changes = prefetched.get('variety_changes')
|
||||||
|
if changes is not None:
|
||||||
|
return len(changes)
|
||||||
|
return obj.variety_changes.count()
|
||||||
|
|
||||||
|
def get_latest_variety_change(self, obj):
|
||||||
|
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||||
|
changes = prefetched.get('variety_changes')
|
||||||
|
if changes is not None:
|
||||||
|
latest = changes[0] if changes else None
|
||||||
|
else:
|
||||||
|
latest = obj.variety_changes.select_related('old_variety', 'new_variety').first()
|
||||||
|
if latest is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id': latest.id,
|
||||||
|
'changed_at': latest.changed_at,
|
||||||
|
'old_variety_id': latest.old_variety_id,
|
||||||
|
'old_variety_name': latest.old_variety.name if latest.old_variety else None,
|
||||||
|
'new_variety_id': latest.new_variety_id,
|
||||||
|
'new_variety_name': latest.new_variety.name if latest.new_variety else None,
|
||||||
|
'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||||
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
|
field_area_tan = serializers.DecimalField(
|
||||||
|
source='field.area_tan',
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=4,
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
planned_boxes = serializers.SerializerMethodField()
|
||||||
|
default_seedling_boxes = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RiceTransplantEntry
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'field',
|
||||||
|
'field_name',
|
||||||
|
'field_area_tan',
|
||||||
|
'installed_seedling_boxes',
|
||||||
|
'default_seedling_boxes',
|
||||||
|
'planned_boxes',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_default_seedling_boxes(self, obj):
|
||||||
|
area = Decimal(str(obj.field.area_tan))
|
||||||
|
default_boxes_per_tan = obj.plan.seedling_boxes_per_tan
|
||||||
|
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
|
||||||
|
|
||||||
|
def get_planned_boxes(self, obj):
|
||||||
|
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
|
||||||
|
|
||||||
|
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
|
||||||
|
variety_name = serializers.CharField(source='variety.name', read_only=True)
|
||||||
|
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
|
||||||
|
seed_material_name = serializers.CharField(source='variety.seed_material.name', read_only=True)
|
||||||
|
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
|
||||||
|
field_count = serializers.SerializerMethodField()
|
||||||
|
total_seedling_boxes = serializers.SerializerMethodField()
|
||||||
|
total_seed_kg = serializers.SerializerMethodField()
|
||||||
|
variety_seed_inventory_kg = serializers.SerializerMethodField()
|
||||||
|
remaining_seed_kg = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RiceTransplantPlan
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'year',
|
||||||
|
'variety',
|
||||||
|
'variety_name',
|
||||||
|
'crop_name',
|
||||||
|
'default_seed_grams_per_box',
|
||||||
|
'seedling_boxes_per_tan',
|
||||||
|
'notes',
|
||||||
|
'seed_material_name',
|
||||||
|
'entries',
|
||||||
|
'field_count',
|
||||||
|
'total_seedling_boxes',
|
||||||
|
'total_seed_kg',
|
||||||
|
'variety_seed_inventory_kg',
|
||||||
|
'remaining_seed_kg',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_field_count(self, obj):
|
||||||
|
return obj.entries.count()
|
||||||
|
|
||||||
|
def get_total_seedling_boxes(self, obj):
|
||||||
|
total = sum(
|
||||||
|
(
|
||||||
|
entry.installed_seedling_boxes
|
||||||
|
for entry in obj.entries.all()
|
||||||
|
),
|
||||||
|
Decimal('0'),
|
||||||
|
)
|
||||||
|
return str(total.quantize(Decimal('0.01')))
|
||||||
|
|
||||||
|
def get_total_seed_kg(self, obj):
|
||||||
|
total = sum(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
entry.installed_seedling_boxes
|
||||||
|
* obj.default_seed_grams_per_box
|
||||||
|
/ Decimal('1000')
|
||||||
|
)
|
||||||
|
for entry in obj.entries.all()
|
||||||
|
),
|
||||||
|
Decimal('0'),
|
||||||
|
)
|
||||||
|
return str(total.quantize(Decimal('0.001')))
|
||||||
|
|
||||||
|
def get_variety_seed_inventory_kg(self, obj):
|
||||||
|
return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001')))
|
||||||
|
|
||||||
|
def get_remaining_seed_kg(self, obj):
|
||||||
|
total_seed = Decimal(self.get_total_seed_kg(obj))
|
||||||
|
return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001')))
|
||||||
|
|
||||||
|
def _get_seed_inventory_kg(self, obj):
|
||||||
|
material = obj.variety.seed_material
|
||||||
|
if material is None:
|
||||||
|
return Decimal('0')
|
||||||
|
|
||||||
|
transactions = list(material.stock_transactions.all())
|
||||||
|
increase = sum(
|
||||||
|
(
|
||||||
|
txn.quantity
|
||||||
|
for txn in transactions
|
||||||
|
if txn.transaction_type in StockTransaction.INCREASE_TYPES
|
||||||
|
),
|
||||||
|
Decimal('0'),
|
||||||
|
)
|
||||||
|
decrease = sum(
|
||||||
|
(
|
||||||
|
txn.quantity
|
||||||
|
for txn in transactions
|
||||||
|
if txn.transaction_type in StockTransaction.DECREASE_TYPES
|
||||||
|
),
|
||||||
|
Decimal('0'),
|
||||||
|
)
|
||||||
|
return increase - decrease
|
||||||
|
|
||||||
|
|
||||||
|
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
|
||||||
|
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RiceTransplantPlan
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'year',
|
||||||
|
'variety',
|
||||||
|
'default_seed_grams_per_box',
|
||||||
|
'seedling_boxes_per_tan',
|
||||||
|
'notes',
|
||||||
|
'entries',
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
entries_data = validated_data.pop('entries', [])
|
||||||
|
plan = RiceTransplantPlan.objects.create(**validated_data)
|
||||||
|
self._save_entries(plan, entries_data)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
entries_data = validated_data.pop('entries', None)
|
||||||
for attr, value in validated_data.items():
|
for attr, value in validated_data.items():
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
instance.save()
|
instance.save()
|
||||||
|
if entries_data is not None:
|
||||||
|
instance.entries.all().delete()
|
||||||
|
self._save_entries(instance, entries_data)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
entries_data = attrs.get('entries')
|
||||||
|
if entries_data is None:
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
|
||||||
|
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
|
||||||
|
missing_ids = sorted(set(field_ids) - existing_ids)
|
||||||
|
if missing_ids:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
|
||||||
|
})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def _save_entries(self, plan, entries_data):
|
||||||
|
for entry in entries_data:
|
||||||
|
RiceTransplantEntry.objects.create(
|
||||||
|
plan=plan,
|
||||||
|
field_id=entry['field_id'],
|
||||||
|
installed_seedling_boxes=entry['installed_seedling_boxes'],
|
||||||
|
seed_grams_per_box=plan.default_seed_grams_per_box,
|
||||||
|
)
|
||||||
|
|||||||
74
backend/apps/plans/services.py
Normal file
74
backend/apps/plans/services.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import Plan, PlanVarietyChange
|
||||||
|
|
||||||
|
|
||||||
|
class _NoChange:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
NO_CHANGE = _NoChange()
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_plan_with_variety_tracking(
|
||||||
|
plan: Plan,
|
||||||
|
*,
|
||||||
|
crop=NO_CHANGE,
|
||||||
|
variety=NO_CHANGE,
|
||||||
|
notes=NO_CHANGE,
|
||||||
|
reason: str = '',
|
||||||
|
):
|
||||||
|
old_variety = plan.variety
|
||||||
|
updated_fields = []
|
||||||
|
|
||||||
|
if crop is not NO_CHANGE:
|
||||||
|
plan.crop = crop
|
||||||
|
updated_fields.append('crop')
|
||||||
|
if variety is not NO_CHANGE:
|
||||||
|
plan.variety = variety
|
||||||
|
updated_fields.append('variety')
|
||||||
|
if notes is not NO_CHANGE:
|
||||||
|
plan.notes = notes
|
||||||
|
updated_fields.append('notes')
|
||||||
|
|
||||||
|
if updated_fields:
|
||||||
|
plan.save(update_fields=updated_fields)
|
||||||
|
|
||||||
|
if variety is not NO_CHANGE and _get_variety_id(old_variety) != _get_variety_id(plan.variety):
|
||||||
|
handle_plan_variety_change(plan, old_variety=old_variety, new_variety=plan.variety, reason=reason)
|
||||||
|
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: str = ''):
|
||||||
|
if _get_variety_id(old_variety) == _get_variety_id(new_variety):
|
||||||
|
return None
|
||||||
|
|
||||||
|
change = PlanVarietyChange.objects.create(
|
||||||
|
field=plan.field,
|
||||||
|
year=plan.year,
|
||||||
|
plan=plan,
|
||||||
|
old_variety=old_variety,
|
||||||
|
new_variety=new_variety,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
process_plan_variety_change(change)
|
||||||
|
return change
|
||||||
|
|
||||||
|
|
||||||
|
def process_plan_variety_change(change: PlanVarietyChange):
|
||||||
|
from apps.fertilizer.services import move_fertilization_entries_for_variety_change
|
||||||
|
from .services_rice_transplant import move_rice_transplant_entries_for_variety_change
|
||||||
|
|
||||||
|
moved_count = move_fertilization_entries_for_variety_change(change)
|
||||||
|
move_rice_transplant_entries_for_variety_change(change)
|
||||||
|
if moved_count != change.fertilizer_moved_entry_count:
|
||||||
|
change.fertilizer_moved_entry_count = moved_count
|
||||||
|
change.save(update_fields=['fertilizer_moved_entry_count'])
|
||||||
|
return change
|
||||||
|
|
||||||
|
|
||||||
|
def _get_variety_id(variety):
|
||||||
|
return getattr(variety, 'id', None)
|
||||||
46
backend/apps/plans/services_rice_transplant.py
Normal file
46
backend/apps/plans/services_rice_transplant.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def move_rice_transplant_entries_for_variety_change(change):
|
||||||
|
old_variety_id = change.old_variety_id
|
||||||
|
new_variety = change.new_variety
|
||||||
|
if old_variety_id is None or new_variety is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
old_plans = (
|
||||||
|
RiceTransplantPlan.objects
|
||||||
|
.filter(
|
||||||
|
year=change.year,
|
||||||
|
variety_id=old_variety_id,
|
||||||
|
entries__field_id=change.field_id,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.prefetch_related('entries')
|
||||||
|
)
|
||||||
|
|
||||||
|
moved_count = 0
|
||||||
|
for old_plan in old_plans:
|
||||||
|
entries_to_move = list(
|
||||||
|
old_plan.entries.filter(field_id=change.field_id).order_by('id')
|
||||||
|
)
|
||||||
|
if not entries_to_move:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_plan = RiceTransplantPlan.objects.create(
|
||||||
|
name=f'{change.year}年度 {new_variety.name} 田植え計画(品種変更移動)',
|
||||||
|
year=change.year,
|
||||||
|
variety=new_variety,
|
||||||
|
default_seed_grams_per_box=old_plan.default_seed_grams_per_box,
|
||||||
|
seedling_boxes_per_tan=old_plan.seedling_boxes_per_tan,
|
||||||
|
notes=old_plan.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
RiceTransplantEntry.objects.filter(
|
||||||
|
id__in=[entry.id for entry in entries_to_move]
|
||||||
|
).update(plan=new_plan)
|
||||||
|
moved_count += len(entries_to_move)
|
||||||
|
|
||||||
|
return moved_count
|
||||||
@@ -1,3 +1,263 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
# Create your tests here.
|
from apps.fertilizer.models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||||
|
from apps.fields.models import Field
|
||||||
|
from apps.materials.models import Material, StockTransaction
|
||||||
|
from apps.materials.stock_service import create_reserves_for_plan
|
||||||
|
from .models import (
|
||||||
|
Crop,
|
||||||
|
Plan,
|
||||||
|
PlanVarietyChange,
|
||||||
|
RiceTransplantEntry,
|
||||||
|
RiceTransplantPlan,
|
||||||
|
Variety,
|
||||||
|
)
|
||||||
|
from .serializers import PlanSerializer
|
||||||
|
from .views import PlanViewSet
|
||||||
|
|
||||||
|
|
||||||
|
class PlanVarietyChangeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username='tester',
|
||||||
|
password='secret12345',
|
||||||
|
)
|
||||||
|
self.crop = Crop.objects.create(name='水稲')
|
||||||
|
self.old_variety = Variety.objects.create(crop=self.crop, name='にこまる')
|
||||||
|
self.new_variety = Variety.objects.create(crop=self.crop, name='たちはるか特栽')
|
||||||
|
self.field = Field.objects.create(
|
||||||
|
name='足川北上',
|
||||||
|
address='高知県高岡郡',
|
||||||
|
area_tan='1.2000',
|
||||||
|
area_m2=1200,
|
||||||
|
owner_name='吉田',
|
||||||
|
group_name='北',
|
||||||
|
display_order=1,
|
||||||
|
)
|
||||||
|
self.plan = Plan.objects.create(
|
||||||
|
field=self.field,
|
||||||
|
year=2026,
|
||||||
|
crop=self.crop,
|
||||||
|
variety=self.old_variety,
|
||||||
|
notes='',
|
||||||
|
)
|
||||||
|
self.other_field = Field.objects.create(
|
||||||
|
name='足川南',
|
||||||
|
address='高知県高岡郡',
|
||||||
|
area_tan='0.8000',
|
||||||
|
area_m2=800,
|
||||||
|
owner_name='吉田',
|
||||||
|
group_name='南',
|
||||||
|
display_order=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_serializer_update_creates_history_when_variety_changes(self):
|
||||||
|
serializer = PlanSerializer(
|
||||||
|
instance=self.plan,
|
||||||
|
data={'variety': self.new_variety.id},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||||
|
|
||||||
|
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||||
|
self.assertEqual(change.field_id, self.field.id)
|
||||||
|
self.assertEqual(change.year, 2026)
|
||||||
|
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||||
|
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||||
|
self.assertEqual(change.fertilizer_moved_entry_count, 0)
|
||||||
|
|
||||||
|
def test_serializer_update_does_not_create_history_without_variety_change(self):
|
||||||
|
serializer = PlanSerializer(
|
||||||
|
instance=self.plan,
|
||||||
|
data={'notes': 'メモ更新'},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertEqual(self.plan.notes, 'メモ更新')
|
||||||
|
self.assertFalse(PlanVarietyChange.objects.exists())
|
||||||
|
|
||||||
|
def test_bulk_update_creates_history_for_existing_plan(self):
|
||||||
|
view = PlanViewSet.as_view({'post': 'bulk_update'})
|
||||||
|
request = self.factory.post(
|
||||||
|
'/api/plans/bulk_update/',
|
||||||
|
{
|
||||||
|
'field_ids': [self.field.id],
|
||||||
|
'year': 2026,
|
||||||
|
'crop': self.crop.id,
|
||||||
|
'variety': self.new_variety.id,
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = view(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||||
|
|
||||||
|
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||||
|
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||||
|
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||||
|
|
||||||
|
def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self):
|
||||||
|
material_target = Material.objects.create(
|
||||||
|
name='高度化成14号',
|
||||||
|
material_type=Material.MaterialType.FERTILIZER,
|
||||||
|
)
|
||||||
|
material_spread = Material.objects.create(
|
||||||
|
name='分げつ一発',
|
||||||
|
material_type=Material.MaterialType.FERTILIZER,
|
||||||
|
)
|
||||||
|
fertilizer_target = Fertilizer.objects.create(
|
||||||
|
name='高度化成14号',
|
||||||
|
material=material_target,
|
||||||
|
)
|
||||||
|
fertilizer_spread = Fertilizer.objects.create(
|
||||||
|
name='分げつ一発',
|
||||||
|
material=material_spread,
|
||||||
|
)
|
||||||
|
old_fertilization_plan = FertilizationPlan.objects.create(
|
||||||
|
name='2026年度 にこまる 元肥',
|
||||||
|
year=2026,
|
||||||
|
variety=self.old_variety,
|
||||||
|
calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}],
|
||||||
|
)
|
||||||
|
target_entry = FertilizationEntry.objects.create(
|
||||||
|
plan=old_fertilization_plan,
|
||||||
|
field=self.field,
|
||||||
|
fertilizer=fertilizer_target,
|
||||||
|
bags='4.00',
|
||||||
|
actual_bags=None,
|
||||||
|
)
|
||||||
|
spread_entry = FertilizationEntry.objects.create(
|
||||||
|
plan=old_fertilization_plan,
|
||||||
|
field=self.field,
|
||||||
|
fertilizer=fertilizer_spread,
|
||||||
|
bags='3.00',
|
||||||
|
actual_bags='1.0000',
|
||||||
|
)
|
||||||
|
untouched_entry = FertilizationEntry.objects.create(
|
||||||
|
plan=old_fertilization_plan,
|
||||||
|
field=self.other_field,
|
||||||
|
fertilizer=fertilizer_target,
|
||||||
|
bags='2.00',
|
||||||
|
actual_bags=None,
|
||||||
|
)
|
||||||
|
create_reserves_for_plan(old_fertilization_plan)
|
||||||
|
|
||||||
|
serializer = PlanSerializer(
|
||||||
|
instance=self.plan,
|
||||||
|
data={'variety': self.new_variety.id},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||||
|
self.assertEqual(change.fertilizer_moved_entry_count, 2)
|
||||||
|
|
||||||
|
old_fertilization_plan.refresh_from_db()
|
||||||
|
new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get(
|
||||||
|
year=2026,
|
||||||
|
variety=self.new_variety,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
new_plan.name,
|
||||||
|
f'2026年度 {self.new_variety.name} 施肥計画(品種変更移動)',
|
||||||
|
)
|
||||||
|
self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings)
|
||||||
|
|
||||||
|
target_entry.refresh_from_db()
|
||||||
|
spread_entry.refresh_from_db()
|
||||||
|
untouched_entry.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(target_entry.plan_id, new_plan.id)
|
||||||
|
self.assertEqual(spread_entry.plan_id, new_plan.id)
|
||||||
|
self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id)
|
||||||
|
|
||||||
|
old_reserves = list(
|
||||||
|
StockTransaction.objects.filter(
|
||||||
|
fertilization_plan=old_fertilization_plan,
|
||||||
|
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||||
|
).order_by('material__name')
|
||||||
|
)
|
||||||
|
new_reserves = list(
|
||||||
|
StockTransaction.objects.filter(
|
||||||
|
fertilization_plan=new_plan,
|
||||||
|
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||||
|
).order_by('material__name')
|
||||||
|
)
|
||||||
|
self.assertEqual(len(old_reserves), 1)
|
||||||
|
self.assertEqual(len(new_reserves), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{(reserve.material_id, reserve.quantity) for reserve in old_reserves},
|
||||||
|
{
|
||||||
|
(material_target.id, untouched_entry.bags),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{(reserve.material_id, reserve.quantity) for reserve in new_reserves},
|
||||||
|
{
|
||||||
|
(material_target.id, target_entry.bags),
|
||||||
|
(material_spread.id, spread_entry.bags),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_serializer_update_moves_rice_transplant_entries_for_target_field(self):
|
||||||
|
old_rice_plan = RiceTransplantPlan.objects.create(
|
||||||
|
name='2026年度 にこまる 田植え計画',
|
||||||
|
year=2026,
|
||||||
|
variety=self.old_variety,
|
||||||
|
default_seed_grams_per_box='200.00',
|
||||||
|
seedling_boxes_per_tan='12.00',
|
||||||
|
notes='旧計画メモ',
|
||||||
|
)
|
||||||
|
target_entry = RiceTransplantEntry.objects.create(
|
||||||
|
plan=old_rice_plan,
|
||||||
|
field=self.field,
|
||||||
|
installed_seedling_boxes='14.40',
|
||||||
|
seed_grams_per_box='200.00',
|
||||||
|
)
|
||||||
|
other_entry = RiceTransplantEntry.objects.create(
|
||||||
|
plan=old_rice_plan,
|
||||||
|
field=self.other_field,
|
||||||
|
installed_seedling_boxes='9.60',
|
||||||
|
seed_grams_per_box='200.00',
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = PlanSerializer(
|
||||||
|
instance=self.plan,
|
||||||
|
data={'variety': self.new_variety.id},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
target_entry.refresh_from_db()
|
||||||
|
other_entry.refresh_from_db()
|
||||||
|
|
||||||
|
new_rice_plan = RiceTransplantPlan.objects.exclude(id=old_rice_plan.id).get(
|
||||||
|
year=2026,
|
||||||
|
variety=self.new_variety,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
new_rice_plan.name,
|
||||||
|
f'2026年度 {self.new_variety.name} 田植え計画(品種変更移動)',
|
||||||
|
)
|
||||||
|
self.assertEqual(new_rice_plan.default_seed_grams_per_box, Decimal('200.00'))
|
||||||
|
self.assertEqual(new_rice_plan.seedling_boxes_per_tan, Decimal('12.00'))
|
||||||
|
self.assertEqual(new_rice_plan.notes, old_rice_plan.notes)
|
||||||
|
self.assertEqual(target_entry.plan_id, new_rice_plan.id)
|
||||||
|
self.assertEqual(other_entry.plan_id, old_rice_plan.id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from . import views
|
|||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'crops', views.CropViewSet)
|
router.register(r'crops', views.CropViewSet)
|
||||||
router.register(r'varieties', views.VarietyViewSet)
|
router.register(r'varieties', views.VarietyViewSet)
|
||||||
|
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
|
||||||
router.register(r'', views.PlanViewSet)
|
router.register(r'', views.PlanViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ from rest_framework import viewsets, status
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from .models import Crop, Variety, Plan
|
from .models import Crop, Variety, Plan, RiceTransplantPlan
|
||||||
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
|
from .serializers import (
|
||||||
|
CropSerializer,
|
||||||
|
VarietySerializer,
|
||||||
|
PlanSerializer,
|
||||||
|
RiceTransplantPlanSerializer,
|
||||||
|
RiceTransplantPlanWriteSerializer,
|
||||||
|
)
|
||||||
|
from .services import update_plan_with_variety_tracking
|
||||||
from apps.fields.models import Field
|
from apps.fields.models import Field
|
||||||
|
|
||||||
|
|
||||||
@@ -13,16 +20,20 @@ class CropViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class VarietyViewSet(viewsets.ModelViewSet):
|
class VarietyViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Variety.objects.all()
|
queryset = Variety.objects.select_related('seed_material', 'crop').all()
|
||||||
serializer_class = VarietySerializer
|
serializer_class = VarietySerializer
|
||||||
|
|
||||||
|
|
||||||
class PlanViewSet(viewsets.ModelViewSet):
|
class PlanViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Plan.objects.all()
|
queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related(
|
||||||
|
'variety_changes',
|
||||||
|
'variety_changes__old_variety',
|
||||||
|
'variety_changes__new_variety',
|
||||||
|
)
|
||||||
serializer_class = PlanSerializer
|
serializer_class = PlanSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Plan.objects.all()
|
queryset = self.queryset
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=year)
|
queryset = queryset.filter(year=year)
|
||||||
@@ -114,19 +125,78 @@ class PlanViewSet(viewsets.ModelViewSet):
|
|||||||
updated = 0
|
updated = 0
|
||||||
created = 0
|
created = 0
|
||||||
for field_id in field_ids:
|
for field_id in field_ids:
|
||||||
plan, was_created = Plan.objects.update_or_create(
|
plan = Plan.objects.filter(field_id=field_id, year=year).first()
|
||||||
field_id=field_id,
|
if plan is None:
|
||||||
year=year,
|
Plan.objects.create(
|
||||||
defaults={'crop': crop, 'variety': variety}
|
field_id=field_id,
|
||||||
)
|
year=year,
|
||||||
if was_created:
|
crop=crop,
|
||||||
|
variety=variety,
|
||||||
|
)
|
||||||
created += 1
|
created += 1
|
||||||
else:
|
continue
|
||||||
updated += 1
|
|
||||||
|
update_plan_with_variety_tracking(
|
||||||
|
plan,
|
||||||
|
crop=crop,
|
||||||
|
variety=variety,
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def get_crops_with_varieties(self, request):
|
def get_crops_with_varieties(self, request):
|
||||||
crops = Crop.objects.prefetch_related('varieties').all()
|
crops = Crop.objects.prefetch_related('varieties__seed_material').all()
|
||||||
return Response(CropSerializer(crops, many=True).data)
|
return Response(CropSerializer(crops, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = RiceTransplantPlan.objects.select_related(
|
||||||
|
'variety',
|
||||||
|
'variety__crop',
|
||||||
|
'variety__seed_material',
|
||||||
|
).prefetch_related(
|
||||||
|
'variety__seed_material__stock_transactions',
|
||||||
|
'entries',
|
||||||
|
'entries__field',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = self.queryset
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
if year:
|
||||||
|
queryset = queryset.filter(year=year)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ['create', 'update', 'partial_update']:
|
||||||
|
return RiceTransplantPlanWriteSerializer
|
||||||
|
return RiceTransplantPlanSerializer
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def candidate_fields(self, request):
|
||||||
|
year = request.query_params.get('year')
|
||||||
|
variety_id = request.query_params.get('variety_id')
|
||||||
|
if not year or not variety_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'year と variety_id が必要です'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
field_ids = Plan.objects.filter(
|
||||||
|
year=year,
|
||||||
|
variety_id=variety_id,
|
||||||
|
).values_list('field_id', flat=True)
|
||||||
|
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': field.id,
|
||||||
|
'name': field.name,
|
||||||
|
'area_tan': str(field.area_tan),
|
||||||
|
'area_m2': field.area_m2,
|
||||||
|
'group_name': field.group_name,
|
||||||
|
}
|
||||||
|
for field in fields
|
||||||
|
]
|
||||||
|
return Response(data)
|
||||||
|
|||||||
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 @@
|
|||||||
|
|
||||||
52
backend/apps/workrecords/models.py
Normal file
52
backend/apps/workrecords/models.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class WorkRecord(models.Model):
|
||||||
|
class WorkType(models.TextChoices):
|
||||||
|
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
||||||
|
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
||||||
|
LEVEE_WORK = 'levee_work', '畔塗'
|
||||||
|
|
||||||
|
work_date = models.DateField(verbose_name='作業日')
|
||||||
|
work_type = models.CharField(
|
||||||
|
max_length=40,
|
||||||
|
choices=WorkType.choices,
|
||||||
|
verbose_name='作業種別',
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200, verbose_name='タイトル')
|
||||||
|
year = models.IntegerField(verbose_name='年度')
|
||||||
|
auto_created = models.BooleanField(default=True, verbose_name='自動生成')
|
||||||
|
delivery_trip = models.OneToOneField(
|
||||||
|
'fertilizer.DeliveryTrip',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='work_record',
|
||||||
|
verbose_name='運搬回',
|
||||||
|
)
|
||||||
|
spreading_session = models.OneToOneField(
|
||||||
|
'fertilizer.SpreadingSession',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='work_record',
|
||||||
|
verbose_name='散布実績',
|
||||||
|
)
|
||||||
|
levee_work_session = models.OneToOneField(
|
||||||
|
'levee_work.LeveeWorkSession',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='work_record',
|
||||||
|
verbose_name='畔塗記録',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-work_date', '-updated_at', '-id']
|
||||||
|
verbose_name = '作業記録'
|
||||||
|
verbose_name_plural = '作業記録'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.work_date} {self.get_work_type_display()}'
|
||||||
38
backend/apps/workrecords/serializers.py
Normal file
38
backend/apps/workrecords/serializers.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import WorkRecord
|
||||||
|
|
||||||
|
|
||||||
|
class WorkRecordSerializer(serializers.ModelSerializer):
|
||||||
|
work_type_display = serializers.CharField(source='get_work_type_display', read_only=True)
|
||||||
|
delivery_plan_id = serializers.SerializerMethodField()
|
||||||
|
delivery_plan_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = WorkRecord
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'work_date',
|
||||||
|
'work_type',
|
||||||
|
'work_type_display',
|
||||||
|
'title',
|
||||||
|
'year',
|
||||||
|
'auto_created',
|
||||||
|
'delivery_trip',
|
||||||
|
'delivery_plan_id',
|
||||||
|
'delivery_plan_name',
|
||||||
|
'spreading_session',
|
||||||
|
'levee_work_session',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_delivery_plan_id(self, obj):
|
||||||
|
if obj.delivery_trip_id:
|
||||||
|
return obj.delivery_trip.delivery_plan_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_delivery_plan_name(self, obj):
|
||||||
|
if obj.delivery_trip_id:
|
||||||
|
return obj.delivery_trip.delivery_plan.name
|
||||||
|
return None
|
||||||
48
backend/apps/workrecords/services.py
Normal file
48
backend/apps/workrecords/services.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from .models import WorkRecord
|
||||||
|
|
||||||
|
|
||||||
|
def sync_delivery_work_record(trip):
|
||||||
|
if trip.date is None:
|
||||||
|
WorkRecord.objects.filter(delivery_trip=trip).delete()
|
||||||
|
return
|
||||||
|
|
||||||
|
WorkRecord.objects.update_or_create(
|
||||||
|
delivery_trip=trip,
|
||||||
|
defaults={
|
||||||
|
'work_date': trip.date,
|
||||||
|
'work_type': WorkRecord.WorkType.FERTILIZER_DELIVERY,
|
||||||
|
'title': f'肥料運搬: {trip.delivery_plan.name} {trip.order + 1}回目',
|
||||||
|
'year': trip.delivery_plan.year,
|
||||||
|
'auto_created': True,
|
||||||
|
'spreading_session': None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_spreading_work_record(session):
|
||||||
|
WorkRecord.objects.update_or_create(
|
||||||
|
spreading_session=session,
|
||||||
|
defaults={
|
||||||
|
'work_date': session.date,
|
||||||
|
'work_type': WorkRecord.WorkType.FERTILIZER_SPREADING,
|
||||||
|
'title': f'肥料散布: {session.name.strip() or session.date}',
|
||||||
|
'year': session.year,
|
||||||
|
'auto_created': True,
|
||||||
|
'delivery_trip': None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_levee_work_record(session):
|
||||||
|
WorkRecord.objects.update_or_create(
|
||||||
|
levee_work_session=session,
|
||||||
|
defaults={
|
||||||
|
'work_date': session.date,
|
||||||
|
'work_type': WorkRecord.WorkType.LEVEE_WORK,
|
||||||
|
'title': session.title,
|
||||||
|
'year': session.year,
|
||||||
|
'auto_created': True,
|
||||||
|
'delivery_trip': None,
|
||||||
|
'spreading_session': None,
|
||||||
|
},
|
||||||
|
)
|
||||||
12
backend/apps/workrecords/urls.py
Normal file
12
backend/apps/workrecords/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import WorkRecordViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'', WorkRecordViewSet, basename='workrecord')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
22
backend/apps/workrecords/views.py
Normal file
22
backend/apps/workrecords/views.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
from .models import WorkRecord
|
||||||
|
from .serializers import WorkRecordSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = WorkRecordSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = WorkRecord.objects.select_related(
|
||||||
|
'delivery_trip',
|
||||||
|
'delivery_trip__delivery_plan',
|
||||||
|
'spreading_session',
|
||||||
|
'levee_work_session',
|
||||||
|
)
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
if year:
|
||||||
|
queryset = queryset.filter(year=year)
|
||||||
|
return queryset
|
||||||
@@ -44,6 +44,9 @@ INSTALLED_APPS = [
|
|||||||
'apps.mail',
|
'apps.mail',
|
||||||
'apps.weather',
|
'apps.weather',
|
||||||
'apps.fertilizer',
|
'apps.fertilizer',
|
||||||
|
'apps.materials',
|
||||||
|
'apps.workrecords',
|
||||||
|
'apps.levee_work',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -58,4 +58,7 @@ urlpatterns = [
|
|||||||
path('api/mail/', include('apps.mail.urls')),
|
path('api/mail/', include('apps.mail.urls')),
|
||||||
path('api/weather/', include('apps.weather.urls')),
|
path('api/weather/', include('apps.weather.urls')),
|
||||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
|
path('api/materials/', include('apps.materials.urls')),
|
||||||
|
path('api/workrecords/', include('apps.workrecords.urls')),
|
||||||
|
path('api/levee-work/', include('apps.levee_work.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
1
butler.pid
Normal file
1
butler.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3396
|
||||||
25
deploy.sh
Normal file
25
deploy.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== KeinaSystem デプロイ ==="
|
||||||
|
|
||||||
|
echo "[1/4] git pull..."
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
echo "[2/4] docker compose down..."
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
echo "[3/4] docker compose build..."
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
echo "[4/5] docker compose up -d..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo "[5/5] migrate..."
|
||||||
|
docker compose exec backend python manage.py migrate
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== デプロイ完了 ==="
|
||||||
|
docker compose ps
|
||||||
37
deploy_local.sh
Executable file
37
deploy_local.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ローカル本番同等環境の起動スクリプト
|
||||||
|
# 使用: bash deploy_local.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== KeinaSystem ローカル本番環境 ==="
|
||||||
|
|
||||||
|
# .env ファイル確認
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "エラー: .env ファイルがありません"
|
||||||
|
echo " .env.production.example を .env にコピーして値を設定してください"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/4] 停止..."
|
||||||
|
docker compose -f docker-compose.local.yml down
|
||||||
|
|
||||||
|
echo "[2/4] ビルド..."
|
||||||
|
docker compose -f docker-compose.local.yml build
|
||||||
|
|
||||||
|
echo "[3/4] 起動..."
|
||||||
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
|
||||||
|
echo "[4/4] マイグレーション..."
|
||||||
|
sleep 5
|
||||||
|
docker compose -f docker-compose.local.yml exec backend python manage.py migrate
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 起動完了 ==="
|
||||||
|
docker compose -f docker-compose.local.yml ps
|
||||||
|
echo ""
|
||||||
|
echo " フロントエンド: http://localhost:3000"
|
||||||
|
echo " バックエンドAPI: http://localhost:8000/api/"
|
||||||
|
echo ""
|
||||||
|
echo "DBをサーバーと同期する場合: bash sync_db.sh"
|
||||||
BIN
designated_mix_national.zip
Normal file
BIN
designated_mix_national.zip
Normal file
Binary file not shown.
130670
designated_mix_national/6. 指定混合.csv
Normal file
130670
designated_mix_national/6. 指定混合.csv
Normal file
File diff suppressed because it is too large
Load Diff
20
develop.bat
Normal file
20
develop.bat
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo === KeinaSystem 開発環境起動 ===
|
||||||
|
|
||||||
|
echo [1/3] docker compose down...
|
||||||
|
docker compose -f docker-compose.develop.yml down
|
||||||
|
|
||||||
|
echo [2/3] docker compose build...
|
||||||
|
docker compose -f docker-compose.develop.yml build
|
||||||
|
|
||||||
|
echo [3/3] docker compose up -d...
|
||||||
|
docker compose -f docker-compose.develop.yml up -d
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === 開発環境起動完了 ===
|
||||||
|
docker compose -f docker-compose.develop.yml ps
|
||||||
|
echo.
|
||||||
|
echo Frontend: http://localhost:3000
|
||||||
|
echo Backend: http://localhost:8000
|
||||||
59
docker-compose.develop.yml
Normal file
59
docker-compose.develop.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
container_name: keinasystem_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: keinasystem
|
||||||
|
POSTGRES_USER: keinasystem
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U keinasystem -d keinasystem"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: keinasystem_backend
|
||||||
|
environment:
|
||||||
|
DB_NAME: keinasystem
|
||||||
|
DB_USER: keinasystem
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
DEBUG: "True"
|
||||||
|
MAIL_API_KEY: ${MAIL_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: keinasystem_frontend
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
59
docker-compose.local.yml
Normal file
59
docker-compose.local.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ローカルでの本番同等テスト用
|
||||||
|
# Traefikなし、ポート直接公開、本番用Dockerfileを使用
|
||||||
|
# 使用: docker compose -f docker-compose.local.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
container_name: keinasystem_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: keinasystem
|
||||||
|
POSTGRES_USER: keinasystem
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data_local:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U keinasystem -d keinasystem"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
container_name: keinasystem_backend
|
||||||
|
environment:
|
||||||
|
DB_NAME: keinasystem
|
||||||
|
DB_USER: keinasystem
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
DEBUG: "False"
|
||||||
|
ALLOWED_HOSTS: localhost,127.0.0.1
|
||||||
|
CORS_ALLOWED_ORIGINS: http://localhost:3000
|
||||||
|
MAIL_API_KEY: ${MAIL_API_KEY}
|
||||||
|
FRONTEND_URL: http://localhost:3000
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||||
|
container_name: keinasystem_frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data_local:
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-net:
|
|
||||||
external: true
|
|
||||||
internal:
|
|
||||||
internal: true
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgis/postgis:16-3.4
|
|
||||||
container_name: keinasystem_db
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: keinasystem
|
|
||||||
POSTGRES_USER: keinasystem
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U keinasystem -d keinasystem"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile.prod
|
|
||||||
container_name: keinasystem_backend
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
DB_NAME: keinasystem
|
|
||||||
DB_USER: keinasystem
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
|
||||||
DEBUG: "False"
|
|
||||||
ALLOWED_HOSTS: main.keinafarm.net
|
|
||||||
CORS_ALLOWED_ORIGINS: https://main.keinafarm.net
|
|
||||||
MAIL_API_KEY: ${MAIL_API_KEY}
|
|
||||||
FRONTEND_URL: https://main.keinafarm.net
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
- traefik-net
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.keinasystem-api.rule=Host(`main.keinafarm.net`) && PathPrefix(`/api/`)"
|
|
||||||
- "traefik.http.routers.keinasystem-api.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.keinasystem-api.tls=true"
|
|
||||||
- "traefik.http.routers.keinasystem-api.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.routers.keinasystem-api.priority=10"
|
|
||||||
- "traefik.http.services.keinasystem-api.loadbalancer.server.port=8000"
|
|
||||||
- "traefik.docker.network=traefik-net"
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.prod
|
|
||||||
args:
|
|
||||||
NEXT_PUBLIC_API_URL: https://main.keinafarm.net
|
|
||||||
container_name: keinasystem_frontend
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
networks:
|
|
||||||
- traefik-net
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.keinasystem.rule=Host(`main.keinafarm.net`)"
|
|
||||||
- "traefik.http.routers.keinasystem.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.keinasystem.tls=true"
|
|
||||||
- "traefik.http.routers.keinasystem.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.routers.keinasystem.priority=5"
|
|
||||||
- "traefik.http.services.keinasystem.loadbalancer.server.port=3000"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
version: '3.8'
|
networks:
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgis/postgis:16-3.4
|
image: postgis/postgis:16-3.4
|
||||||
container_name: keinasystem_db
|
container_name: keinasystem_db
|
||||||
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: keinasystem
|
POSTGRES_DB: keinasystem
|
||||||
POSTGRES_USER: keinasystem
|
POSTGRES_USER: keinasystem
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -17,12 +20,15 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.prod
|
||||||
container_name: keinasystem_backend
|
container_name: keinasystem_backend
|
||||||
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
DB_NAME: keinasystem
|
DB_NAME: keinasystem
|
||||||
DB_USER: keinasystem
|
DB_USER: keinasystem
|
||||||
@@ -30,32 +36,47 @@ services:
|
|||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
DEBUG: "True"
|
DEBUG: "False"
|
||||||
|
ALLOWED_HOSTS: main.keinafarm.net
|
||||||
|
CORS_ALLOWED_ORIGINS: https://main.keinafarm.net
|
||||||
MAIL_API_KEY: ${MAIL_API_KEY}
|
MAIL_API_KEY: ${MAIL_API_KEY}
|
||||||
ports:
|
FRONTEND_URL: https://main.keinafarm.net
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./backend:/app
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: python manage.py runserver 0.0.0.0:8000
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.keinasystem-api.rule=Host(`main.keinafarm.net`) && PathPrefix(`/api/`)"
|
||||||
|
- "traefik.http.routers.keinasystem-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.keinasystem-api.tls=true"
|
||||||
|
- "traefik.http.routers.keinasystem-api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.keinasystem-api.priority=10"
|
||||||
|
- "traefik.http.services.keinasystem-api.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.prod
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: https://main.keinafarm.net
|
||||||
container_name: keinasystem_frontend
|
container_name: keinasystem_frontend
|
||||||
environment:
|
restart: always
|
||||||
NEXT_PUBLIC_API_URL: http://localhost:8000
|
|
||||||
WATCHPACK_POLLING: "true"
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- ./frontend:/app
|
|
||||||
- /app/node_modules
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
networks:
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.keinasystem.rule=Host(`main.keinafarm.net`)"
|
||||||
|
- "traefik.http.routers.keinasystem.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.keinasystem.tls=true"
|
||||||
|
- "traefik.http.routers.keinasystem.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.keinasystem.priority=5"
|
||||||
|
- "traefik.http.services.keinasystem.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
# マスタードキュメント:施肥計画機能
|
# マスタードキュメント:施肥計画機能
|
||||||
|
|
||||||
> **作成**: 2026-03-01
|
> **作成**: 2026-03-01
|
||||||
> **最終更新**: 2026-03-01
|
> **最終更新**: 2026-03-17
|
||||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理)
|
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布実績記録)
|
||||||
> **実装状況**: 実装完了・本番稼働中(最終 commit deb03ef)
|
> **実装状況**: 実装完了・本番稼働中(散布実績連携追加)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 概要
|
## 概要
|
||||||
|
|
||||||
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
||||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力する。
|
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力・在庫引当・散布実績記録・作業記録索引生成まで一連で扱う。
|
||||||
|
|
||||||
### 機能スコープ(IN / OUT)
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
| IN(実装済み) | OUT(対象外) |
|
| IN(実装済み) | OUT(対象外) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 肥料マスタ管理 | 肥料購入管理 |
|
| 肥料マスタ管理 | 肥料購入管理 |
|
||||||
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て) |
|
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照) |
|
||||||
| 3方式の自動計算 | 施肥作業の実績記録 |
|
| 3方式の自動計算 | 運搬便ごとの散布充当追跡 |
|
||||||
| 作付け計画からの圃場自動取得 | |
|
| 作付け計画からの圃場自動取得 | 相手先ごとのPDF様式実装 |
|
||||||
| PDF出力(圃場×肥料マトリクス表) | |
|
| PDF出力(圃場×肥料マトリクス表) | 残肥返却・再入庫管理 |
|
||||||
|
| 在庫引当・引当解除 | |
|
||||||
|
| 散布実績記録(日付単位・運搬済み肥料ベース) | |
|
||||||
|
| 作業記録索引(WorkRecord)自動生成 | |
|
||||||
|
| 在庫USE連携(散布実績保存時) | |
|
||||||
|
| 施肥計画進捗表示(未散布/一部散布/完了/計画超過) | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,9 +52,20 @@
|
|||||||
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
||||||
| year | int | required | 年度 |
|
| year | int | required | 年度 |
|
||||||
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
||||||
|
| is_confirmed | bool | default=False | ~~散布確定済みフラグ~~(deprecated: 新UIでは使用しない) |
|
||||||
|
| confirmed_at | datetime | nullable | ~~散布確定日時~~(deprecated: 新UIでは使用しない) |
|
||||||
| created_at | datetime | auto | |
|
| created_at | datetime | auto | |
|
||||||
| updated_at | datetime | auto | |
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
#### 表示用計算項目(APIレスポンスに含まれる)
|
||||||
|
|
||||||
|
| 項目 | 型 | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| spread_status | string | `unspread` / `partial` / `completed` / `over_applied` |
|
||||||
|
| planned_total_bags | decimal | 計画袋数合計(全entries.bagsの合計) |
|
||||||
|
| spread_total_bags | decimal | 散布済み袋数合計(全entries.actual_bagsの合計) |
|
||||||
|
| remaining_total_bags | decimal | 残袋数(planned_total_bags - spread_total_bags) |
|
||||||
|
|
||||||
### FertilizationEntry(施肥エントリ:圃場×肥料×袋数)
|
### FertilizationEntry(施肥エントリ:圃場×肥料×袋数)
|
||||||
|
|
||||||
| フィールド | 型 | 制約 | 説明 |
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
@@ -58,11 +74,60 @@
|
|||||||
| plan | FK(FertilizationPlan) | CASCADE | |
|
| plan | FK(FertilizationPlan) | CASCADE | |
|
||||||
| field | FK(fields.Field) | CASCADE | |
|
| field | FK(fields.Field) | CASCADE | |
|
||||||
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
|
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
|
||||||
| bags | decimal(8,2) | required | 袋数 |
|
| bags | decimal(8,2) | required | 袋数(計画値) |
|
||||||
|
| actual_bags | decimal(10,4) | nullable | 散布実績集計値(SpreadingSessionItemから自動集計) |
|
||||||
|
|
||||||
- `unique_together = ['plan', 'field', 'fertilizer']`
|
- `unique_together = ['plan', 'field', 'fertilizer']`
|
||||||
- 順序: `field__display_order, field__id, fertilizer__name`
|
- 順序: `field__display_order, field__id, fertilizer__name`
|
||||||
|
|
||||||
|
### SpreadingSession(散布実績セッション)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| year | int | required | 年度フィルタ用 |
|
||||||
|
| date | DateField | required | 散布日 |
|
||||||
|
| name | varchar(100) | required | セッション名(必須) |
|
||||||
|
| notes | text | blank | 備考 |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
- `year + date` の一意制約は付けない(同日に午前・午後やエリア別で複数記録可能)
|
||||||
|
|
||||||
|
### SpreadingSessionItem(散布実績明細)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| session | FK(SpreadingSession) | CASCADE | |
|
||||||
|
| field | FK(fields.Field) | PROTECT | |
|
||||||
|
| fertilizer | FK(Fertilizer) | PROTECT | |
|
||||||
|
| actual_bags | decimal(10,4) | required | 実散布袋数 |
|
||||||
|
| planned_bags_snapshot | decimal(10,4) | required | 表示時点の計画値 |
|
||||||
|
| delivered_bags_snapshot | decimal(10,4) | required | 表示時点の運搬済み合計 |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
- `unique_together = ['session', 'field', 'fertilizer']`
|
||||||
|
|
||||||
|
### WorkRecord(作業記録索引)
|
||||||
|
|
||||||
|
別アプリ `apps/workrecords/` で管理。施肥・運搬の作業を日付順に一覧するための索引テーブル。
|
||||||
|
詳細の本体は各業務テーブル側(DeliveryTrip / SpreadingSession)に持つ。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| work_date | DateField | required | 作業日 |
|
||||||
|
| work_type | varchar | required | `fertilizer_delivery` / `fertilizer_spreading` |
|
||||||
|
| title | varchar(200) | required | 一覧表示名 |
|
||||||
|
| year | int | required | 年度フィルタ補助 |
|
||||||
|
| auto_created | bool | default=True | 自動生成フラグ |
|
||||||
|
| delivery_trip | OneToOne FK(DeliveryTrip) | nullable | 運搬由来 |
|
||||||
|
| spreading_session | OneToOne FK(SpreadingSession) | nullable | 散布由来 |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API エンドポイント
|
## API エンドポイント
|
||||||
@@ -102,6 +167,8 @@
|
|||||||
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
||||||
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
||||||
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
||||||
|
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | ~~散布確定~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||||
|
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | ~~散布確定取消~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||||
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
||||||
|
|
||||||
一覧レスポンス例(FertilizationPlan):
|
一覧レスポンス例(FertilizationPlan):
|
||||||
@@ -113,6 +180,8 @@
|
|||||||
"variety": 3,
|
"variety": 3,
|
||||||
"variety_name": "コシヒカリ",
|
"variety_name": "コシヒカリ",
|
||||||
"crop_name": "米",
|
"crop_name": "米",
|
||||||
|
"is_confirmed": false,
|
||||||
|
"confirmed_at": null,
|
||||||
"field_count": 12,
|
"field_count": 12,
|
||||||
"fertilizer_count": 2,
|
"fertilizer_count": 2,
|
||||||
"entries": [
|
"entries": [
|
||||||
@@ -146,6 +215,61 @@ POST/PUT リクエスト例:
|
|||||||
|
|
||||||
PUT 時は entries が全置換(削除→再作成)。entries を省略した場合は既存を維持。
|
PUT 時は entries が全置換(削除→再作成)。entries を省略した場合は既存を維持。
|
||||||
|
|
||||||
|
### 散布実績(新規)
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/fertilizer/spreading/?year={year}` | 年度別一覧 |
|
||||||
|
| POST | `/api/fertilizer/spreading/` | 新規作成 |
|
||||||
|
| GET | `/api/fertilizer/spreading/{id}/` | 詳細 |
|
||||||
|
| PUT | `/api/fertilizer/spreading/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/fertilizer/spreading/{id}/` | 削除 |
|
||||||
|
| GET | `/api/fertilizer/spreading/candidates/?year={year}` | 散布候補一覧 |
|
||||||
|
|
||||||
|
散布候補一覧レスポンス例:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": 5,
|
||||||
|
"field_name": "田中上",
|
||||||
|
"fertilizer": 1,
|
||||||
|
"fertilizer_name": "電気炉さい",
|
||||||
|
"planned_bags": "4.0000",
|
||||||
|
"delivered_bags": "4.0000",
|
||||||
|
"spread_bags": "1.5000",
|
||||||
|
"remaining_bags": "2.5000",
|
||||||
|
"remaining_plan_bags": "2.5000",
|
||||||
|
"delivery_gap": "0.0000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
散布実績 POST リクエスト例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"date": "2026-04-15",
|
||||||
|
"name": "午前・田中エリア",
|
||||||
|
"notes": "",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"field": 5,
|
||||||
|
"fertilizer": 1,
|
||||||
|
"actual_bags": "2.5000",
|
||||||
|
"planned_bags_snapshot": "4.0000",
|
||||||
|
"delivered_bags_snapshot": "4.0000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 作業記録(新規・別アプリ)
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/workrecords/?year={year}` | 一覧 |
|
||||||
|
| GET | `/api/workrecords/{id}/` | 詳細(元レコードへのリンク情報を返す) |
|
||||||
|
|
||||||
### 圃場候補取得
|
### 圃場候補取得
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -269,9 +393,11 @@ GET /api/plans/crops/
|
|||||||
### 施肥計画一覧(`/fertilizer`)
|
### 施肥計画一覧(`/fertilizer`)
|
||||||
|
|
||||||
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
||||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数
|
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布進捗
|
||||||
- 操作ボタン: PDF出力・編集・削除
|
- 操作ボタン: PDF出力・編集・削除
|
||||||
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
||||||
|
- 進捗表示: `未散布` / `一部散布 3.5 / 8.0袋` / `散布完了` / `計画超過`
|
||||||
|
- 計画値と実績値を並べて表示
|
||||||
|
|
||||||
### 肥料マスタ(`/fertilizer/masters`)
|
### 肥料マスタ(`/fertilizer/masters`)
|
||||||
|
|
||||||
@@ -295,6 +421,12 @@ GET /api/plans/crops/
|
|||||||
6. **手動調整**: マトリクス表のセルを直接編集
|
6. **手動調整**: マトリクス表のセルを直接編集
|
||||||
7. **保存**: 「保存」ボタンで entries を一括送信
|
7. **保存**: 「保存」ボタンで entries を一括送信
|
||||||
|
|
||||||
|
#### 在庫連携・実績表示
|
||||||
|
|
||||||
|
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
|
||||||
|
- マトリクス表で `bags`(計画値)を編集可能、`actual_bags`(実績値)は読み取り専用で参照表示
|
||||||
|
- 散布実績画面(`/fertilizer/spreading`)へのリンクを表示
|
||||||
|
|
||||||
#### マトリクスの表示仕様
|
#### マトリクスの表示仕様
|
||||||
|
|
||||||
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
|
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
|
||||||
@@ -302,7 +434,24 @@ GET /api/plans/crops/
|
|||||||
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
||||||
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
||||||
|
|
||||||
#### State 構成
|
### 散布実績画面(`/fertilizer/spreading`)
|
||||||
|
|
||||||
|
- 年度セレクタ(localStorage `fertilizerYear` と連動)
|
||||||
|
- 散布日入力(DateField)
|
||||||
|
- セッション名入力(必須)
|
||||||
|
- 運搬済み・未散布候補一覧を表示(`candidates` APIから取得)
|
||||||
|
- 圃場単位で選択可能(全部または一部)
|
||||||
|
- 実績袋数の編集
|
||||||
|
- 差異がある場合はインライン警告表示
|
||||||
|
- 保存時に在庫USE連携・WorkRecord自動生成・FertilizationEntry.actual_bags再集計を実行
|
||||||
|
|
||||||
|
### 作業記録画面(`/workrecords`)
|
||||||
|
|
||||||
|
- 年度セレクタ
|
||||||
|
- 日付・作業種別・タイトルの一覧表示
|
||||||
|
- 元データ(運搬回 / 散布セッション)への遷移リンク
|
||||||
|
|
||||||
|
#### State 構成(施肥計画編集画面)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 基本情報
|
// 基本情報
|
||||||
@@ -338,13 +487,15 @@ backend/apps/fertilizer/
|
|||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── admin.py # Django admin 登録
|
├── admin.py # Django admin 登録
|
||||||
├── apps.py # FertilizerConfig
|
├── apps.py # FertilizerConfig
|
||||||
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry
|
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry, SpreadingSession, SpreadingSessionItem
|
||||||
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
|
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer, SpreadingSessionSerializer
|
||||||
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
|
├── services.py # actual_bags再集計、WorkRecord自動生成、在庫USE連携
|
||||||
├── urls.py # DefaultRouter + candidate_fields/ + calculate/
|
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, SpreadingSessionViewSet, CandidateFieldsView, CalculateView
|
||||||
|
├── urls.py # DefaultRouter + candidate_fields/ + calculate/ + spreading/
|
||||||
├── migrations/
|
├── migrations/
|
||||||
│ ├── 0001_initial.py
|
│ ├── 0001_initial.py
|
||||||
│ └── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
│ ├── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
||||||
|
│ └── ... # SpreadingSession, SpreadingSessionItem, actual_bags 追加
|
||||||
└── templates/
|
└── templates/
|
||||||
└── fertilizer/
|
└── fertilizer/
|
||||||
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
||||||
@@ -362,25 +513,131 @@ frontend/src/app/fertilizer/
|
|||||||
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
||||||
├── masters/
|
├── masters/
|
||||||
│ └── page.tsx # 肥料マスタ管理
|
│ └── page.tsx # 肥料マスタ管理
|
||||||
|
├── spreading/
|
||||||
|
│ └── ... # 散布実績画面(一覧・作成・編集)
|
||||||
└── _components/
|
└── _components/
|
||||||
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
||||||
|
|
||||||
|
frontend/src/app/workrecords/
|
||||||
|
└── ... # 作業記録画面(一覧・詳細)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 変更されたファイル
|
### 変更されたファイル
|
||||||
|
|
||||||
| ファイル | 変更内容 |
|
| ファイル | 変更内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'` を追加 |
|
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'`, `'apps.workrecords'` を追加 |
|
||||||
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
|
| `backend/keinasystem/urls.py` | `api/fertilizer/`, `api/workrecords/` を追加 |
|
||||||
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
|
| `backend/apps/materials/models.py` | `StockTransaction.spreading_item` FK 追加(`on_delete=SET_NULL`) |
|
||||||
|
| `backend/apps/workrecords/` | 作業記録索引アプリ(WorkRecord モデル・API・services) |
|
||||||
|
| `frontend/src/types/index.ts` | 施肥・散布・作業記録の型を追加 |
|
||||||
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |
|
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 在庫連携
|
||||||
|
|
||||||
|
### RESERVE(施肥計画保存時)
|
||||||
|
|
||||||
|
- 従来どおり計画値 `bags` ベースで維持
|
||||||
|
- 施肥計画の entries 保存時に RESERVE トランザクションを作成
|
||||||
|
|
||||||
|
### USE(散布実績保存時)
|
||||||
|
|
||||||
|
- `SpreadingSessionItem` ごとに USE を1件作成
|
||||||
|
- `material`: `item.fertilizer.material`
|
||||||
|
- `quantity`: `actual_bags`
|
||||||
|
- `occurred_on`: `session.date`
|
||||||
|
- `note`: `散布実績「{session.name or session.date}」`
|
||||||
|
|
||||||
|
### StockTransaction 追加フィールド
|
||||||
|
|
||||||
|
- `spreading_item = FK(SpreadingSessionItem, null=True, blank=True, on_delete=SET_NULL)`
|
||||||
|
|
||||||
|
### 更新・削除
|
||||||
|
|
||||||
|
- 散布実績更新時: その session に紐づく USE を全置換で作り直す
|
||||||
|
- 散布実績削除時: 対応 USE を削除する
|
||||||
|
|
||||||
|
### RESERVE と USE の整合
|
||||||
|
|
||||||
|
- RESERVE は計画値 `bags` ベース
|
||||||
|
- USE は散布実績 `actual_bags` ベース
|
||||||
|
- 計画値と実績値は併存する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 集計ルール
|
||||||
|
|
||||||
|
### planned_total(圃場×肥料×年度)
|
||||||
|
|
||||||
|
`FertilizationEntry.bags` の合計
|
||||||
|
|
||||||
|
### delivered_total(圃場×肥料×年度)
|
||||||
|
|
||||||
|
`DeliveryTrip.date != null` の `DeliveryTripItem.bags` 合計
|
||||||
|
|
||||||
|
### spread_total(圃場×肥料×年度)
|
||||||
|
|
||||||
|
`SpreadingSessionItem.actual_bags` の合計
|
||||||
|
|
||||||
|
### actual_bags 再集計ルール
|
||||||
|
|
||||||
|
- `SUM(SpreadingSessionItem.actual_bags)` を同一 year, field, fertilizer で集計
|
||||||
|
- 散布実績の保存・更新・削除時に該当する `FertilizationEntry.actual_bags` を即時再計算
|
||||||
|
- `SUM(...) = 0` の場合は `actual_bags = null`
|
||||||
|
|
||||||
|
### remaining_bags(表示用の残量)
|
||||||
|
|
||||||
|
`delivered_total - spread_total`
|
||||||
|
|
||||||
|
### remaining_plan_bags(計画進捗用の残量)
|
||||||
|
|
||||||
|
`planned_total - spread_total`
|
||||||
|
|
||||||
|
### 差異の扱い
|
||||||
|
|
||||||
|
- `remaining_bags < 0`: 運搬実績不足
|
||||||
|
- `remaining_plan_bags < 0`: 計画超過
|
||||||
|
- 圃場+肥料単位で差異が分かることを優先する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WorkRecord 自動生成ルール
|
||||||
|
|
||||||
|
### 運搬(fertilizer_delivery)
|
||||||
|
|
||||||
|
- `DeliveryTrip.date` 保存時に upsert
|
||||||
|
- `title = 肥料運搬: {delivery_plan.name} {n}回目`
|
||||||
|
- 日付削除時は対応 WorkRecord を削除
|
||||||
|
|
||||||
|
### 散布(fertilizer_spreading)
|
||||||
|
|
||||||
|
- `SpreadingSession` 保存時に upsert
|
||||||
|
- `title = 肥料散布: {session.name or session.date}`
|
||||||
|
- 削除時は対応 WorkRecord を削除
|
||||||
|
|
||||||
|
### 実装方針
|
||||||
|
|
||||||
|
自動生成は view に直書きせず、サービス層(`services.py`)で idempotent に実装する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前年度コピー
|
||||||
|
|
||||||
|
`copy_from_previous_year` で前年度の `FertilizationEntry` をコピーする際のルール:
|
||||||
|
|
||||||
|
- `actual_bags` がある場合: `actual_bags` を新年度の `bags` 初期値として使用
|
||||||
|
- `actual_bags` が `null` の場合: 従来どおり `bags` をコピー
|
||||||
|
|
||||||
|
前年度に実際に散布した量を次年度計画の初期値として再利用できる。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 型定義(TypeScript)
|
## 型定義(TypeScript)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/types/index.ts
|
// frontend/src/types/index.ts(主要な型のみ抜粋)
|
||||||
|
|
||||||
export interface Fertilizer {
|
export interface Fertilizer {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -401,6 +658,7 @@ export interface FertilizationEntry {
|
|||||||
fertilizer: number;
|
fertilizer: number;
|
||||||
fertilizer_name: string;
|
fertilizer_name: string;
|
||||||
bags: string;
|
bags: string;
|
||||||
|
actual_bags: string | null; // 散布実績集計値
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationPlan {
|
export interface FertilizationPlan {
|
||||||
@@ -413,6 +671,10 @@ export interface FertilizationPlan {
|
|||||||
field_count: number;
|
field_count: number;
|
||||||
fertilizer_count: number;
|
fertilizer_count: number;
|
||||||
entries: FertilizationEntry[];
|
entries: FertilizationEntry[];
|
||||||
|
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
||||||
|
planned_total_bags: string;
|
||||||
|
spread_total_bags: string;
|
||||||
|
remaining_total_bags: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -451,6 +713,25 @@ plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-v
|
|||||||
PUT 時は entries を全削除→再作成する「全置換」方式。
|
PUT 時は entries を全削除→再作成する「全置換」方式。
|
||||||
部分更新は非対応(PATCH でも entries がある場合は全置換)。
|
部分更新は非対応(PATCH でも entries がある場合は全置換)。
|
||||||
|
|
||||||
|
### 散布実績の在庫連携
|
||||||
|
|
||||||
|
- 施肥計画保存時: `RESERVE`(計画値 `bags` ベース)
|
||||||
|
- 散布実績保存時: `USE`(実績値 `actual_bags` ベース)
|
||||||
|
- `RESERVE` と `USE` は併存する(計画値と実績値は別管理)
|
||||||
|
- 散布実績更新時は `session` に紐づく `USE` を全置換で作り直す
|
||||||
|
- 散布実績削除時は対応 `USE` を削除する(`StockTransaction.spreading_item` は `SET_NULL`)
|
||||||
|
- `perform_destroy` で明示的に `StockTransaction` を削除してから `session.delete()` を呼ぶ
|
||||||
|
|
||||||
|
### 散布セッション名は必須
|
||||||
|
|
||||||
|
`SpreadingSession.name` は必須フィールド。WorkRecord のタイトル生成や一覧表示に使用するため、
|
||||||
|
空文字での保存は許可しない。
|
||||||
|
|
||||||
|
### useSearchParams と Suspense(Next.js 14)
|
||||||
|
|
||||||
|
散布実績画面(`/fertilizer/spreading`)では `useSearchParams()` を使用するため、
|
||||||
|
`Suspense` boundary でラップする必要がある(本番ビルドで必須)。
|
||||||
|
|
||||||
### Next.js ホットリロードが効かない問題(Windows + Docker)
|
### Next.js ホットリロードが効かない問題(Windows + Docker)
|
||||||
|
|
||||||
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
||||||
@@ -463,6 +744,8 @@ Windows 環境では Docker ボリュームマウント経由のファイル変
|
|||||||
|
|
||||||
## 将来の拡張(スコープ外)
|
## 将来の拡張(スコープ外)
|
||||||
|
|
||||||
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
- **相手先別PDF様式**: 客先ごとの提出資料フォーマット(元データは散布実績から取得可能)
|
||||||
|
- **残肥返却・再入庫管理**: 散布後の残りを在庫に戻す処理
|
||||||
|
- **SpreadingAllocation**: 運搬便単位の散布充当追跡(現状は集計ベースで十分)
|
||||||
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
||||||
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数)
|
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||||
|
|||||||
@@ -1,65 +1,140 @@
|
|||||||
# マスタードキュメント:分配計画機能
|
# マスタードキュメント:運搬計画機能(旧・分配計画)
|
||||||
|
|
||||||
> **作成**: 2026-03-02
|
> **作成**: 2026-03-02
|
||||||
> **最終更新**: 2026-03-02
|
> **最終更新**: 2026-03-16
|
||||||
> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計)
|
> **対象機能**: 運搬計画(施肥計画の肥料を軽トラで運ぶ単位で計画・記録する)
|
||||||
> **実装状況**: 実装完了
|
> **実装状況**: 本番稼働中
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 概要
|
## 概要
|
||||||
|
|
||||||
施肥計画(FertilizationPlan)で決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。
|
施肥計画で決めた圃場ごとの肥料袋数を、**軽トラ1回分の積載単位**で運搬計画にまとめる機能。
|
||||||
例:「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。
|
実際の作業では一度に全部運べないため、「何回目にどのグループのどの肥料を何袋運ぶか」を計画・記録する。
|
||||||
|
|
||||||
|
### 旧設計(分配計画)からの変更理由
|
||||||
|
|
||||||
|
旧設計は「1つの施肥計画の圃場をグループ分けする」だけだった。
|
||||||
|
実運用で以下のギャップが判明(2026-03-16):
|
||||||
|
|
||||||
|
1. **複数の施肥計画が混在する** - 軽トラには品種をまたいで積む
|
||||||
|
2. **単一の施肥計画が分割される** - 1回で運びきれない
|
||||||
|
3. **全肥料を一度に運ぶわけではない** - 運ぶ肥料を指定する必要がある
|
||||||
|
4. **圃場単位の合計袋数は不要** - グループ×肥料の合計が重要
|
||||||
|
5. **同じグループの圃場を回ごとに分割する** - 載りきらないときは次の回に
|
||||||
|
6. **作業記録でもある** - 運搬した日付を記録したい
|
||||||
|
|
||||||
### 機能スコープ
|
### 機能スコープ
|
||||||
|
|
||||||
| IN(実装済み) | OUT(対象外) |
|
| IN(実装対象) | OUT(対象外) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 |
|
| 年度単位の運搬計画作成 | 購入管理 |
|
||||||
| グループ×肥料の集計表(画面表示) | 実施記録 |
|
| 配送先グループへの圃場割り当て | 肥料の在庫管理 |
|
||||||
| PDF出力(グループ合計行+圃場サブ行) | |
|
| 運搬回ごとの圃場×肥料割り当て | ルート最適化 |
|
||||||
| グループの順序変更・名前変更 | |
|
| 回ごとの積載合計リアルタイム表示 | |
|
||||||
|
| 圃場を回の間で移動する操作 | |
|
||||||
|
| 「残り全部」一括割り当て | |
|
||||||
|
| 回ごとの運搬日記録 | |
|
||||||
|
| PDF出力(回ごとに1ページ) | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## データモデル
|
## データモデル
|
||||||
|
|
||||||
### DistributionPlan(分配計画)
|
### 旧モデルからの移行
|
||||||
|
|
||||||
|
| 旧(削除) | 新(追加) | 備考 |
|
||||||
|
|---|---|---|
|
||||||
|
| DistributionPlan | DeliveryPlan | FK(FertilizationPlan) 廃止 → year ベース |
|
||||||
|
| DistributionGroup | DeliveryGroup | ほぼ同等 |
|
||||||
|
| DistributionGroupField | DeliveryGroupField | ほぼ同等 |
|
||||||
|
| (なし) | DeliveryTrip | 新規:運搬回 |
|
||||||
|
| (なし) | DeliveryTripItem | 新規:運搬明細(圃場×肥料単位) |
|
||||||
|
|
||||||
|
### DeliveryPlan(運搬計画)
|
||||||
|
|
||||||
| フィールド | 型 | 制約 | 説明 |
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| id | int | PK | |
|
| id | int | PK | |
|
||||||
| fertilization_plan | FK(FertilizationPlan) | CASCADE | |
|
| year | int | required | 年度 |
|
||||||
| name | varchar(200) | required | 計画名 |
|
| name | varchar(200) | required | 計画名 |
|
||||||
| created_at / updated_at | datetime | auto | |
|
| created_at / updated_at | datetime | auto | |
|
||||||
|
|
||||||
- `ordering = ['-fertilization_plan__year', 'name']`
|
- `ordering = ['-year', 'name']`
|
||||||
- 1つの施肥計画に対して複数の分配計画を作れる(OneToOneではなくFK)
|
- 施肥計画への直接FK なし(年度ベースで全施肥計画を横断)
|
||||||
|
|
||||||
### DistributionGroup(分配グループ)
|
### DeliveryGroup(配送先グループ)
|
||||||
|
|
||||||
| フィールド | 型 | 制約 | 説明 |
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| id | int | PK | |
|
| id | int | PK | |
|
||||||
| distribution_plan | FK(DistributionPlan) | CASCADE | |
|
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
|
||||||
| name | varchar(100) | required | グループ名 |
|
| name | varchar(100) | required | グループ名(例: キウイ, 足川北) |
|
||||||
| order | PositiveIntegerField | default=0 | 表示順 |
|
| order | PositiveIntegerField | default=0 | 表示順 |
|
||||||
|
|
||||||
- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可
|
- `unique_together = [['delivery_plan', 'name']]`
|
||||||
- `ordering = ['order', 'id']`
|
- `ordering = ['order', 'id']`
|
||||||
|
|
||||||
### DistributionGroupField(グループ圃場割り当て)
|
### DeliveryGroupField(グループ圃場割り当て)
|
||||||
|
|
||||||
| フィールド | 型 | 制約 | 説明 |
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| id | int | PK | |
|
| id | int | PK | |
|
||||||
| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 |
|
| delivery_plan | FK(DeliveryPlan) | CASCADE | 一意制約用 |
|
||||||
| group | FK(DistributionGroup) | CASCADE | |
|
| group | FK(DeliveryGroup) | CASCADE | |
|
||||||
| field | FK(fields.Field) | PROTECT | 圃場 |
|
| field | FK(fields.Field) | PROTECT | |
|
||||||
|
|
||||||
- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画
|
- `unique_together = [['delivery_plan', 'field']]` → 1圃場=1グループ/1計画
|
||||||
- `ordering = ['field__display_order', 'field__id']`
|
|
||||||
|
### DeliveryTrip(運搬回)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
|
||||||
|
| order | PositiveIntegerField | default=0 | 何回目(表示順) |
|
||||||
|
| name | varchar(100) | blank | 任意の名前(例: "たちはるか電気炉さい") |
|
||||||
|
| date | DateField | nullable | 運搬日(デフォルト: 1回目の日付を引き継ぎ) |
|
||||||
|
|
||||||
|
- `ordering = ['order', 'id']`
|
||||||
|
|
||||||
|
### DeliveryTripItem(運搬明細)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| trip | FK(DeliveryTrip) | CASCADE | |
|
||||||
|
| field | FK(fields.Field) | PROTECT | |
|
||||||
|
| fertilizer | FK(Fertilizer) | PROTECT | |
|
||||||
|
| bags | Decimal(10,4) | required | 袋数 |
|
||||||
|
|
||||||
|
- `unique_together = [['trip', 'field', 'fertilizer']]`
|
||||||
|
- bags は施肥計画の FertilizationEntry から自動計算で初期値を設定するが、手動上書きも可能
|
||||||
|
|
||||||
|
### ER図(概念)
|
||||||
|
|
||||||
|
```
|
||||||
|
DeliveryPlan (運搬計画)
|
||||||
|
├── year, name
|
||||||
|
│
|
||||||
|
├── groups → DeliveryGroup (配送先グループ)
|
||||||
|
│ ├── name, order
|
||||||
|
│ └── fields → DeliveryGroupField → Field
|
||||||
|
│
|
||||||
|
└── trips → DeliveryTrip (運搬回)
|
||||||
|
├── order, name, date
|
||||||
|
└── items → DeliveryTripItem
|
||||||
|
├── field → Field
|
||||||
|
├── fertilizer → Fertilizer
|
||||||
|
└── bags
|
||||||
|
```
|
||||||
|
|
||||||
|
### 袋数の算出ルール
|
||||||
|
|
||||||
|
1. 運搬計画作成時、年度の全 FertilizationEntry を参照して「グループ×肥料→圃場×袋数」を自動算出
|
||||||
|
2. ユーザーが運搬回に圃場を割り当てると、該当する FertilizationEntry の bags が DeliveryTripItem.bags にコピーされる
|
||||||
|
3. 手動で bags を上書きすることも可能(施肥計画との差異は許容)
|
||||||
|
4. 「残り全部」操作: 施肥計画の合計 − 既に割り当て済みの回の合計 = 残り
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,143 +144,212 @@
|
|||||||
|
|
||||||
| メソッド | URL | 説明 |
|
| メソッド | URL | 説明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/fertilizer/distribution/?year={year}` | 一覧(年度フィルタ) |
|
| GET | `/api/fertilizer/delivery/?year={year}` | 一覧(年度フィルタ) |
|
||||||
| POST | `/api/fertilizer/distribution/` | 新規作成 |
|
| POST | `/api/fertilizer/delivery/` | 新規作成 |
|
||||||
| GET | `/api/fertilizer/distribution/{id}/` | 詳細(groups/entries/unassigned込み) |
|
| GET | `/api/fertilizer/delivery/{id}/` | 詳細(groups/trips/items 込み) |
|
||||||
| PUT | `/api/fertilizer/distribution/{id}/` | 更新(groups全置換) |
|
| PUT | `/api/fertilizer/delivery/{id}/` | 更新(groups・trips 全置換) |
|
||||||
| DELETE | `/api/fertilizer/distribution/{id}/` | 削除 |
|
| DELETE | `/api/fertilizer/delivery/{id}/` | 削除 |
|
||||||
| GET | `/api/fertilizer/distribution/{id}/pdf/` | PDF出力(application/pdf) |
|
| GET | `/api/fertilizer/delivery/{id}/pdf/` | PDF出力 |
|
||||||
|
|
||||||
### 一覧レスポンス(DistributionPlanListSerializer)
|
### 一覧レスポンス
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "2025年コシヒカリ 分配計画",
|
"year": 2026,
|
||||||
"fertilization_plan_id": 3,
|
"name": "2026春 肥料運搬",
|
||||||
"fertilization_plan_name": "2025年コシヒカリ施肥計画",
|
"group_count": 5,
|
||||||
"year": 2025,
|
"trip_count": 3,
|
||||||
"variety_name": "コシヒカリ",
|
|
||||||
"crop_name": "米",
|
|
||||||
"group_count": 3,
|
|
||||||
"field_count": 12,
|
|
||||||
"created_at": "...",
|
"created_at": "...",
|
||||||
"updated_at": "..."
|
"updated_at": "..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 詳細レスポンス(DistributionPlanReadSerializer)
|
### 詳細レスポンス
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "2025年コシヒカリ 分配計画",
|
"year": 2026,
|
||||||
"fertilization_plan": {
|
"name": "2026春 肥料運搬",
|
||||||
"id": 3,
|
|
||||||
"name": "2025年コシヒカリ施肥計画",
|
|
||||||
"year": 2025,
|
|
||||||
"variety_name": "コシヒカリ",
|
|
||||||
"crop_name": "米",
|
|
||||||
"fertilizers": [{"id": 1, "name": "一発肥料"}],
|
|
||||||
"entries": [{"field": 5, "fertilizer": 1, "bags": "2.40"}]
|
|
||||||
},
|
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"name": "田中エリア",
|
"name": "キウイ",
|
||||||
"order": 0,
|
"order": 0,
|
||||||
"fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}]
|
"fields": [
|
||||||
|
{"id": 5, "name": "キウイ畑1", "area_tan": "1.2000"}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}]
|
"trips": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"order": 0,
|
||||||
|
"name": "1回目 たちはるか電気炉さい",
|
||||||
|
"date": "2026-03-16",
|
||||||
|
"items": [
|
||||||
|
{"field": 5, "fertilizer": 1, "bags": "4.00"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unassigned_fields": [],
|
||||||
|
"available_fertilizers": [
|
||||||
|
{"id": 1, "name": "電気炉さい"},
|
||||||
|
{"id": 2, "name": "ミネラルホウ素"}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `available_fertilizers`: 該当年度の全施肥計画で使われている肥料の一覧
|
||||||
|
- `unassigned_fields`: グループに割り当てられていない圃場
|
||||||
|
|
||||||
### 書き込みリクエスト(POST/PUT)
|
### 書き込みリクエスト(POST/PUT)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "2025年コシヒカリ 分配計画",
|
"year": 2026,
|
||||||
"fertilization_plan_id": 3,
|
"name": "2026春 肥料運搬",
|
||||||
"groups": [
|
"groups": [
|
||||||
{"name": "田中エリア", "order": 0, "field_ids": [5, 6]},
|
{"name": "キウイ", "order": 0, "field_ids": [5, 6]}
|
||||||
{"name": "奥地エリア", "order": 1, "field_ids": [7]}
|
],
|
||||||
|
"trips": [
|
||||||
|
{
|
||||||
|
"order": 0,
|
||||||
|
"name": "1回目",
|
||||||
|
"date": "2026-03-16",
|
||||||
|
"items": [
|
||||||
|
{"field_id": 5, "fertilizer_id": 1, "bags": "4.00"}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
PUT は groups を全削除→再作成する全置換方式。
|
PUT は groups・trips を全削除→再作成する全置換方式。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## フロントエンド画面
|
## フロントエンド画面
|
||||||
|
|
||||||
### 分配計画一覧 `/distribution`
|
### 運搬計画一覧 `/distribution`
|
||||||
|
|
||||||
- 年度セレクタ(`localStorage distributionYear` で保持)
|
- 年度セレクタ(`localStorage distributionYear` で保持)
|
||||||
- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場数
|
- テーブル: 計画名・グループ数・回数
|
||||||
- アクション: PDF・編集・削除
|
- アクション: PDF・編集・削除
|
||||||
- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示)
|
|
||||||
|
|
||||||
### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit`
|
### 運搬計画編集 `/distribution/new` / `/distribution/[id]/edit`
|
||||||
|
|
||||||
**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx`
|
#### 画面レイアウト
|
||||||
|
|
||||||
#### State構成
|
```
|
||||||
|
[計画名: ________________] [年度: 2026]
|
||||||
|
|
||||||
```typescript
|
━━━ グループ定義 ━━━━━━━━━━━━━━━━━━━
|
||||||
// 基本情報
|
(既存の方式: グループ追加・圃場割り当て・並び替え)
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [fertilizationPlanId, setFertilizationPlanId] = useState<number|''>('')
|
|
||||||
|
|
||||||
// 施肥計画詳細(施肥計画選択後に取得)
|
━━━ 対象肥料 ━━━━━━━━━━━━━━━━━━━━━
|
||||||
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null)
|
☑電気炉さい ☑ミネラルホウ素 ☐有機100号 ...
|
||||||
|
(年度の施肥計画に含まれる肥料をチェックボックスで選択)
|
||||||
|
|
||||||
// ローカルグループ(tempId で管理、保存時にサーバーへ送信)
|
━━━ 未割り当て ━━━━━━━━━━━━━━━━━━━━
|
||||||
const [groups, setGroups] = useState<LocalGroup[]>([])
|
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
|
||||||
// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string }
|
圃場A 電気炉さい:2 ミネラルホウ素:3 [→1回目 ▼]
|
||||||
|
圃場B 電気炉さい:2 ミネラルホウ素:2 [→1回目 ▼]
|
||||||
|
★ 足川北 (小計: 電気炉さい 12, ミネラルホウ素 6)
|
||||||
|
圃場D ...
|
||||||
|
|
||||||
|
━━━ 1回目 (2026-03-16) ━━━ 積載: 46袋 ━━━
|
||||||
|
日付: [2026-03-16] 名前: [たちはるか電気炉さい]
|
||||||
|
★ たちはるか (小計: 電気炉さい 46)
|
||||||
|
圃場X 電気炉さい:10 [←戻す]
|
||||||
|
圃場Y 電気炉さい:12 [←戻す]
|
||||||
|
...
|
||||||
|
|
||||||
|
━━━ 2回目 (2026-03-16) ━━━ 積載: 39袋 ━━━
|
||||||
|
日付: [2026-03-16] 名前: [____________]
|
||||||
|
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
|
||||||
|
...
|
||||||
|
|
||||||
|
[+回を追加] [残り全部→新しい回] [保存]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### UI構成
|
#### 主要な操作
|
||||||
|
|
||||||
1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ
|
| 操作 | 方法 | 説明 |
|
||||||
2. **グループ割り当て**:
|
|---|---|---|
|
||||||
- 新規グループ追加(名前入力 + 追加ボタン)
|
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
|
||||||
- グループカード(↑↓順序変更・鉛筆名前変更・×削除)
|
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
|
||||||
- グループ内圃場(×解除)+ 肥料別袋数をインライン表示
|
| 圃場を別の回に移動 | 圃場行の「移動...」ドロップダウン | 回の間で移動 |
|
||||||
- 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て)
|
| グループを回に一括割り当て | 回内の「グループを追加...」ドロップダウン | グループの全圃場を一括割り当て |
|
||||||
3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし)
|
| グループを別の回に移動 | グループ★行の「移動...」ドロップダウン | グループの全圃場を回の間で一括移動 |
|
||||||
|
| グループを未割り当てに戻す | グループ★行の「移動...」→「未割り当てに戻す」 | グループの全圃場を一括で未割り当てに戻す |
|
||||||
|
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て全圃場を新しい回に追加 |
|
||||||
|
| 回の追加 | 「+回を追加」ボタン | 空の回を追加 |
|
||||||
|
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
|
||||||
|
| 回の日付設定 | 日付入力フィールド | デフォルトは1回目の日付 |
|
||||||
|
| 対象肥料の絞り込み | チェックボックス | 選択した肥料だけ表示 |
|
||||||
|
|
||||||
|
#### 積載合計のリアルタイム表示
|
||||||
|
|
||||||
|
各回のヘッダーに、その回の肥料ごとの合計袋数と総袋数を表示。
|
||||||
|
圃場を追加・削除するたびに即時再計算(サーバー通信なし)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PDF 出力
|
## PDF 出力
|
||||||
|
|
||||||
`GET /api/fertilizer/distribution/{id}/pdf/`
|
`GET /api/fertilizer/delivery/{id}/pdf/`
|
||||||
|
|
||||||
- WeasyPrint(既存施肥計画PDFと同じ仕組み)
|
### フォーマット
|
||||||
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html`
|
|
||||||
- フォーマット: A4横向き
|
- WeasyPrint、A4横向き
|
||||||
- 内容:
|
- **回ごとに1ページ**(1回目=1ページ目、2回目=2ページ目...)
|
||||||
- ★グループ合計行(太字・緑背景)
|
|
||||||
- 圃場サブ行(小フォント・灰色背景)
|
### 各ページの内容
|
||||||
- 肥料列合計・総合計
|
|
||||||
- ファイル名: `distribution_{year}_{plan_id}.pdf`
|
```
|
||||||
|
━━━ 2回目 2026-03-16 ━━━━━━━━━━━━━━━
|
||||||
|
電気炉さい ミネラルホウ素
|
||||||
|
★ キウイ 4 5
|
||||||
|
圃場A 2 3
|
||||||
|
圃場B 2 2
|
||||||
|
★ 池田さんちの前 2 2
|
||||||
|
圃場C 2 2
|
||||||
|
★ 足川北 12 6
|
||||||
|
圃場D 4 2
|
||||||
|
圃場E 4 2
|
||||||
|
圃場F 4 2
|
||||||
|
★ 出祥邸 - 8
|
||||||
|
圃場G - 4
|
||||||
|
圃場H - 4
|
||||||
|
─────────────────────────────────────
|
||||||
|
合計 18 21
|
||||||
|
```
|
||||||
|
|
||||||
|
- ★行: グループ小計(肥料ごと)、太字・緑背景
|
||||||
|
- 圃場行: 各圃場の肥料ごとの袋数(**合計列なし**)
|
||||||
|
- 最下行: 回全体の肥料ごと合計
|
||||||
|
- 日付を各ページのヘッダーに記載
|
||||||
|
- ファイル名: `delivery_{year}_{plan_id}.pdf`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ファイル構成
|
## ファイル構成(予定)
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/apps/fertilizer/
|
backend/apps/fertilizer/
|
||||||
├── models.py # DistributionPlan/Group/GroupField 追加(migration 0003)
|
├── models.py # DeliveryPlan/Group/GroupField/Trip/TripItem
|
||||||
├── serializers.py # Distribution* シリアライザ追加
|
├── serializers.py # Delivery* シリアライザ
|
||||||
├── views.py # DistributionPlanViewSet 追加
|
├── views.py # DeliveryPlanViewSet
|
||||||
├── urls.py # router.register('distribution', ...) 追加
|
├── urls.py # router.register('delivery', ...)
|
||||||
├── admin.py # DistributionPlan/Group の admin 登録
|
├── admin.py # DeliveryPlan 等の admin 登録
|
||||||
|
├── migrations/
|
||||||
|
│ └── 000X_delivery_*.py # 旧Distribution → 新Delivery マイグレーション
|
||||||
└── templates/fertilizer/
|
└── templates/fertilizer/
|
||||||
└── distribution_pdf.html # A4横 PDF テンプレート
|
└── delivery_pdf.html # 回ごと1ページ PDF テンプレート
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@@ -215,27 +359,54 @@ frontend/src/app/distribution/
|
|||||||
├── page.tsx # 一覧ページ
|
├── page.tsx # 一覧ページ
|
||||||
├── new/page.tsx # 新規作成(ラッパー)
|
├── new/page.tsx # 新規作成(ラッパー)
|
||||||
├── [id]/edit/page.tsx # 編集(ラッパー)
|
├── [id]/edit/page.tsx # 編集(ラッパー)
|
||||||
└── _components/DistributionEditPage.tsx # 編集共通コンポーネント
|
└── _components/DeliveryEditPage.tsx # 編集共通コンポーネント
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## マイグレーション方針
|
||||||
|
|
||||||
|
### 旧モデル(Distribution*)の扱い
|
||||||
|
|
||||||
|
1. 新モデル(Delivery*)を追加するマイグレーションを作成
|
||||||
|
2. 旧モデル(Distribution*)は削除マイグレーションで除去
|
||||||
|
3. 旧データは少量のため、データ移行は行わない(手動で再作成)
|
||||||
|
|
||||||
|
### マイグレーション順序
|
||||||
|
|
||||||
|
1. `000X_add_delivery_models.py` - DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem を追加
|
||||||
|
2. `000Y_remove_distribution_models.py` - DistributionPlan, DistributionGroup, DistributionGroupField を削除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 注意点
|
## 注意点
|
||||||
|
|
||||||
### 集計は全クライアントサイド計算
|
### 施肥計画との関係
|
||||||
|
|
||||||
集計プレビューは API を呼ばず、`fertPlanDetail.entries` と `groups.fieldIds` からクライアントで計算する。
|
- 運搬計画は施肥計画への直接FKを持たない
|
||||||
|
- 年度ベースで、その年度の全 FertilizationEntry を参照して圃場×肥料の袋数を取得する
|
||||||
|
- 施肥計画を変更すると、未割り当ての圃場の袋数は自動で反映される
|
||||||
|
- 既に運搬回に割り当て済みの DeliveryTripItem.bags は変わらない(コピー済み)
|
||||||
|
|
||||||
|
### 集計はクライアントサイド計算
|
||||||
|
|
||||||
|
画面上の集計(グループ小計・回の積載合計)は API を呼ばずクライアントで計算。
|
||||||
PDF生成時のみサーバーサイドで同じ計算を実施。
|
PDF生成時のみサーバーサイドで同じ計算を実施。
|
||||||
|
|
||||||
### PUT の全置換方式
|
|
||||||
|
|
||||||
PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。
|
|
||||||
|
|
||||||
### 未割り当て圃場の扱い
|
|
||||||
|
|
||||||
- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示
|
|
||||||
- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし)
|
|
||||||
|
|
||||||
### エラー表示方針
|
### エラー表示方針
|
||||||
|
|
||||||
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
|
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
|
||||||
|
|
||||||
|
### 散布実績との連携
|
||||||
|
|
||||||
|
- 運搬計画の `DeliveryTripItem` が散布実績画面(`/fertilizer/spreading`)の候補データソースとなる
|
||||||
|
- `DeliveryTrip.date != null` の明細のみを「運搬済み」とみなし、散布候補に含める
|
||||||
|
- 散布実績画面から運搬計画を指定して遷移する場合(`?delivery_plan_id=N`)、日付フィルタは適用されない(その計画の全明細が候補になる)
|
||||||
|
- 散布実績の保存時に在庫 `USE` が作成される(運搬時点では在庫変動なし)
|
||||||
|
|
||||||
|
### WorkRecord 自動生成
|
||||||
|
|
||||||
|
- `DeliveryTrip` に日付が保存されると、`WorkRecord`(`work_type=fertilizer_delivery`)が自動生成される
|
||||||
|
- 実装: `apps/workrecords/services.py` の `sync_delivery_work_record()`
|
||||||
|
- `DeliveryTrip` の日付が削除されると、対応する `WorkRecord` も削除される
|
||||||
|
- `WorkRecord` は索引として機能し、明細データは `DeliveryTrip` / `DeliveryTripItem` 側が保持する
|
||||||
|
|||||||
557
document/15_マスタードキュメント_畔塗作業編.md
Normal file
557
document/15_マスタードキュメント_畔塗作業編.md
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
# マスタードキュメント:畔塗作業機能
|
||||||
|
|
||||||
|
> **作成**: 2026-04-04
|
||||||
|
> **最終更新**: 2026-04-04
|
||||||
|
> **対象機能**: 畔塗作業記録(日付単位の圃場選択・作業記録索引連携)
|
||||||
|
> **実装状況**: 実装予定(仕様策定版)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
農業生産者が、水稲作付け圃場に対して実施した「畔塗」作業を日付単位で記録する機能。
|
||||||
|
対象圃場をまとめて選択し、保存時に作業記録一覧へ自動反映する。
|
||||||
|
|
||||||
|
本機能は、施肥計画の散布実績と同様に
|
||||||
|
「作業本体を専用テーブルで持ち、作業記録一覧には索引を自動生成する」
|
||||||
|
という設計方針を採用する。
|
||||||
|
|
||||||
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
|
| IN(本機能で扱う) | OUT(本機能では扱わない) |
|
||||||
|
|---|---|
|
||||||
|
| 畔塗日単位の記録作成 | 畔塗作業の工程管理 |
|
||||||
|
| 水稲作付け圃場の候補抽出 | 作業者別の工数集計 |
|
||||||
|
| 複数圃場の一括選択・保存 | 機械・資材の在庫管理 |
|
||||||
|
| 作業記録一覧(WorkRecord)への自動反映 | 写真添付 |
|
||||||
|
| 畔塗記録の編集・削除 | GPS軌跡連携 |
|
||||||
|
| 対象圃場一覧の参照画面 | 汎用作業日誌への完全統合 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景と目的
|
||||||
|
|
||||||
|
現状システムには、運搬や肥料散布のような作業実績を日付順に参照する仕組みがあるが、
|
||||||
|
春作業の一つである畔塗については記録先が存在しない。
|
||||||
|
|
||||||
|
畔塗は次の特徴を持つ。
|
||||||
|
|
||||||
|
- 1日で複数圃場をまとめて実施することが多い
|
||||||
|
- 対象圃場は当年の作付け計画と密接に関係する
|
||||||
|
- 後から「いつ、どの圃場を畔塗したか」を一覧で見返したい
|
||||||
|
|
||||||
|
そのため、圃場ごとに単発レコードを大量に作るのではなく、
|
||||||
|
`1日 = 1件の畔塗記録` とし、対象圃場を明細としてぶら下げる構成とする。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## データモデル
|
||||||
|
|
||||||
|
### LeveeWorkSession(畔塗記録本体)
|
||||||
|
|
||||||
|
日付単位の畔塗作業記録。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| year | int | required | 年度フィルタ用。既存機能に合わせて暦年を保持し、原則 `date.year` と一致させる |
|
||||||
|
| date | DateField | required | 畔塗日 |
|
||||||
|
| title | varchar(100) | required, default=`水稲畔塗` | 一覧表示タイトル。未指定時はサーバー側で `水稲畔塗` を補完する |
|
||||||
|
| notes | text | blank | 備考 |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
- `year + date` の一意制約は付けない
|
||||||
|
- 同日に午前・午後や地区別で複数記録を持てるようにする
|
||||||
|
|
||||||
|
### LeveeWorkSessionItem(畔塗対象圃場明細)
|
||||||
|
|
||||||
|
畔塗記録に紐づく対象圃場一覧。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| session | FK(LeveeWorkSession) | CASCADE | 親の畔塗記録 |
|
||||||
|
| field | FK(fields.Field) | PROTECT | 対象圃場 |
|
||||||
|
| plan | FK(plans.Plan) | SET_NULL, nullable | 保存時点の作付け計画参照 |
|
||||||
|
| crop_name_snapshot | varchar(100) | required | 保存時点の作物名 |
|
||||||
|
| variety_name_snapshot | varchar(100) | blank | 保存時点の品種名 |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
- `unique_together = ['session', 'field']`
|
||||||
|
- 圃場名そのものは `Field` を参照して表示する
|
||||||
|
- 作物・品種は履歴保全のためスナップショット保持を推奨する
|
||||||
|
|
||||||
|
### WorkRecord(作業記録索引)
|
||||||
|
|
||||||
|
既存 `apps/workrecords` の `WorkRecord` に畔塗種別を追加して連携する。
|
||||||
|
|
||||||
|
追加内容:
|
||||||
|
|
||||||
|
- `work_type` に `levee_work` を追加
|
||||||
|
- `levee_work_session` への `OneToOne FK('levee_work.LeveeWorkSession')` を追加
|
||||||
|
|
||||||
|
想定制約:
|
||||||
|
|
||||||
|
- `on_delete=models.CASCADE`
|
||||||
|
- `null=True`
|
||||||
|
- `blank=True`
|
||||||
|
- `related_name='work_record'`
|
||||||
|
|
||||||
|
削除方針:
|
||||||
|
|
||||||
|
- 親である `LeveeWorkSession` 削除時に、関連する `WorkRecord` は DB 制約の `CASCADE` で自動削除する
|
||||||
|
- アプリケーション側での「紐づく WorkRecord を削除する」は、この DB 制約により満たされるものとして扱う
|
||||||
|
|
||||||
|
一覧表示時の想定値:
|
||||||
|
|
||||||
|
| 項目 | 値 |
|
||||||
|
|---|---|
|
||||||
|
| 作業日 | 畔塗記録の日付 |
|
||||||
|
| 種別 | 畔塗 |
|
||||||
|
| タイトル | 水稲畔塗 |
|
||||||
|
| 参照先 | 畔塗した圃場一覧画面 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 候補圃場抽出ルール
|
||||||
|
|
||||||
|
畔塗対象候補は、作付け計画 `Plan` から抽出する。
|
||||||
|
|
||||||
|
### 基本条件
|
||||||
|
|
||||||
|
- 指定年度の `Plan` であること
|
||||||
|
- `crop.name = "水稲"` の圃場であること
|
||||||
|
- 圃場が存在すること
|
||||||
|
|
||||||
|
### 補足
|
||||||
|
|
||||||
|
- 判定条件は「品種が水稲」ではなく、原則として「作物が水稲」とする
|
||||||
|
- `variety` は任意項目のため、品種未設定でも `crop=水稲` なら候補に含める
|
||||||
|
- 並び順は `field.display_order`, `field.id`
|
||||||
|
|
||||||
|
### 候補レスポンスで返したい情報
|
||||||
|
|
||||||
|
| 項目 | 説明 |
|
||||||
|
|---|---|
|
||||||
|
| field_id | 圃場ID |
|
||||||
|
| field_name | 圃場名 |
|
||||||
|
| field_area_tan | 面積(反) |
|
||||||
|
| group_name | グループ名 |
|
||||||
|
| plan_id | 対応する作付け計画ID |
|
||||||
|
| crop_name | 作物名 |
|
||||||
|
| variety_name | 品種名 |
|
||||||
|
| selected | 初期選択状態。候補圃場は原則 `true` を返し、全選択をデフォルトとする |
|
||||||
|
|
||||||
|
### 初期選択ルール
|
||||||
|
|
||||||
|
- 候補として返す水稲圃場は、原則すべて `selected=true` とする
|
||||||
|
- 品種未設定の水稲圃場も `selected=true` とする
|
||||||
|
- UI 上のチェック解除は、ユーザーが今回畔塗しない圃場を明示的に外すための操作と位置づける
|
||||||
|
- 先行イメージ図にあった `☐ 山の前` は例示上の表現であり、初期ルールそのものではない
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 画面仕様
|
||||||
|
|
||||||
|
### 画面の位置づけ
|
||||||
|
|
||||||
|
畔塗機能は、日付を先に決めて対象圃場を選ぶ「日報型UI」とする。
|
||||||
|
圃場ごとの個別登録画面ではなく、1回の保存で複数圃場をまとめて記録する。
|
||||||
|
|
||||||
|
### 主要画面
|
||||||
|
|
||||||
|
#### 1. 畔塗記録一覧画面
|
||||||
|
|
||||||
|
目的:
|
||||||
|
|
||||||
|
- 年度内の畔塗記録を一覧する
|
||||||
|
- 新規作成画面へ遷移する
|
||||||
|
- 既存記録の編集・削除を行う
|
||||||
|
|
||||||
|
表示項目:
|
||||||
|
|
||||||
|
- 畔塗日
|
||||||
|
- タイトル
|
||||||
|
- 対象圃場数
|
||||||
|
- 対象圃場名の要約
|
||||||
|
- 備考
|
||||||
|
|
||||||
|
#### 2. 畔塗記録作成・編集画面
|
||||||
|
|
||||||
|
入力項目:
|
||||||
|
|
||||||
|
- 日付
|
||||||
|
- タイトル
|
||||||
|
- 備考
|
||||||
|
- 対象圃場一覧
|
||||||
|
|
||||||
|
対象圃場一覧の表示項目:
|
||||||
|
|
||||||
|
- 選択チェック
|
||||||
|
- 圃場名
|
||||||
|
- 面積
|
||||||
|
- グループ
|
||||||
|
- 作物
|
||||||
|
- 品種
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
- 全選択
|
||||||
|
- 全解除
|
||||||
|
- 個別選択
|
||||||
|
- 保存
|
||||||
|
|
||||||
|
初期表示ルール:
|
||||||
|
|
||||||
|
- 初回表示時は候補圃場を全選択状態で表示する
|
||||||
|
- 編集時は保存済み明細に含まれる圃場を選択状態で復元する
|
||||||
|
|
||||||
|
### 推奨UIイメージ
|
||||||
|
|
||||||
|
```text
|
||||||
|
畔塗記録作成
|
||||||
|
|
||||||
|
[日付 2026-04-20]
|
||||||
|
[タイトル 水稲畔塗]
|
||||||
|
[備考 __________________ ]
|
||||||
|
|
||||||
|
対象圃場一覧
|
||||||
|
[全選択] [全解除]
|
||||||
|
|
||||||
|
☑ 田中上 1.2反 上エリア 水稲 コシヒカリ
|
||||||
|
☑ 田中下 0.8反 上エリア 水稲 あきたこまち
|
||||||
|
☐ 山の前 1.5反 南エリア 水稲 (未設定)
|
||||||
|
|
||||||
|
[保存]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 作業記録一覧への見え方
|
||||||
|
|
||||||
|
既存の作業記録一覧には次の形式で表示する。
|
||||||
|
|
||||||
|
| 列 | 表示内容 |
|
||||||
|
|---|---|
|
||||||
|
| 作業日 | 指定した日付 |
|
||||||
|
| 種別 | 畔塗 |
|
||||||
|
| タイトル | 水稲畔塗 |
|
||||||
|
| 参照先 | 畔塗記録 #ID または対象圃場要約 |
|
||||||
|
| 開く | 畔塗記録詳細画面へ遷移 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API エンドポイント
|
||||||
|
|
||||||
|
すべて JWT 認証必須。
|
||||||
|
|
||||||
|
### 畔塗記録
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/levee-work/sessions/?year={year}` | 年度別一覧 |
|
||||||
|
| POST | `/api/levee-work/sessions/` | 新規作成 |
|
||||||
|
| GET | `/api/levee-work/sessions/{id}/` | 詳細取得 |
|
||||||
|
| PUT/PATCH | `/api/levee-work/sessions/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/levee-work/sessions/{id}/` | 削除 |
|
||||||
|
|
||||||
|
### 候補圃場取得
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/levee-work/candidates/?year={year}` | 水稲作付け圃場候補を返す |
|
||||||
|
|
||||||
|
### レスポンス例(候補圃場)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field_id": 5,
|
||||||
|
"field_name": "田中上",
|
||||||
|
"field_area_tan": "1.2000",
|
||||||
|
"group_name": "上エリア",
|
||||||
|
"plan_id": 12,
|
||||||
|
"crop_name": "水稲",
|
||||||
|
"variety_name": "コシヒカリ",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### リクエスト例(新規作成)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"date": "2026-04-20",
|
||||||
|
"title": "水稲畔塗",
|
||||||
|
"notes": "",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"field": 5,
|
||||||
|
"plan": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": 6,
|
||||||
|
"plan": 13
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
備考:
|
||||||
|
|
||||||
|
- `crop_name_snapshot` / `variety_name_snapshot` はクライアント送信項目ではない
|
||||||
|
- サーバーが `plan` と `field` の整合を検証したうえで、保存時に `Plan` から自動設定する
|
||||||
|
- `plan` が `null` の場合は、保存時点で参照できる `field` に対応する当年 `Plan` から補完を試みる
|
||||||
|
|
||||||
|
### レスポンス例(詳細)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"year": 2026,
|
||||||
|
"date": "2026-04-20",
|
||||||
|
"title": "水稲畔塗",
|
||||||
|
"notes": "",
|
||||||
|
"work_record_id": 15,
|
||||||
|
"item_count": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"field": 5,
|
||||||
|
"field_name": "田中上",
|
||||||
|
"plan": 12,
|
||||||
|
"crop_name_snapshot": "水稲",
|
||||||
|
"variety_name_snapshot": "コシヒカリ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"field": 6,
|
||||||
|
"field_name": "田中下",
|
||||||
|
"plan": 13,
|
||||||
|
"crop_name_snapshot": "水稲",
|
||||||
|
"variety_name_snapshot": "あきたこまち"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-04-20T08:00:00Z",
|
||||||
|
"updated_at": "2026-04-20T08:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 業務フロー
|
||||||
|
|
||||||
|
### 1. 新規作成
|
||||||
|
|
||||||
|
1. ユーザーが年度と日付を選ぶ
|
||||||
|
2. システムが当年の水稲作付け圃場を候補表示する
|
||||||
|
3. ユーザーが対象圃場を選択する
|
||||||
|
4. 保存時に `LeveeWorkSession` を作成する
|
||||||
|
5. 明細として `LeveeWorkSessionItem` を一括作成する
|
||||||
|
6. 各明細の `crop_name_snapshot` / `variety_name_snapshot` をサーバー側で自動設定する
|
||||||
|
7. `WorkRecord` を自動生成または更新する
|
||||||
|
|
||||||
|
### 2. 編集
|
||||||
|
|
||||||
|
1. ユーザーが既存の畔塗記録を開く
|
||||||
|
2. 日付・タイトル・備考・対象圃場を変更する
|
||||||
|
3. 保存時に明細を再構成する
|
||||||
|
4. `WorkRecord` 側の作業日・タイトルも同期更新する
|
||||||
|
5. 明細のスナップショットも保存時点情報で再構成する
|
||||||
|
|
||||||
|
### 3. 削除
|
||||||
|
|
||||||
|
1. ユーザーが畔塗記録を削除する
|
||||||
|
2. 紐づく `LeveeWorkSessionItem` は `CASCADE` で削除される
|
||||||
|
3. 紐づく `WorkRecord` は `levee_work_session` の `on_delete=CASCADE` により削除される
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 作業記録連携仕様
|
||||||
|
|
||||||
|
畔塗記録保存時に `apps/workrecords` 側へ自動反映する。
|
||||||
|
|
||||||
|
### 追加する種別
|
||||||
|
|
||||||
|
| enum値 | 表示名 |
|
||||||
|
|---|---|
|
||||||
|
| `levee_work` | 畔塗 |
|
||||||
|
|
||||||
|
### 自動生成ルール
|
||||||
|
|
||||||
|
- `work_date` = `session.date`
|
||||||
|
- `work_type` = `levee_work`
|
||||||
|
- `title` = `session.title`
|
||||||
|
- `year` = `session.year`
|
||||||
|
- `auto_created` = `True`
|
||||||
|
- `levee_work_session` = 対応する畔塗記録
|
||||||
|
- `delivery_trip` = `None`
|
||||||
|
- `spreading_session` = `None`
|
||||||
|
|
||||||
|
実装メモ:
|
||||||
|
|
||||||
|
- 既存の `sync_spreading_work_record()` と同様に、`update_or_create()` の `defaults` 内で他系統 FK を明示的に `None` へそろえる
|
||||||
|
- `title` の未入力は `LeveeWorkSession` 保存時にサーバー側で `水稲畔塗` を補完するため、同期処理では補完済みの `session.title` をそのまま使う
|
||||||
|
|
||||||
|
### 同期タイミング
|
||||||
|
|
||||||
|
- 畔塗記録作成時: `update_or_create`
|
||||||
|
- 畔塗記録更新時: `update_or_create`
|
||||||
|
- 畔塗記録削除時: `levee_work_session` の `on_delete=CASCADE` により `WorkRecord` も自動削除される
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## バリデーションルール
|
||||||
|
|
||||||
|
### 必須
|
||||||
|
|
||||||
|
- `year`
|
||||||
|
- `date`
|
||||||
|
- `items`(1件以上)
|
||||||
|
|
||||||
|
### 保存時チェック
|
||||||
|
|
||||||
|
- 選択圃場が0件の保存を禁止する
|
||||||
|
- 同一セッション内で同じ圃場を重複登録しない
|
||||||
|
- 候補外圃場の保存を原則禁止する
|
||||||
|
- `year` は原則 `date.year` と一致しなければならない
|
||||||
|
- `plan` が指定されている場合、その `plan.field` と `field` は一致しなければならない
|
||||||
|
- `plan` が指定されている場合、その `plan.year` は `session.year` と一致しなければならない
|
||||||
|
|
||||||
|
### 業務上の許容
|
||||||
|
|
||||||
|
- 品種未設定の水稲圃場は保存可
|
||||||
|
- 同日に別記録を複数作ることは可
|
||||||
|
- 一度畔塗した圃場を別日に再度記録することは可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 実装方針
|
||||||
|
|
||||||
|
### バックエンド
|
||||||
|
|
||||||
|
- 新規アプリ `apps/levee_work` を追加する案を第一候補とする
|
||||||
|
- `Session` / `SessionItem` 構成でモデル化する
|
||||||
|
- Serializer は `read` と `write` を分離する
|
||||||
|
- 候補取得 API は `Plan` を起点に組み立てる
|
||||||
|
- `sync_levee_work_record(session)` を作成して `WorkRecord` と同期する
|
||||||
|
- `WorkRecord` から `LeveeWorkSession` への参照は、アプリ間循環参照を避けるため文字列参照 `OneToOneField('levee_work.LeveeWorkSession', ...)` を使う
|
||||||
|
|
||||||
|
### フロントエンド
|
||||||
|
|
||||||
|
- 画面候補: `frontend/src/app/levee-work/page.tsx`
|
||||||
|
- 1画面完結の一覧 + 作成/編集パネル、または一覧画面 + 詳細画面のどちらでも可
|
||||||
|
- 既存の `fertilizer/spreading` の「一覧 + 編集」導線を参考にする
|
||||||
|
- `workrecords/page.tsx` に遷移先判定を追加する
|
||||||
|
|
||||||
|
### 命名方針
|
||||||
|
|
||||||
|
- ユーザー向け表示は「畔塗」で統一
|
||||||
|
- コード上の英語名は `levee_work` または `levee_coating` が候補
|
||||||
|
- 既存の `WorkRecord.WorkType` に追加する値は、短く意味がぶれない `levee_work` を推奨する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 画面遷移案
|
||||||
|
|
||||||
|
```text
|
||||||
|
作業記録一覧
|
||||||
|
└─ 畔塗レコードの「開く」
|
||||||
|
└─ 畔塗記録画面(該当セッションを編集状態で開く)
|
||||||
|
|
||||||
|
畔塗記録画面
|
||||||
|
├─ 新規作成
|
||||||
|
├─ 既存記録の編集
|
||||||
|
└─ 保存後、作業記録一覧に反映
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 将来拡張
|
||||||
|
|
||||||
|
- 作業者名の保持
|
||||||
|
- 使用機械の記録
|
||||||
|
- 実施済み圃場を地図で確認
|
||||||
|
- 写真添付
|
||||||
|
- 代かき、耕起、播種など他作業への横展開
|
||||||
|
- 汎用作業日誌基盤への統合
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 実装タスク案
|
||||||
|
|
||||||
|
1. `apps/levee_work` アプリ新設
|
||||||
|
2. `LeveeWorkSession` / `LeveeWorkSessionItem` モデル追加
|
||||||
|
3. migration 作成
|
||||||
|
4. serializer / view / url 実装
|
||||||
|
5. 候補圃場 API 実装
|
||||||
|
6. `WorkRecord` に畔塗種別と参照FK追加
|
||||||
|
7. `sync_levee_work_record` サービス実装
|
||||||
|
8. フロントエンド一覧・作成画面実装
|
||||||
|
9. 作業記録一覧の遷移先対応
|
||||||
|
10. テスト追加
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意点と設計判断
|
||||||
|
|
||||||
|
### なぜ「圃場ごと1件」ではなく「日付ごと1件」か
|
||||||
|
|
||||||
|
- 実際の作業単位が日付ベースである
|
||||||
|
- 一覧が見やすい
|
||||||
|
- 既存の散布実績機能と整合する
|
||||||
|
- 作業記録索引との親和性が高い
|
||||||
|
|
||||||
|
### なぜ作付け計画を参照するか
|
||||||
|
|
||||||
|
- 水稲圃場だけを自然に抽出できる
|
||||||
|
- 年度との整合が取りやすい
|
||||||
|
- 将来「未畔塗候補」や「前年比較」に発展させやすい
|
||||||
|
|
||||||
|
### スナップショットを持つ理由
|
||||||
|
|
||||||
|
- 後から作付け計画が変更されても、記録時点の情報を追える
|
||||||
|
- 作業記録としての監査性を保ちやすい
|
||||||
|
|
||||||
|
### なぜ snapshot をクライアント入力にしないか
|
||||||
|
|
||||||
|
- `plan` と `field` からサーバーが一意に導出できる情報だから
|
||||||
|
- クライアント送信にすると改ざんや不整合の余地が増えるから
|
||||||
|
- API 入力を最小限に保った方が UI 実装が単純になるから
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ソースファイル追加想定
|
||||||
|
|
||||||
|
### バックエンド
|
||||||
|
|
||||||
|
- `backend/apps/levee_work/models.py`
|
||||||
|
- `backend/apps/levee_work/serializers.py`
|
||||||
|
- `backend/apps/levee_work/views.py`
|
||||||
|
- `backend/apps/levee_work/urls.py`
|
||||||
|
- `backend/apps/levee_work/admin.py`
|
||||||
|
- `backend/apps/levee_work/migrations/0001_initial.py`
|
||||||
|
- `backend/apps/workrecords/models.py`
|
||||||
|
- `backend/apps/workrecords/services.py`
|
||||||
|
- `backend/apps/workrecords/serializers.py`
|
||||||
|
- `backend/apps/workrecords/views.py`
|
||||||
|
- `backend/keinasystem/urls.py`
|
||||||
|
|
||||||
|
### フロントエンド
|
||||||
|
|
||||||
|
- `frontend/src/app/levee-work/page.tsx`
|
||||||
|
- `frontend/src/types/index.ts`
|
||||||
|
- `frontend/src/app/workrecords/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## まとめ
|
||||||
|
|
||||||
|
畔塗作業機能は、
|
||||||
|
「当年の水稲作付け圃場を候補として出し、日付単位で複数圃場をまとめて記録し、作業記録一覧へ自動反映する」
|
||||||
|
というシンプルな構成を基本とする。
|
||||||
|
|
||||||
|
この構成により、既存の作付け計画・作業記録の設計を壊さずに、
|
||||||
|
春作業の記録を自然に追加できる。
|
||||||
316
document/16_マスタードキュメント_田植え計画編.md
Normal file
316
document/16_マスタードキュメント_田植え計画編.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# マスタードキュメント:田植え計画機能
|
||||||
|
|
||||||
|
> **作成**: 2026-04-04
|
||||||
|
> **最終更新**: 2026-04-05
|
||||||
|
> **対象機能**: 田植え計画(年度・種子資材を軸に複数回作成できる苗箱・種もみ使用量計画)
|
||||||
|
> **実装状況**: MVP実装完了
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
農業生産者が「年度 × 種子資材」を軸に、田植え前の播種・育苗準備量を見積もる機能。
|
||||||
|
各圃場について「実際に使う苗箱数」を記録し、計画全体で必要な種もみ量を自動集計する。
|
||||||
|
|
||||||
|
圃場候補は既存の作付け計画から自動取得し、選択した種子資材に紐づく品種を内部的に参照して候補圃場を決める。種もみ在庫は種子資材単位、反当苗箱枚数の初期値は紐づく品種単位で管理する。
|
||||||
|
同じ年度・同じ種子資材でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
|
||||||
|
|
||||||
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
|
| IN(実装済み) | OUT(対象外) |
|
||||||
|
|---|---|
|
||||||
|
| 田植え計画の作成・編集・削除 | 育苗日程のカレンダー管理 |
|
||||||
|
| 作付け計画からの候補圃場自動取得 | 実播種実績の記録 |
|
||||||
|
| 圃場ごとの苗箱数の個別調整 | 種もみロット管理 |
|
||||||
|
| 列単位のデフォルト反映・四捨五入 | 在庫の自動引当 |
|
||||||
|
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
|
||||||
|
| 種子資材ごとの種もみ在庫参照 | 品種ごとの播種日管理 |
|
||||||
|
| 品種ごとの反当苗箱枚数デフォルト管理 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 業務ルール
|
||||||
|
|
||||||
|
1. 田植え計画は `年度 × 種子資材` を軸に作成する
|
||||||
|
2. 対象圃場は、選択した種子資材に紐づく品種の作付け計画が登録されている圃場から取得する
|
||||||
|
3. 種もみ在庫は種子資材単位で管理する
|
||||||
|
4. 反当苗箱枚数の初期値は、種子資材に紐づく品種単位で管理する
|
||||||
|
5. 計画ヘッダ側に `反当苗箱枚数` を持ち、施肥計画の `反当袋数` と同じ役割で使う
|
||||||
|
6. 画面上では `反当苗箱枚数 × 面積(反)` を各圃場のデフォルト苗箱数として表示する
|
||||||
|
7. 実際に保存するのは圃場ごとの `苗箱数` であり、手動調整後の値をそのまま保持する
|
||||||
|
8. `種もみg/箱` は計画全体の共通パラメータとして扱い、圃場ごとには持たない
|
||||||
|
9. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
|
||||||
|
10. 同じ年度・同じ種子資材で複数の計画を作成してよい
|
||||||
|
11. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 計算式
|
||||||
|
|
||||||
|
### 圃場ごとのデフォルト苗箱数
|
||||||
|
|
||||||
|
`デフォルト苗箱数 = 圃場面積(反) × 反当苗箱枚数`
|
||||||
|
|
||||||
|
### 圃場ごとの種もみ使用量
|
||||||
|
|
||||||
|
`種もみkg = 苗箱数合計 × 苗箱1枚あたり種もみ(g) ÷ 1000`
|
||||||
|
|
||||||
|
### 計画全体の残在庫見込み
|
||||||
|
|
||||||
|
`残在庫見込み = 種子資材在庫(kg) - 計画全体の種もみkg合計`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## データモデル
|
||||||
|
|
||||||
|
### Variety(品種マスタ)
|
||||||
|
|
||||||
|
既存 `plans.Variety` に以下を追加・参照する。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| default_seedling_boxes_per_tan | decimal(6,2) | default=0 | 反当苗箱枚数の初期値 |
|
||||||
|
| seed_material | FK(materials.Material) 相当 | nullable | その品種に対応する種子在庫 |
|
||||||
|
|
||||||
|
### RiceTransplantPlan(田植え計画)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| name | varchar(200) | required | 計画名 |
|
||||||
|
| year | int | required | 年度 |
|
||||||
|
| variety | FK(plans.Variety) | PROTECT | 内部参照用の品種 |
|
||||||
|
| seedling_boxes_per_tan | decimal(6,2) | default=0 | 計画で使う反当苗箱枚数 |
|
||||||
|
| default_seed_grams_per_box | decimal(8,2) | default=0 | 苗箱1枚あたり種もみ(g)の初期値 |
|
||||||
|
| notes | text | blank | 備考 |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
- ユーザー操作上の主選択は `種子資材`
|
||||||
|
- 保存時には、選択した種子資材に紐づく `Variety` を内部的に参照して保持する
|
||||||
|
- `year + variety` の一意制約は持たない
|
||||||
|
- 同一年度・同一種子資材で複数レコード作成可能
|
||||||
|
|
||||||
|
#### 表示用計算項目(APIレスポンスに含まれる)
|
||||||
|
|
||||||
|
| 項目 | 型 | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| field_count | int | 対象圃場数 |
|
||||||
|
| total_seedling_boxes | decimal | 苗箱数合計 |
|
||||||
|
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
|
||||||
|
| variety_seed_inventory_kg | decimal | 種子資材在庫(kg) |
|
||||||
|
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
|
||||||
|
|
||||||
|
### RiceTransplantEntry(田植え計画エントリ)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| plan | FK(RiceTransplantPlan) | CASCADE | |
|
||||||
|
| field | FK(fields.Field) | CASCADE | |
|
||||||
|
| installed_seedling_boxes | decimal(8,2) | required | その圃場の苗箱数 |
|
||||||
|
|
||||||
|
- `unique_together = ['plan', 'field']`
|
||||||
|
- 順序: `field__display_order, field__id`
|
||||||
|
|
||||||
|
#### 表示用計算項目(entryレスポンスに含まれる)
|
||||||
|
|
||||||
|
| 項目 | 型 | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| field_name | string | 圃場名 |
|
||||||
|
| field_area_tan | decimal | 圃場面積(反) |
|
||||||
|
| default_seedling_boxes | decimal | `反当苗箱枚数 × 面積(反)` で求めたデフォルト候補 |
|
||||||
|
| planned_boxes | decimal | 圃場ごとの苗箱数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API エンドポイント
|
||||||
|
|
||||||
|
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
|
||||||
|
|
||||||
|
### 田植え計画
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/plans/rice-transplant-plans/?year={year}` | 年度別一覧 |
|
||||||
|
| POST | `/api/plans/rice-transplant-plans/` | 新規作成 |
|
||||||
|
| GET | `/api/plans/rice-transplant-plans/{id}/` | 詳細取得 |
|
||||||
|
| PUT/PATCH | `/api/plans/rice-transplant-plans/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/plans/rice-transplant-plans/{id}/` | 削除 |
|
||||||
|
| GET | `/api/plans/rice-transplant-plans/candidate_fields/?year={year}&variety_id={id}` | 内部参照した品種IDで候補圃場取得 |
|
||||||
|
|
||||||
|
一覧レスポンス例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "2026年度 にこまる種もみ 田植え計画",
|
||||||
|
"year": 2026,
|
||||||
|
"variety": 3,
|
||||||
|
"variety_name": "にこまる",
|
||||||
|
"crop_name": "水稲",
|
||||||
|
"seedling_boxes_per_tan": "12.00",
|
||||||
|
"default_seed_grams_per_box": "200.00",
|
||||||
|
"seed_material_name": "にこまる 種もみ",
|
||||||
|
"notes": "",
|
||||||
|
"field_count": 8,
|
||||||
|
"total_seedling_boxes": "98.40",
|
||||||
|
"total_seed_kg": "19.680",
|
||||||
|
"variety_seed_inventory_kg": "25.000",
|
||||||
|
"remaining_seed_kg": "5.320",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"field": 5,
|
||||||
|
"field_name": "田中上",
|
||||||
|
"field_area_tan": "1.2000",
|
||||||
|
"installed_seedling_boxes": "14.40",
|
||||||
|
"default_seedling_boxes": "14.40",
|
||||||
|
"planned_boxes": "14.40"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
POST/PUT リクエスト例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "2026年度 にこまる種もみ 田植え計画",
|
||||||
|
"year": 2026,
|
||||||
|
"variety": 3,
|
||||||
|
"seedling_boxes_per_tan": "12.00",
|
||||||
|
"default_seed_grams_per_box": "200.00",
|
||||||
|
"notes": "",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"field_id": 5,
|
||||||
|
"installed_seedling_boxes": "14.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_id": 6,
|
||||||
|
"installed_seedling_boxes": "13.80"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更新時は `entries` を全置換する。
|
||||||
|
|
||||||
|
### 品種マスタ更新 / 在庫管理
|
||||||
|
|
||||||
|
田植え計画に必要な既定値は既存 API で更新する。
|
||||||
|
|
||||||
|
| メソッド | URL | 更新項目 |
|
||||||
|
|---|---|---|
|
||||||
|
| PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` |
|
||||||
|
| PATCH | `/api/plans/varieties/{id}/` | `seed_material` または同等の種子在庫参照 |
|
||||||
|
| CRUD | `/api/materials/materials/?material_type=seed` | 種子在庫マスタ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 画面仕様
|
||||||
|
|
||||||
|
### 1. 田植え計画一覧 `/rice-transplant`
|
||||||
|
|
||||||
|
- 年度切替
|
||||||
|
- 田植え計画の一覧表示
|
||||||
|
- 同一年度・同一種子資材の計画が複数並ぶことを想定する
|
||||||
|
- 表示列:
|
||||||
|
- 計画名
|
||||||
|
- 種子資材
|
||||||
|
- 圃場数
|
||||||
|
- 苗箱合計
|
||||||
|
- 種もみ計画kg
|
||||||
|
- 残在庫見込みkg
|
||||||
|
- 行アクション:
|
||||||
|
- 編集
|
||||||
|
- 削除
|
||||||
|
|
||||||
|
### 2. 田植え計画編集 `/rice-transplant/new`, `/rice-transplant/{id}/edit`
|
||||||
|
|
||||||
|
- 基本情報:
|
||||||
|
- 計画名
|
||||||
|
- 同一年度・同一種子資材の複数計画を区別できる名称を付ける
|
||||||
|
- 例: `2026年度 にこまる種もみ 第1回`, `2026年度 にこまる種もみ 4/15播種分`
|
||||||
|
- 年度
|
||||||
|
- 種子資材
|
||||||
|
- 苗箱1枚あたり種もみ(g) デフォルト
|
||||||
|
- 備考
|
||||||
|
- 対象圃場:
|
||||||
|
- 種子資材選択後に、その資材に紐づく品種の作付け計画から候補圃場を自動取得
|
||||||
|
- 新規作成時は候補圃場を初期選択
|
||||||
|
- 圃場の追加・除外が可能
|
||||||
|
- 初期値:
|
||||||
|
- `反当苗箱枚数` は紐づく品種マスタの `default_seedling_boxes_per_tan` を初期値として表示
|
||||||
|
- `反当苗箱枚数 × 面積(反)` で各圃場のデフォルト苗箱数を求める
|
||||||
|
- `種もみg/箱` は計画全体の共通値
|
||||||
|
- 圃場テーブル:
|
||||||
|
- 圃場
|
||||||
|
- 面積(反)
|
||||||
|
- 小数は 2 桁表示を基本とする
|
||||||
|
- 苗箱数入力欄
|
||||||
|
- 左側にデフォルト苗箱数ラベルを表示
|
||||||
|
- 小数は 1 桁表示を基本とする
|
||||||
|
- 列操作:
|
||||||
|
- `反当苗箱枚数` の入力欄
|
||||||
|
- デフォルトを列単位で一括反映するボタン
|
||||||
|
- 列単位の四捨五入ボタン
|
||||||
|
- 施肥計画の四捨五入ボタンと同じ配置・2ステート動作
|
||||||
|
- サマリー:
|
||||||
|
- 対象圃場数
|
||||||
|
- 苗箱合計
|
||||||
|
- 種もみ計画kg
|
||||||
|
- 種子資材在庫kg
|
||||||
|
- 残在庫見込みkg
|
||||||
|
|
||||||
|
### 3. 品種管理モーダル `/allocation`
|
||||||
|
|
||||||
|
既存の作付け計画画面内の品種管理モーダルを拡張。
|
||||||
|
|
||||||
|
- 品種単位:
|
||||||
|
- 反当苗箱枚数デフォルトを更新可能
|
||||||
|
|
||||||
|
### 4. 資材マスタ `/materials/masters`
|
||||||
|
|
||||||
|
- 種子タブ:
|
||||||
|
- 種子資材を登録・編集できる
|
||||||
|
- 各種子資材に対応する品種を 1 件選んで紐付ける
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## バリデーション・運用ルール
|
||||||
|
|
||||||
|
1. 計画名は必須
|
||||||
|
2. 種子資材は必須
|
||||||
|
3. 圃場は1件以上必要
|
||||||
|
4. `installed_seedling_boxes` と `seedling_boxes_per_tan` は 0 以上の数値を想定
|
||||||
|
5. 在庫不足でも保存は許可し、UIで不足を可視化する
|
||||||
|
6. 候補圃場の抽出元は既存 `Plan`(作付け計画)であるため、先に作付け計画が必要
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 既知の制約
|
||||||
|
|
||||||
|
1. 田植え計画の PDF 出力は未実装
|
||||||
|
2. 実播種や田植え実績との連携は未実装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 関連ファイル
|
||||||
|
|
||||||
|
| 種別 | パス |
|
||||||
|
|---|---|
|
||||||
|
| モデル | `backend/apps/plans/models.py` |
|
||||||
|
| モデル | `backend/apps/materials/models.py` |
|
||||||
|
| シリアライザ | `backend/apps/plans/serializers.py` |
|
||||||
|
| シリアライザ | `backend/apps/materials/serializers.py` |
|
||||||
|
| ViewSet | `backend/apps/plans/views.py` |
|
||||||
|
| URL | `backend/apps/plans/urls.py` |
|
||||||
|
| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py`, `backend/apps/plans/migrations/0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes.py`, `backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py`, `backend/apps/plans/migrations/0008_variety_seed_material.py`, `backend/apps/plans/migrations/0009_alter_ricetransplantentry_installed_seedling_boxes.py`, `backend/apps/materials/migrations/0005_material_seed_type.py` |
|
||||||
|
| 一覧画面 | `frontend/src/app/rice-transplant/page.tsx` |
|
||||||
|
| 編集画面 | `frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx` |
|
||||||
|
| ナビゲーション | `frontend/src/components/Navbar.tsx` |
|
||||||
|
| 品種管理モーダル | `frontend/src/app/allocation/page.tsx` |
|
||||||
|
| 在庫画面 | `frontend/src/app/materials/page.tsx` |
|
||||||
|
| 資材マスタ | `frontend/src/app/materials/masters/page.tsx` |
|
||||||
298
document/17_マスタードキュメント_ナビゲーション再編編.md
Normal file
298
document/17_マスタードキュメント_ナビゲーション再編編.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# マスタードキュメント:ナビゲーション再編
|
||||||
|
|
||||||
|
> **作成**: 2026-04-07
|
||||||
|
> **最終更新**: 2026-04-07
|
||||||
|
> **対象機能**: グローバルナビゲーション再編(トップメニュー整理・カテゴリ再編・PC/スマホ共通情報設計)
|
||||||
|
> **実装状況**: 仕様策定完了・未実装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
機能追加に伴って共通ナビゲーションのトップレベル項目が増え、画面名ベースで並ぶ構造になってきたため、業務カテゴリ単位で再整理する。
|
||||||
|
|
||||||
|
今回の再編では、トップナビを `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類に絞り、個別画面はドロップダウン配下に集約する。
|
||||||
|
これにより、利用者が「どの画面名か」ではなく「何をしたいか」で画面を探せる状態を目指す。
|
||||||
|
|
||||||
|
また、URL 構造とメニュー構成は意図的に分離して扱う。既存 URL は安定性を優先して原則維持し、アクティブ判定はナビ定義側で吸収する。
|
||||||
|
|
||||||
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
|
| IN(今回対象) | OUT(今回対象外) |
|
||||||
|
|---|---|
|
||||||
|
| PC ヘッダーのトップメニュー再編 | 各業務画面自体のUI改修 |
|
||||||
|
| スマホ用ハンバーガーメニュー再編 | 権限別メニュー出し分け |
|
||||||
|
| メニュー分類、並び順、開閉仕様 | お気に入り、ピン留め |
|
||||||
|
| アクティブ判定ルール整理 | ダッシュボード内容の刷新 |
|
||||||
|
| `NavGroup` / `NavItem` ベースのメニュー定義整理 | URL の全面変更 |
|
||||||
|
| `作物` `品種` を将来のマスター画面として位置づけ | 矢印キー移動を含む高度なメニューアクセシビリティ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景と判断理由
|
||||||
|
|
||||||
|
### 現状の課題
|
||||||
|
|
||||||
|
- 横並びのトップメニュー数が多く、目的の画面を探しにくい
|
||||||
|
- `計画` `実績` `設定` `補助機能` が同じ粒度で並んでいる
|
||||||
|
- 画面名ベースで項目が増えており、業務単位でまとまっていない
|
||||||
|
- 今後も機能追加が続くと、視認性と拡張性の両方が悪化する
|
||||||
|
|
||||||
|
### 採用した考え方
|
||||||
|
|
||||||
|
1. トップレベルは日常的に使う業務カテゴリだけに絞る
|
||||||
|
2. 個別機能名ではなく、業務単位で束ねる
|
||||||
|
3. URL はリソース識別子として安定性を優先し、メニュー構成とは分離する
|
||||||
|
4. 例外的な URL 衝突のみナビ定義側のルールで吸収する
|
||||||
|
|
||||||
|
### 関連議論
|
||||||
|
|
||||||
|
- 判断理由、論点の切り分け、URL とメニューの関係整理は Gitea Issue `#13` に残す
|
||||||
|
- 実装向けの決定事項は `改善案/ナビゲーション再編仕様書.md` に集約する
|
||||||
|
- 本ドキュメントは、その内容を長期参照用に固定化したものとして扱う
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 情報設計
|
||||||
|
|
||||||
|
### トップレベル構成
|
||||||
|
|
||||||
|
1. ホーム
|
||||||
|
2. 計画
|
||||||
|
3. 実績
|
||||||
|
4. マスター
|
||||||
|
5. 帳票・連携
|
||||||
|
|
||||||
|
右上ユーザー操作:
|
||||||
|
|
||||||
|
- パスワード変更
|
||||||
|
- ログアウト
|
||||||
|
|
||||||
|
### カテゴリ構成
|
||||||
|
|
||||||
|
#### ホーム
|
||||||
|
|
||||||
|
- ダッシュボード
|
||||||
|
|
||||||
|
#### 計画
|
||||||
|
|
||||||
|
- 作付け計画
|
||||||
|
- 施肥計画
|
||||||
|
- 田植え計画
|
||||||
|
- 運搬計画
|
||||||
|
|
||||||
|
#### 実績
|
||||||
|
|
||||||
|
- 散布実績
|
||||||
|
- 畔塗記録
|
||||||
|
- 作業記録
|
||||||
|
|
||||||
|
#### マスター
|
||||||
|
|
||||||
|
- 圃場管理
|
||||||
|
- 作物
|
||||||
|
- 品種
|
||||||
|
- 資材マスタ
|
||||||
|
- 肥料マスタ
|
||||||
|
|
||||||
|
#### 帳票・連携
|
||||||
|
|
||||||
|
- 在庫管理
|
||||||
|
- 帳票出力
|
||||||
|
- データ取込
|
||||||
|
- 気象
|
||||||
|
- メール
|
||||||
|
|
||||||
|
### この分類にした理由
|
||||||
|
|
||||||
|
#### マスター
|
||||||
|
|
||||||
|
- `圃場管理` は圃場マスタとして独立性が高い
|
||||||
|
- `作物` `品種` も本来マスター管理である
|
||||||
|
- `資材マスタ` `肥料マスタ` はすでに独立画面が存在する
|
||||||
|
|
||||||
|
そのため、基礎データ管理を `マスター` に集約する。
|
||||||
|
|
||||||
|
#### 帳票・連携
|
||||||
|
|
||||||
|
- `在庫管理` `帳票出力` `データ取込` `気象` `メール` は完全に同質ではない
|
||||||
|
- ただし、いずれも主作業そのものではなく、補助・参照・出力・連携の性質が強い
|
||||||
|
|
||||||
|
そのため、トップ階層を増やしすぎないための受け皿として `帳票・連携` にまとめる。
|
||||||
|
|
||||||
|
補足:
|
||||||
|
|
||||||
|
- `データ取込` は日常操作ではなく、年度切替時や初期設定時の補助導線とみなす
|
||||||
|
- `メール` は個別トップにしない
|
||||||
|
- `設定` は現状パスワード変更のみなので、右上ユーザー操作に残す
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 画面と所属カテゴリ
|
||||||
|
|
||||||
|
| カテゴリ | ラベル | パス |
|
||||||
|
|---|---|---|
|
||||||
|
| ホーム | ダッシュボード | `/dashboard` |
|
||||||
|
| 計画 | 作付け計画 | `/allocation` |
|
||||||
|
| 計画 | 施肥計画 | `/fertilizer` |
|
||||||
|
| 計画 | 田植え計画 | `/rice-transplant` |
|
||||||
|
| 計画 | 運搬計画 | `/distribution` |
|
||||||
|
| 実績 | 散布実績 | `/fertilizer/spreading` |
|
||||||
|
| 実績 | 畔塗記録 | `/levee-work` |
|
||||||
|
| 実績 | 作業記録 | `/workrecords` |
|
||||||
|
| マスター | 圃場管理 | `/fields` |
|
||||||
|
| マスター | 作物 | 未実装(allocation 内管理を独立予定) |
|
||||||
|
| マスター | 品種 | 未実装(allocation 内管理を独立予定) |
|
||||||
|
| マスター | 資材マスタ | `/materials/masters` |
|
||||||
|
| マスター | 肥料マスタ | `/fertilizer/masters` |
|
||||||
|
| 帳票・連携 | 在庫管理 | `/materials` |
|
||||||
|
| 帳票・連携 | 帳票出力 | `/reports` |
|
||||||
|
| 帳票・連携 | データ取込 | `/import` |
|
||||||
|
| 帳票・連携 | 気象 | `/weather` |
|
||||||
|
| 帳票・連携 > メール | メール履歴 | `/mail/history` |
|
||||||
|
| 帳票・連携 > メール | メールルール | `/mail/rules` |
|
||||||
|
| 右上ユーザー操作 | パスワード変更 | `/settings/password` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL とナビゲーションの関係
|
||||||
|
|
||||||
|
### 基本原則
|
||||||
|
|
||||||
|
1. URL はリソース・機能識別子として安定性を優先する
|
||||||
|
2. メニュー構成とは意図的に分離して扱う
|
||||||
|
3. メニュー再編のたびに URL を変更しない
|
||||||
|
4. アクティブ判定はナビ定義側のルールで吸収する
|
||||||
|
|
||||||
|
### 採用理由
|
||||||
|
|
||||||
|
- URL をメニュー階層に合わせて変更すると、既存リンク、ブックマーク、テストへの影響が大きい
|
||||||
|
- メニュー構成は将来も変わりうるため、URL にメニュー階層を埋め込むと変更コストが増える
|
||||||
|
|
||||||
|
### 衝突する既存パス
|
||||||
|
|
||||||
|
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
|
||||||
|
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
|
||||||
|
|
||||||
|
通常判定:
|
||||||
|
|
||||||
|
- `/fertilizer` `/fertilizer/new` `/fertilizer/[id]/edit` は `施肥計画`
|
||||||
|
- `/materials` `/materials?tab=...` は `在庫管理`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表示仕様
|
||||||
|
|
||||||
|
### PC
|
||||||
|
|
||||||
|
- 左: ブランド名 `KeinaSystem`
|
||||||
|
- 中央: トップメニュー 5 項目
|
||||||
|
- 右: パスワード変更、ログアウト
|
||||||
|
|
||||||
|
表示ルール:
|
||||||
|
|
||||||
|
- `ホーム` は単独リンク
|
||||||
|
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン
|
||||||
|
- 開いているメニューがある状態で別メニューを開く場合は、前のメニューを閉じる
|
||||||
|
- メニュー外クリック、`Esc` キーで閉じる
|
||||||
|
- 項目選択後は遷移して閉じる
|
||||||
|
|
||||||
|
### スマホ
|
||||||
|
|
||||||
|
- ハンバーガーメニューを採用する
|
||||||
|
- `ホーム` は単独リンクで `/dashboard` へ遷移する
|
||||||
|
- それ以外のカテゴリはアコーディオン形式で開閉する
|
||||||
|
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
|
||||||
|
- 項目タップ後はメニューを閉じて画面遷移する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## アクセシビリティ方針
|
||||||
|
|
||||||
|
- トップメニューへキーボードでフォーカス移動できること
|
||||||
|
- `Enter` または `Space` でドロップダウンを開閉できること
|
||||||
|
- ドロップダウン展開後、各項目へ `Tab` で到達できること
|
||||||
|
- `Esc` で閉じられること
|
||||||
|
- 現在位置が視覚的に分かること
|
||||||
|
|
||||||
|
### 初期実装でやらないこと
|
||||||
|
|
||||||
|
- 矢印キーによるドロップダウン項目間移動
|
||||||
|
|
||||||
|
これは Phase 1 の必須要件には含めず、将来のアクセシビリティ強化項目として扱う。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 実装方針
|
||||||
|
|
||||||
|
### メニュー定義
|
||||||
|
|
||||||
|
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type NavItem = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
match?: (pathname: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'link' | 'group';
|
||||||
|
href?: string;
|
||||||
|
items?: NavItem[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
方針:
|
||||||
|
|
||||||
|
- グループ構成そのものが定義から読み取れることを優先する
|
||||||
|
- 通常ケースは `href` ベースで扱う
|
||||||
|
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
|
||||||
|
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
|
||||||
|
|
||||||
|
### Next.js App Router との関係
|
||||||
|
|
||||||
|
- Route Groups は、URL を変えずにコード構造を整理する手段として有効
|
||||||
|
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 段階導入
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- トップナビを 5 分類へ再編する
|
||||||
|
- `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみ
|
||||||
|
- `作物` `品種` はマスター体系には含めるが、独立画面がまだないため Phase 1 ではメニューに表示しない
|
||||||
|
- PC / スマホともに同じ情報設計にそろえる
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- `作物管理` `品種管理` を独立画面として追加
|
||||||
|
- `帳票・連携` 内の `メール` を必要に応じてサブグループ化
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- 将来マルチユーザー化した場合のみ再検討
|
||||||
|
- 単独利用前提の間は実施対象外
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
|
||||||
|
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
|
||||||
|
- 各画面でアクティブ状態が期待通りに表示されること
|
||||||
|
- PC とスマホで同じカテゴリ構成になっていること
|
||||||
|
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参照
|
||||||
|
|
||||||
|
- 議論の背景・判断理由: Gitea Issue `#13 メニューがごちゃごちゃしてきたので、整理する`
|
||||||
|
- 実装向け詳細仕様: `改善案/ナビゲーション再編仕様書.md`
|
||||||
639
document/18_マスタードキュメント_農薬散布管理編.md
Normal file
639
document/18_マスタードキュメント_農薬散布管理編.md
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
# マスタードキュメント:農薬散布管理機能
|
||||||
|
|
||||||
|
> **作成**: 2026-04-09
|
||||||
|
> **最終更新**: 2026-04-09
|
||||||
|
> **対象機能**: 農薬散布管理(農薬マスタ・散布記録・使用回数チェック・特別栽培向け成分数集計)
|
||||||
|
> **実装状況**: 未着手(仕様確定済み)
|
||||||
|
> **Gitea Issue**: akira/keinasystem#18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
農業生産者が散布した農薬を記録・管理し、農薬取締法に基づく使用基準(製品ごと・有効成分ごとの使用回数制限)への適合確認と、特別栽培認証用の成分数集計を行う機能。
|
||||||
|
|
||||||
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
|
| IN(実装対象) | OUT(対象外) |
|
||||||
|
|---|---|
|
||||||
|
| 農薬マスタ管理(CRUD) | 農薬の在庫管理・購入管理 |
|
||||||
|
| 農林水産省サイトからの農薬情報自動取得 | 農薬費用の管理 |
|
||||||
|
| 散布イベント記録(圃場/グループ/作物/品種対象) | 希釈液の量管理 |
|
||||||
|
| 製品ごとの使用回数チェック(年度×作物) | 農薬の廃棄記録 |
|
||||||
|
| 有効成分ごとの総使用回数チェック(年度×作物) | 農薬散布マップ(GIS) |
|
||||||
|
| 特別栽培用:節減対象農薬の使用成分数集計 | 農薬の処方箋・防除暦の自動作成 |
|
||||||
|
| 回数超過アラート表示 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用回数カウントのルール
|
||||||
|
|
||||||
|
農薬の使用回数は **製品単位** と **有効成分単位** の2軸で管理する。
|
||||||
|
|
||||||
|
### ルール1:製品ごとの使用回数
|
||||||
|
|
||||||
|
農薬製品(例: 住化スミチオン乳剤)を1シーズンに使用した回数 ≤ 登録情報の「本剤の使用回数」上限。
|
||||||
|
|
||||||
|
### ルール2:有効成分ごとの総使用回数
|
||||||
|
|
||||||
|
同一有効成分を含む複数製品を使用した場合、その有効成分の総使用回数として合算カウントする。
|
||||||
|
|
||||||
|
```
|
||||||
|
例)「MEP乳剤A(上限3回)」と「MEP乳剤B(上限3回)」、MEP成分の総上限3回
|
||||||
|
→ A剤2回 + B剤1回 = 合計3回 → OK
|
||||||
|
→ A剤2回 + B剤2回 = 合計4回 → 超過!
|
||||||
|
```
|
||||||
|
|
||||||
|
### ルール3:使用時期別カウント
|
||||||
|
|
||||||
|
育苗期・本圃期など時期別に別カウントになる場合がある(登録情報のテキストとして記録)。
|
||||||
|
システムでは現フェーズで時期別の自動判定は行わず、登録情報テキストを参照情報として表示する。
|
||||||
|
|
||||||
|
### カウント対象外農薬(節減対象外)
|
||||||
|
|
||||||
|
以下の農薬は使用回数・成分数のカウントから除外する(`is_non_target` フラグで管理):
|
||||||
|
|
||||||
|
- 展着剤(`is_spreader` フラグでも管理)
|
||||||
|
- 有機JAS別表2に掲げる農薬(除虫菊乳剤・硫黄剤・天敵生物農薬・性フェロモン剤等)
|
||||||
|
- 化学合成でないと認められた農薬(カスガマイシン剤・ポリオキシン剤・バリダマイシン剤等)
|
||||||
|
|
||||||
|
### 特別栽培向け成分数集計
|
||||||
|
|
||||||
|
「節減対象農薬(`is_non_target=False`)の有効成分(`is_active=True`)が何種類使われたか」を年度×作物単位でカウントする。
|
||||||
|
上限はなく、報告用の集計値として表示する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## データモデル
|
||||||
|
|
||||||
|
### Pesticide(農薬マスタ)
|
||||||
|
|
||||||
|
**アプリ**: `apps/pesticide`
|
||||||
|
**テーブル名**: `pesticide_pesticide`
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| name | CharField(200) | required | 農薬名(例: 住化スミチオン乳剤) |
|
||||||
|
| pesticide_type | CharField(100) | blank | 農薬の種類(例: MEP乳剤) |
|
||||||
|
| registration_number | CharField(20) | blank | 農薬登録番号(公式登録番号) |
|
||||||
|
| system_id | CharField(20) | blank | 農水省サイトの内部ID(詳細URLに使用) |
|
||||||
|
| purpose | CharField(100) | blank | 用途(例: 殺虫剤) |
|
||||||
|
| formulation | CharField(100) | blank | 剤型(例: 乳剤) |
|
||||||
|
| toxicity | CharField(20) | blank | 製剤毒性(普/毒/劇等) |
|
||||||
|
| is_spreader | BooleanField | default=False | 展着剤フラグ |
|
||||||
|
| is_non_target | BooleanField | default=False | 節減対象外フラグ(カウント除外) |
|
||||||
|
| notes | TextField | blank | 備考 |
|
||||||
|
| fetched_at | DateTimeField | null=True | 農水省サイトからの最終取得日時 |
|
||||||
|
| created_at | DateTimeField | auto | |
|
||||||
|
| updated_at | DateTimeField | auto | |
|
||||||
|
|
||||||
|
- `name` は unique 制約なし(同名で複数登録番号が存在しうる)
|
||||||
|
- `is_spreader=True` の場合、`is_non_target` も自動的に `True` 扱いとする
|
||||||
|
|
||||||
|
### PesticideIngredient(有効成分)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_pesticideingredient`
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| pesticide | FK(Pesticide) | CASCADE | |
|
||||||
|
| name | CharField(200) | required | 成分名称(例: MEP) |
|
||||||
|
| concentration | CharField(100) | blank | 含有濃度(例: 50.0%) |
|
||||||
|
| is_active | BooleanField | default=True | 有効成分かどうか(False = その他成分) |
|
||||||
|
|
||||||
|
- `unique_together = ['pesticide', 'name']`
|
||||||
|
|
||||||
|
### PesticideIngredientLimit(有効成分の総使用回数上限:作物別)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_pesticideingredientlimit`
|
||||||
|
|
||||||
|
農水省の「○○を含む農薬の総使用回数」は作物ごとに異なりうるため、有効成分本体とは分離して作物別に保持する。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| pesticide | FK(Pesticide) | CASCADE | 取得元農薬 |
|
||||||
|
| ingredient_name | CharField(200) | required | 成分名称(例: MEP) |
|
||||||
|
| crop_name | CharField(200) | required | 作物名(農水省登録情報の表記、例: 稲) |
|
||||||
|
| max_total_uses | IntegerField | null=True | この成分を含む農薬の総使用回数上限 |
|
||||||
|
| use_timing_note | TextField | blank | 使用時期別制限のテキスト(例: 種もみへの処理は1回以内、…) |
|
||||||
|
|
||||||
|
- `unique_together = ['pesticide', 'ingredient_name', 'crop_name']`
|
||||||
|
- 同一成分・同一作物であれば製品が異なっても上限値は同一(農水省登録情報の仕様)
|
||||||
|
- 保存時バリデーション: 同一 `ingredient_name + crop_name` の既存レコードと異なる `max_total_uses` を保存しようとした場合はエラーにする
|
||||||
|
- 使用回数チェック API の `ingredient_usage.max_total_uses` は、同一 `ingredient_name + crop_name` の値が一意であることを前提に単一値を返す
|
||||||
|
|
||||||
|
### PesticideProductLimit(製品の使用回数上限:作物別)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_pesticideproductlimit`
|
||||||
|
|
||||||
|
農水省の適用表は作物ごとに上限が異なるため、作物名をキーとして保存する。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| pesticide | FK(Pesticide) | CASCADE | |
|
||||||
|
| crop_name | CharField(200) | required | 作物名(農水省登録情報の表記、例: 稲) |
|
||||||
|
| max_uses | IntegerField | required | 本剤の使用回数上限 |
|
||||||
|
| use_timing_note | TextField | blank | 使用時期・条件の補足テキスト |
|
||||||
|
|
||||||
|
- `unique_together = ['pesticide', 'crop_name']`
|
||||||
|
|
||||||
|
### PesticideCropAlias(農水省作物名と内部作物の対応)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_pesticidecropalias`
|
||||||
|
|
||||||
|
農水省の適用表上の作物名と、内部 `plans.Crop` の作物を対応付けるための正規化テーブル。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| crop | FK(plans.Crop) | PROTECT | 内部作物 |
|
||||||
|
| alias_name | CharField(200) | required, unique | 農水省登録情報の作物名(例: 稲, 水稲) |
|
||||||
|
| is_primary | BooleanField | default=False | 代表表記かどうか |
|
||||||
|
|
||||||
|
- 使用回数チェック時は `crop_id` から本テーブルを逆引きし、`PesticideProductLimit.crop_name` / `PesticideIngredientLimit.crop_name` と照合する
|
||||||
|
- 初期データ例: `Crop=水稲` に対し `alias_name=稲`, `alias_name=水稲` を登録
|
||||||
|
|
||||||
|
### SprayEvent(散布イベント)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_sprayevent`
|
||||||
|
|
||||||
|
1回の散布作業を1件として記録する。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| year | IntegerField | required | 年度(集計フィルタ用) |
|
||||||
|
| date | DateField | required | 散布日 |
|
||||||
|
| target_type | CharField(20) | required | 対象種別: `field` / `group` / `crop` / `variety` |
|
||||||
|
| target_field | FK(fields.Field) | null=True, PROTECT | 対象が圃場の場合 |
|
||||||
|
| target_group | CharField(50) | blank | 対象が圃場グループの場合(group_name) |
|
||||||
|
| target_crop | FK(plans.Crop) | null=True, PROTECT | 対象が作物の場合 |
|
||||||
|
| target_variety | FK(plans.Variety) | null=True, PROTECT | 対象が品種の場合 |
|
||||||
|
| notes | TextField | blank | 備考 |
|
||||||
|
| created_at | DateTimeField | auto | |
|
||||||
|
| updated_at | DateTimeField | auto | |
|
||||||
|
|
||||||
|
#### target_type 別のバリデーション
|
||||||
|
|
||||||
|
| target_type | 必須フィールド | 意味 |
|
||||||
|
|---|---|---|
|
||||||
|
| `field` | target_field | 特定の圃場1筆に散布 |
|
||||||
|
| `group` | target_group | 同一 group_name の全圃場に散布 |
|
||||||
|
| `crop` | target_crop | 特定の作物に対して散布(作付け計画と照合) |
|
||||||
|
| `variety` | target_variety | 特定の品種に対して散布(作付け計画と照合) |
|
||||||
|
|
||||||
|
- 保存時に全対象圃場を `SprayEventResolvedField` として確定保存し、後日の作付け変更やグループ名変更があっても過去実績の集計結果が変わらないようにする
|
||||||
|
|
||||||
|
### SprayEventResolvedField(散布イベント対象圃場スナップショット)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_sprayeventresolvedfield`
|
||||||
|
|
||||||
|
`target_type=group` / `crop` / `variety` のように複数圃場へ展開される散布について、保存時点で対象圃場を確定保存する。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| event | FK(SprayEvent) | CASCADE | |
|
||||||
|
| field | FK(fields.Field) | PROTECT | 対象圃場 |
|
||||||
|
| field_name_snapshot | CharField(100) | required | 保存時点の圃場名 |
|
||||||
|
| group_name_snapshot | CharField(50) | blank | 保存時点のグループ名 |
|
||||||
|
| crop_name_snapshot | CharField(100) | required | 保存時点の作物名 |
|
||||||
|
| variety_name_snapshot | CharField(100) | blank | 保存時点の品種名 |
|
||||||
|
|
||||||
|
- `unique_together = ['event', 'field']`
|
||||||
|
- `target_type=field` の場合も 1 行作成しておくと、集計ロジックを統一しやすい
|
||||||
|
|
||||||
|
### SprayEventPesticide(散布農薬明細)
|
||||||
|
|
||||||
|
**テーブル名**: `pesticide_sprayeventpesticide`
|
||||||
|
|
||||||
|
1つの散布イベントに複数農薬を紐づける。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | BigAutoField | PK | |
|
||||||
|
| event | FK(SprayEvent) | CASCADE | |
|
||||||
|
| pesticide | FK(Pesticide) | PROTECT | 使用農薬 |
|
||||||
|
| dilution_ratio | CharField(50) | blank | 希釈倍率(例: 1000倍) |
|
||||||
|
| amount_used | CharField(50) | blank | 使用量(例: 500mL、単位込みで自由記述) |
|
||||||
|
| notes | TextField | blank | 備考 |
|
||||||
|
|
||||||
|
- `pesticide` は PROTECT(使用済み農薬は削除不可)
|
||||||
|
- `unique_together = ['event', 'pesticide']`(同一イベント内で同じ農薬を2回登録不可)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用回数集計の仕組み
|
||||||
|
|
||||||
|
### 集計単位
|
||||||
|
|
||||||
|
**年度 × 作物** を基本単位とする(農薬取締法上、使用回数は作物単位で管理する義務がある)。
|
||||||
|
|
||||||
|
- 集計対象作物は `SprayEventResolvedField.crop_name_snapshot` を正とする(圃場ごとに記録)
|
||||||
|
- `target_type=field`/`group`/`crop`/`variety` の違いにかかわらず、保存時に全対象圃場の `SprayEventResolvedField` を作成し、各圃場の作物をスナップショットとして保持する
|
||||||
|
- **グループ内に複数作物が混在する場合**、同一の散布イベント・散布農薬でも作物ごとに使用回数がカウントされる。例:グループ内に「水稲」3筆・「大豆」1筆が含まれる場合、そのイベントの農薬は水稲の回数にも大豆の回数にも +1 される
|
||||||
|
- 使用回数上限の照合は、`SprayEventResolvedField.crop_name_snapshot` → `PesticideCropAlias` → `PesticideProductLimit` / `PesticideIngredientLimit` の順に行う
|
||||||
|
|
||||||
|
### 製品使用回数の集計
|
||||||
|
|
||||||
|
1イベント = 1散布作業 = 1回。`unique_together=['event', 'pesticide']` により同一イベント内で同一農薬は1行しか存在しないため、イベント単位でカウントして正確。
|
||||||
|
|
||||||
|
```
|
||||||
|
製品使用回数(年度Y・作物C・農薬P)=
|
||||||
|
COUNT(DISTINCT SprayEvent.id)
|
||||||
|
where SprayEvent に SprayEventPesticide(pesticide=P) が紐づく
|
||||||
|
かつ SprayEvent に SprayEventResolvedField(crop_name_snapshot=C) が紐づく
|
||||||
|
かつ SprayEvent.year = Y
|
||||||
|
```
|
||||||
|
|
||||||
|
※ 1イベントで複数圃場に散布しても「1回」とカウントする(1イベント=1散布作業)
|
||||||
|
|
||||||
|
### 有効成分総使用回数の集計
|
||||||
|
|
||||||
|
1回の散布作業(イベント)= 有効成分の使用回数1回。同一成分を含む複数製品を同一イベントで施用することは実務上なく、仮に混合散布しても農薬取締法上「1回の散布 = 1回の使用」と解釈される。
|
||||||
|
|
||||||
|
```
|
||||||
|
有効成分総使用回数(年度Y・作物C・成分名I)=
|
||||||
|
COUNT(DISTINCT SprayEvent.id)
|
||||||
|
where SprayEvent に SprayEventPesticide が紐づく
|
||||||
|
かつ SprayEventPesticide.pesticide の PesticideIngredient に
|
||||||
|
name=I かつ is_active=True のものが存在する
|
||||||
|
かつ SprayEventPesticide.pesticide.is_non_target=False
|
||||||
|
かつ SprayEvent に SprayEventResolvedField(crop_name_snapshot=C) が紐づく
|
||||||
|
かつ SprayEvent.year = Y
|
||||||
|
```
|
||||||
|
|
||||||
|
※ `SprayEventResolvedField` は圃場ごとに複数行あるため、結合で行が増えても `DISTINCT SprayEvent.id` で 1散布作業を1回だけ数える
|
||||||
|
|
||||||
|
### 特別栽培・使用成分数の集計
|
||||||
|
|
||||||
|
```
|
||||||
|
使用成分数(年度Y・作物C)=
|
||||||
|
COUNT(DISTINCT PesticideIngredient.name)
|
||||||
|
where 上記条件(年度Y・作物C)の散布イベントで使用された農薬に含まれる
|
||||||
|
かつ PesticideIngredient.is_active=True
|
||||||
|
かつ SprayEventPesticide.pesticide.is_non_target=False
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API エンドポイント
|
||||||
|
|
||||||
|
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
|
||||||
|
|
||||||
|
### 農薬マスタ
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/pesticide/pesticides/` | 一覧取得 |
|
||||||
|
| POST | `/api/pesticide/pesticides/` | 新規作成 |
|
||||||
|
| GET | `/api/pesticide/pesticides/{id}/` | 詳細取得 |
|
||||||
|
| PUT/PATCH | `/api/pesticide/pesticides/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/pesticide/pesticides/{id}/` | 削除(使用中は 400) |
|
||||||
|
| POST | `/api/pesticide/pesticides/fetch/` | 農水省サイトから情報取得 |
|
||||||
|
|
||||||
|
農薬マスタ レスポンス例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "住化スミチオン乳剤",
|
||||||
|
"pesticide_type": "MEP乳剤",
|
||||||
|
"registration_number": "4962",
|
||||||
|
"system_id": "4962",
|
||||||
|
"purpose": "殺虫剤",
|
||||||
|
"formulation": "乳剤",
|
||||||
|
"toxicity": "普",
|
||||||
|
"is_spreader": false,
|
||||||
|
"is_non_target": false,
|
||||||
|
"notes": "",
|
||||||
|
"fetched_at": "2026-04-09T10:00:00Z",
|
||||||
|
"ingredients": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "MEP",
|
||||||
|
"concentration": "50.0%",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"product_limits": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"crop_name": "稲",
|
||||||
|
"max_uses": 2,
|
||||||
|
"use_timing_note": "収穫21日前まで"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ingredient_limits": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"ingredient_name": "MEP",
|
||||||
|
"crop_name": "稲",
|
||||||
|
"max_total_uses": 3,
|
||||||
|
"use_timing_note": "種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crop_aliases": [
|
||||||
|
{
|
||||||
|
"crop": 1,
|
||||||
|
"crop_name": "水稲",
|
||||||
|
"alias_name": "稲",
|
||||||
|
"is_primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/pesticide/pesticides/fetch/`
|
||||||
|
|
||||||
|
農水省農薬登録情報提供システムから農薬情報を取得してマスタに保存する。
|
||||||
|
取得に失敗した場合は `fetch_error` を返し、手動入力に切り替える。
|
||||||
|
|
||||||
|
リクエスト:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "スミチオン"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
レスポンス(成功):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"system_id": "4962",
|
||||||
|
"name": "住化スミチオン乳剤",
|
||||||
|
"pesticide_type": "MEP乳剤",
|
||||||
|
"registration_number": "4962"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system_id": "4991",
|
||||||
|
"name": "ホクコースミチオン乳剤",
|
||||||
|
"pesticide_type": "MEP乳剤",
|
||||||
|
"registration_number": "4991"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
候補が複数ある場合はフロントで選択させ、選択後に詳細取得リクエストを投げる:
|
||||||
|
```json
|
||||||
|
{ "system_id": "4962" }
|
||||||
|
```
|
||||||
|
|
||||||
|
レスポンス(失敗):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "農林水産省サイトへの接続に失敗しました。手動で入力してください。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 散布イベント
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/pesticide/events/?year={year}` | 年度別一覧 |
|
||||||
|
| POST | `/api/pesticide/events/` | 新規作成 |
|
||||||
|
| GET | `/api/pesticide/events/{id}/` | 詳細取得 |
|
||||||
|
| PUT/PATCH | `/api/pesticide/events/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/pesticide/events/{id}/` | 削除 |
|
||||||
|
|
||||||
|
散布イベント POST リクエスト例(圃場グループを対象に複数農薬散布):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"date": "2026-05-10",
|
||||||
|
"target_type": "group",
|
||||||
|
"target_group": "田中エリア",
|
||||||
|
"notes": "曇り、風弱し",
|
||||||
|
"pesticides": [
|
||||||
|
{
|
||||||
|
"pesticide": 1,
|
||||||
|
"dilution_ratio": "1000倍",
|
||||||
|
"amount_used": "500mL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pesticide": 3,
|
||||||
|
"dilution_ratio": "2000倍",
|
||||||
|
"amount_used": "200mL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
散布イベント レスポンス例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"year": 2026,
|
||||||
|
"date": "2026-05-10",
|
||||||
|
"target_type": "group",
|
||||||
|
"target_group": "田中エリア",
|
||||||
|
"target_display": "田中エリア(グループ)",
|
||||||
|
"resolved_fields": [
|
||||||
|
{
|
||||||
|
"field": 5,
|
||||||
|
"field_name_snapshot": "田中上",
|
||||||
|
"group_name_snapshot": "田中エリア",
|
||||||
|
"crop_name_snapshot": "水稲",
|
||||||
|
"variety_name_snapshot": "コシヒカリ"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "曇り、風弱し",
|
||||||
|
"pesticides": [
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"pesticide": 1,
|
||||||
|
"pesticide_name": "住化スミチオン乳剤",
|
||||||
|
"dilution_ratio": "1000倍",
|
||||||
|
"amount_used": "500mL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-04-09T10:00:00Z",
|
||||||
|
"updated_at": "2026-04-09T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用回数チェック
|
||||||
|
|
||||||
|
#### `GET /api/pesticide/usage-summary/?year={year}&crop_id={crop_id}`
|
||||||
|
|
||||||
|
年度×作物単位で使用回数の集計・チェック結果を返す。
|
||||||
|
|
||||||
|
レスポンス例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"crop_id": 1,
|
||||||
|
"crop_name": "水稲",
|
||||||
|
"crop_aliases": ["稲", "水稲"],
|
||||||
|
"product_usage": [
|
||||||
|
{
|
||||||
|
"pesticide_id": 1,
|
||||||
|
"pesticide_name": "住化スミチオン乳剤",
|
||||||
|
"used_count": 2,
|
||||||
|
"max_uses": 2,
|
||||||
|
"remaining": 0,
|
||||||
|
"is_over": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ingredient_usage": [
|
||||||
|
{
|
||||||
|
"ingredient_name": "MEP",
|
||||||
|
"used_count": 2,
|
||||||
|
"max_total_uses": 3,
|
||||||
|
"remaining": 1,
|
||||||
|
"is_over": false,
|
||||||
|
"products_used": ["住化スミチオン乳剤"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"component_count": 2,
|
||||||
|
"has_violation": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 農水省サイトスクレイピング仕様
|
||||||
|
|
||||||
|
### 対象サイト
|
||||||
|
|
||||||
|
農林水産省 農薬登録情報提供システム
|
||||||
|
URL: `https://pesticide.maff.go.jp/`
|
||||||
|
|
||||||
|
### アクセスフロー
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GET /agricultural-chemicals/name-search/
|
||||||
|
→ JSESSIONID クッキー + CSRF トークン(フォーム埋め込み)取得
|
||||||
|
|
||||||
|
2. POST /agricultural-chemicals/name-search
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
Body: _csrf=<token>&agriculturalChemicalsName=<農薬名>&agriculturalChemicalsType=
|
||||||
|
→ 302 リダイレクト先: /agricultural-chemicals/list
|
||||||
|
|
||||||
|
3. GET /agricultural-chemicals/list
|
||||||
|
→ 検索結果一覧 HTML
|
||||||
|
→ <a href="/agricultural-chemicals/details/{system_id}"> からリンク抽出
|
||||||
|
|
||||||
|
4. GET /agricultural-chemicals/details/{system_id}
|
||||||
|
→ 詳細ページ HTML → 下記データをパース
|
||||||
|
```
|
||||||
|
|
||||||
|
### 詳細ページ パース項目
|
||||||
|
|
||||||
|
**基本情報テーブル(`th[scope=col]` + `td` ペア):**
|
||||||
|
|
||||||
|
| th テキスト | 取得項目 | 保存先 |
|
||||||
|
|---|---|---|
|
||||||
|
| 登録番号 | 登録番号 | `registration_number` |
|
||||||
|
| 農薬の種類 | 種類名 | `pesticide_type` |
|
||||||
|
| 農薬の名称 | 農薬名 | `name` |
|
||||||
|
| 用途 | 用途 | `purpose` |
|
||||||
|
| 剤型 | 剤型 | `formulation` |
|
||||||
|
| 製剤毒性 | 毒性区分 | `toxicity` |
|
||||||
|
|
||||||
|
**有効成分テーブル:**
|
||||||
|
|
||||||
|
- 「有効成分」行: `is_active=True`、成分名・含有濃度を取得
|
||||||
|
- 「その他成分」行: `is_active=False`
|
||||||
|
|
||||||
|
**適用表(作物×病害虫ごとの行):**
|
||||||
|
|
||||||
|
各行のカラム(`data-label` 属性でカラム識別):
|
||||||
|
|
||||||
|
| data-label | 取得項目 | 保存先 |
|
||||||
|
|---|---|---|
|
||||||
|
| 作物名 | 作物名 | `PesticideProductLimit.crop_name` |
|
||||||
|
| 本剤の使用回数 | 「N回以内」から N を抽出 | `PesticideProductLimit.max_uses` |
|
||||||
|
| 使用時期 | テキストそのまま | `PesticideProductLimit.use_timing_note` |
|
||||||
|
| `{成分名}を含む農薬の総使用回数` | 「N回以内(...)」から N と補足を抽出 | `PesticideIngredientLimit.max_total_uses` / `use_timing_note` |
|
||||||
|
|
||||||
|
**「総使用回数」テキストのパース規則:**
|
||||||
|
|
||||||
|
```
|
||||||
|
入力例: "3回以内(種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内)"
|
||||||
|
→ max_total_uses = 3
|
||||||
|
→ use_timing_note = "種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内"
|
||||||
|
|
||||||
|
正規表現: r'(\d+)回以内(?:\((.+)\))?'
|
||||||
|
```
|
||||||
|
|
||||||
|
**整合性チェック:**
|
||||||
|
|
||||||
|
- 同一 `ingredient_name + crop_name` に対して既存の `PesticideIngredientLimit.max_total_uses` と異なる値が取得された場合、その農薬の自動取込はエラーとし、手動確認を促す
|
||||||
|
- `use_timing_note` の差異は許容し、より詳細なテキストで上書きしてよい
|
||||||
|
|
||||||
|
### 実装場所
|
||||||
|
|
||||||
|
`apps/pesticide/management/commands/fetch_pesticide.py`
|
||||||
|
Django management command として実装。APIエンドポイントから呼び出す。
|
||||||
|
|
||||||
|
### 注意事項
|
||||||
|
|
||||||
|
- セッション(`requests.Session`)を使用し、クッキーとCSRFを維持する
|
||||||
|
- アクセスは農薬マスタ登録時の1件ずつに限定(バルク取得は行わない)
|
||||||
|
- 農水省サイトの内部ID(`system_id`)と農薬の公式登録番号は別物
|
||||||
|
- タイムアウト: 10秒
|
||||||
|
- 適用表の作物名は `PesticideCropAlias` で内部 `Crop` と対応付ける前提で保存する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 画面仕様
|
||||||
|
|
||||||
|
### 農薬マスタ画面(`/pesticide/`)
|
||||||
|
|
||||||
|
- 登録済み農薬の一覧表示
|
||||||
|
- 農薬名で検索 → 農水省サイトから候補を取得 → 選択して詳細取得 → 保存
|
||||||
|
- 取得失敗時は手動入力フォームに切り替え
|
||||||
|
- 展着剤フラグ・節減対象外フラグの編集
|
||||||
|
|
||||||
|
### 散布記録入力画面(`/pesticide/events/new`)
|
||||||
|
|
||||||
|
- 散布日・年度入力
|
||||||
|
- 対象種別(圃場/グループ/作物/品種)選択 → 対象を選択
|
||||||
|
- 農薬を追加(複数可): 農薬マスタから選択 + 希釈倍率 + 使用量
|
||||||
|
- 保存時に使用回数チェックを実行し、超過がある場合は警告を表示(保存はブロックしない)
|
||||||
|
|
||||||
|
### 使用回数チェック画面(`/pesticide/usage`)
|
||||||
|
|
||||||
|
- 年度・作物でフィルタ
|
||||||
|
- **製品使用回数テーブル**: 農薬名 / 使用回数 / 上限 / 残回数(超過時は赤表示)
|
||||||
|
- **有効成分総使用回数テーブル**: 成分名 / 使用回数 / 上限 / 残回数 / 使用製品一覧(超過時は赤表示)
|
||||||
|
- **特別栽培欄**: 節減対象農薬の使用成分数(報告用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 設計判断と制約
|
||||||
|
|
||||||
|
1. **散布対象の特定**: `target_type` + 対象FK/文字列で柔軟に対応。保存時に `SprayEventResolvedField` で対象圃場と作物を確定保存する。作付け計画(Plan)はあくまで保存時の解決に使うだけで、集計の正源ではない。
|
||||||
|
2. **使用回数上限は作物別に保持**: 同一農薬でも作物ごとに上限が異なるため `PesticideProductLimit` と `PesticideIngredientLimit` を作物別に複数行保持する。
|
||||||
|
3. **作物名の照合は別名テーブルで吸収**: 農水省表記の「稲」と内部の「水稲」のような差異を吸収するため、`PesticideCropAlias` を必須とする。
|
||||||
|
4. **散布対象は保存時に確定保存する**: 後日のグループ名変更や作付け変更で過去実績の集計結果が変わらないよう、`SprayEventResolvedField` に圃場・作物をスナップショット保存する。`SprayEvent` 自体には作物情報を持たない。
|
||||||
|
5. **有効成分総使用回数も「1イベント=1回」**: 同一成分を含む複数製品を同一イベントで施用することは実務上なく、仮に混合散布しても農薬取締法上「1回の散布=1回の使用」。製品使用回数と同様に `COUNT(DISTINCT SprayEvent.id)` で集計する。`SprayEventResolvedField` との結合で行が増えても `DISTINCT` で正確にカウントできる。
|
||||||
|
6. **総使用回数はテキストパース**: 農水省サイトの「○○を含む農薬の総使用回数」カラムから正規表現で数値を抽出する。
|
||||||
|
7. **有効成分上限の整合性は保存時に保証する**: 同一 `ingredient_name + crop_name` の `max_total_uses` は製品をまたいで一致している前提とし、異なる値を保存しようとした場合はエラーにする。
|
||||||
|
8. **保存はブロックしない**: 使用回数超過は警告表示のみ。農薬散布の記録は法的義務があるため、超過でも保存できるようにする。
|
||||||
|
9. **`SprayEventPesticide.pesticide` は PROTECT**: 散布記録に使用中の農薬は削除不可。
|
||||||
|
10. **成分集計は `is_active=True` のみ対象**: 「その他成分」は総使用回数・特別栽培の成分数集計に含めない。
|
||||||
|
11. **`is_spreader=True` は `is_non_target` 扱い**: 展着剤はカウント除外のため、展着剤フラグをセットすれば節減対象外フラグも自動的に True 扱い(DB保存は別フィールド)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ソースファイル索引(実装後に更新)
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
|---|---|
|
||||||
|
| `backend/apps/pesticide/models.py` | Pesticide, PesticideIngredient, PesticideIngredientLimit, PesticideProductLimit, PesticideCropAlias, SprayEvent, SprayEventResolvedField, SprayEventPesticide |
|
||||||
|
| `backend/apps/pesticide/serializers.py` | DRF シリアライザ |
|
||||||
|
| `backend/apps/pesticide/views.py` | ViewSet |
|
||||||
|
| `backend/apps/pesticide/urls.py` | URL ルーティング |
|
||||||
|
| `backend/apps/pesticide/management/commands/fetch_pesticide.py` | 農水省スクレイパー |
|
||||||
|
| `frontend/src/app/pesticide/page.tsx` | 農薬マスタ一覧・散布記録 |
|
||||||
|
| `frontend/src/app/pesticide/usage/page.tsx` | 使用回数チェック画面 |
|
||||||
|
| `frontend/src/lib/types.ts` | 型定義(Pesticide, SprayEvent 等) |
|
||||||
94
document/20_ローカルテスト環境.md
Normal file
94
document/20_ローカルテスト環境.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# ローカルテスト環境(Ubuntu PC)
|
||||||
|
|
||||||
|
本番同等の環境をローカルで起動し、サーバーのデータで動作確認するための手順。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 構成
|
||||||
|
|
||||||
|
| ファイル | 用途 |
|
||||||
|
|---------|------|
|
||||||
|
| `docker-compose.local.yml` | 本番用Dockerfileを使用、Traefikなし、ポート直接公開 |
|
||||||
|
| `deploy_local.sh` | ローカル環境のビルド・起動 |
|
||||||
|
| `sync_db.sh` | サーバーのDBダンプをローカルに取り込む |
|
||||||
|
| `.env` | 本番と同じ環境変数(git管理外) |
|
||||||
|
|
||||||
|
アクセス先:
|
||||||
|
- フロントエンド: http://localhost:3000
|
||||||
|
- バックエンドAPI: http://localhost:8000/api/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 初回セットアップ
|
||||||
|
|
||||||
|
### 1. .env を作成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.production.example .env
|
||||||
|
# .env に本番と同じ値を設定する
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ローカル環境を起動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy_local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
ビルド(初回は10〜15分)→ 起動 → マイグレーションが自動実行される。
|
||||||
|
|
||||||
|
### 3. サーバーのDBを同期
|
||||||
|
|
||||||
|
**サーバー側で実行**(keinasystemユーザーで):
|
||||||
|
```bash
|
||||||
|
docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**ローカル側で実行**:
|
||||||
|
```bash
|
||||||
|
bash sync_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> `sync_db.sh` はリストア後に自動でマイグレーションを実行する。サーバーより新しいマイグレーションがローカルにある場合でも正しく動作する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2回目以降の起動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止中の場合は起動
|
||||||
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker compose -f docker-compose.local.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
コードを変更した場合は再ビルドが必要:
|
||||||
|
```bash
|
||||||
|
bash deploy_local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DBの再同期
|
||||||
|
|
||||||
|
サーバーのデータをローカルに反映したい時。
|
||||||
|
|
||||||
|
**サーバー側**(keinasystemユーザーで):
|
||||||
|
```bash
|
||||||
|
docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**ローカル側**:
|
||||||
|
```bash
|
||||||
|
bash sync_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: ローカルのDBデータは上書きされる。ローカルで加えた変更は失われる。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- `.env` は gitignore 対象(コミットしない)
|
||||||
|
- ローカルDBは `postgres_data_local` ボリュームに保存(本番の `postgres_data` とは別)
|
||||||
|
- `sync_db.sh` は SSH設定 `keinafarm`(`~/.ssh/config`)を使用
|
||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Field, Crop, Plan } from '@/types';
|
import { Field, Crop, Plan } from '@/types';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
|
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search, History } from 'lucide-react';
|
||||||
|
|
||||||
interface SummaryItem {
|
interface SummaryItem {
|
||||||
cropId: number;
|
cropId: number;
|
||||||
@@ -48,6 +48,13 @@ export default function AllocationPage() {
|
|||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
||||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const timer = window.setTimeout(() => setToast(null), 4000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('allocationYear', String(year));
|
localStorage.setItem('allocationYear', String(year));
|
||||||
@@ -233,17 +240,46 @@ export default function AllocationPage() {
|
|||||||
const existingPlan = getPlanForField(fieldId);
|
const existingPlan = getPlanForField(fieldId);
|
||||||
|
|
||||||
if (!existingPlan || !existingPlan.crop) return;
|
if (!existingPlan || !existingPlan.crop) return;
|
||||||
|
if ((existingPlan.variety || null) === variety) return;
|
||||||
|
|
||||||
|
const nextVarietyName =
|
||||||
|
variety === null
|
||||||
|
? '(品種未選択)'
|
||||||
|
: getVarietiesForCrop(existingPlan.crop).find((item) => item.id === variety)?.name || '不明';
|
||||||
|
const currentVarietyName = existingPlan.variety_name || '(品種未選択)';
|
||||||
|
|
||||||
|
const shouldProceed = confirm(
|
||||||
|
[
|
||||||
|
`品種を「${currentVarietyName}」から「${nextVarietyName}」へ変更します。`,
|
||||||
|
'施肥計画・田植え計画の関連エントリが自動で移動する場合があります。',
|
||||||
|
'実行しますか?',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
if (!shouldProceed) return;
|
||||||
|
|
||||||
setSaving(fieldId);
|
setSaving(fieldId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/plans/${existingPlan.id}/`, {
|
const res = await api.patch(`/plans/${existingPlan.id}/`, {
|
||||||
variety,
|
variety,
|
||||||
notes: existingPlan.notes,
|
notes: existingPlan.notes,
|
||||||
});
|
});
|
||||||
|
const updatedPlan: Plan = res.data;
|
||||||
|
const movedCount = updatedPlan.latest_variety_change?.fertilizer_moved_entry_count ?? 0;
|
||||||
|
setToast({
|
||||||
|
type: 'success',
|
||||||
|
message:
|
||||||
|
movedCount > 0
|
||||||
|
? `品種を変更し、施肥計画 ${movedCount} 件を移動しました。`
|
||||||
|
: '品種を変更しました。関連する施肥計画の移動はありませんでした。',
|
||||||
|
});
|
||||||
await fetchData(true);
|
await fetchData(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save variety:', error);
|
console.error('Failed to save variety:', error);
|
||||||
|
setToast({
|
||||||
|
type: 'error',
|
||||||
|
message: '品種変更に失敗しました。',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
@@ -367,6 +403,20 @@ export default function AllocationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => {
|
||||||
|
try {
|
||||||
|
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
|
||||||
|
if (!variety) return;
|
||||||
|
await api.patch(`/plans/varieties/${varietyId}/`, {
|
||||||
|
default_seedling_boxes_per_tan: defaultBoxes,
|
||||||
|
});
|
||||||
|
await fetchData(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update variety default boxes:', error);
|
||||||
|
alert('品種デフォルトの更新に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleFieldSelection = (fieldId: number) => {
|
const toggleFieldSelection = (fieldId: number) => {
|
||||||
setSelectedFields((prev) => {
|
setSelectedFields((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -549,6 +599,17 @@ export default function AllocationPage() {
|
|||||||
{/* メインコンテンツ */}
|
{/* メインコンテンツ */}
|
||||||
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 rounded-md border px-4 py-3 text-sm ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'border-green-300 bg-green-50 text-green-800'
|
||||||
|
: 'border-red-300 bg-red-50 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
作付け計画 <span className="text-green-700">{year}年度</span>
|
作付け計画 <span className="text-green-700">{year}年度</span>
|
||||||
@@ -873,27 +934,43 @@ export default function AllocationPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
value={selectedVarietyId || ''}
|
<select
|
||||||
onChange={(e) => {
|
value={selectedVarietyId || ''}
|
||||||
if (e.target.value === '__add__') {
|
onChange={(e) => {
|
||||||
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
if (e.target.value === '__add__') {
|
||||||
setNewVarietyName('');
|
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
||||||
} else {
|
setNewVarietyName('');
|
||||||
handleVarietyChange(field.id, e.target.value);
|
} else {
|
||||||
}
|
handleVarietyChange(field.id, e.target.value);
|
||||||
}}
|
}
|
||||||
disabled={saving === field.id || !selectedCropId}
|
}}
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
disabled={saving === field.id || !selectedCropId}
|
||||||
>
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
||||||
<option value="">選択してください</option>
|
>
|
||||||
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
<option value="">選択してください</option>
|
||||||
<option key={variety.id} value={variety.id}>
|
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
||||||
{variety.name}
|
<option key={variety.id} value={variety.id}>
|
||||||
</option>
|
{variety.name}
|
||||||
))}
|
</option>
|
||||||
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
))}
|
||||||
</select>
|
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
||||||
|
</select>
|
||||||
|
{plan?.latest_variety_change && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
|
||||||
|
title={[
|
||||||
|
`変更日時: ${new Date(plan.latest_variety_change.changed_at).toLocaleString('ja-JP')}`,
|
||||||
|
`変更前: ${plan.latest_variety_change.old_variety_name || '未設定'}`,
|
||||||
|
`変更後: ${plan.latest_variety_change.new_variety_name || '未設定'}`,
|
||||||
|
`施肥移動件数: ${plan.latest_variety_change.fertilizer_moved_entry_count}`,
|
||||||
|
].join('\n')}
|
||||||
|
>
|
||||||
|
<History className="h-3 w-3" />
|
||||||
|
変更履歴あり
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -1032,15 +1109,22 @@ export default function AllocationPage() {
|
|||||||
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{getVarietiesForCrop(managerCropId).map((v) => (
|
{getVarietiesForCrop(managerCropId).map((v) => (
|
||||||
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
|
<li key={v.id} className="rounded border border-gray-200 p-3">
|
||||||
<span className="text-sm text-gray-900">{v.name}</span>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<button
|
<span className="text-sm font-medium text-gray-900">{v.name}</span>
|
||||||
onClick={() => handleDeleteVariety(v.id, v.name)}
|
<button
|
||||||
className="text-red-400 hover:text-red-600 p-1"
|
onClick={() => handleDeleteVariety(v.id, v.name)}
|
||||||
title="削除"
|
className="text-red-400 hover:text-red-600 p-1"
|
||||||
>
|
title="削除"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<VarietyDefaultBoxesForm
|
||||||
|
varietyId={v.id}
|
||||||
|
initialValue={v.default_seedling_boxes_per_tan}
|
||||||
|
onSave={handleUpdateVarietyDefaultBoxes}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1105,3 +1189,47 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VarietyDefaultBoxesForm({
|
||||||
|
varietyId,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
varietyId: number;
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (varietyId: number, defaultBoxes: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
await onSave(varietyId, value);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1 block text-xs text-gray-600">反当苗箱枚数デフォルト</label>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import DistributionEditPage from '../../_components/DistributionEditPage';
|
import DeliveryEditPage from '../../_components/DeliveryEditPage';
|
||||||
|
|
||||||
export default function DistributionEditRoute({ params }: { params: { id: string } }) {
|
export default function DeliveryEditRoute({ params }: { params: { id: string } }) {
|
||||||
return <DistributionEditPage planId={Number(params.id)} />;
|
return <DeliveryEditPage planId={Number(params.id)} />;
|
||||||
}
|
}
|
||||||
|
|||||||
1182
frontend/src/app/distribution/_components/DeliveryEditPage.tsx
Normal file
1182
frontend/src/app/distribution/_components/DeliveryEditPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user