Compare commits
85 Commits
466eef128c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04e1884d9 | ||
|
|
cc6823b071 | ||
|
|
b7b5ce3943 | ||
|
|
8de1ae70aa | ||
|
|
3901caf668 | ||
|
|
5f58c2c686 | ||
|
|
83525c2f59 | ||
|
|
627d7e4f59 | ||
|
|
9059b2b51e | ||
|
|
7d2eb1ebe2 | ||
|
|
3e2942b479 | ||
|
|
70fe3824b3 | ||
|
|
10f2b6f77f | ||
|
|
6dfcd0be06 | ||
|
|
1371eef648 | ||
|
|
ac0bc7b6a9 | ||
|
|
1c474e9692 | ||
|
|
0cd90e61db | ||
|
|
8de27de335 | ||
|
|
71b8258281 | ||
|
|
4516a74772 | ||
|
|
a42ccb5cda | ||
|
|
4a1db5ef27 | ||
|
|
c90c6210e1 | ||
|
|
c675b7b7ae | ||
|
|
ae0249be69 | ||
|
|
1d5bcc9dd6 | ||
|
|
98814299cf | ||
|
|
21fb2323eb | ||
|
|
5a9b6a053b | ||
|
|
429a98decb | ||
|
|
4299c6eb4b | ||
|
|
8dd680e28a | ||
|
|
3eb2852b78 | ||
|
|
5c2d17fe0a | ||
|
|
182ef5d83d | ||
|
|
0131982c34 | ||
|
|
491f05eee8 | ||
|
|
a38472e4a0 | ||
|
|
11b36b28a5 | ||
|
|
95c90dd699 | ||
|
|
9bcc5e5e21 | ||
|
|
0c57dd7886 | ||
|
|
f236fe2f90 | ||
|
|
b7b9818855 | ||
|
|
c773c7d3b8 | ||
|
|
edd2f2a274 | ||
|
|
00fd4a8cba | ||
|
|
13c21ed7de | ||
|
|
daae1a42e5 | ||
|
|
4e06318985 | ||
|
|
9f96d1f820 | ||
|
|
140d5e5a4d | ||
|
|
865d53ed9a | ||
|
|
c9ae99ebc8 | ||
|
|
9dbbb48ee0 | ||
|
|
1f26d5001b | ||
|
|
722ac4efd0 | ||
|
|
bba04f24c2 | ||
|
|
287a1ebb59 | ||
|
|
1c27a66691 | ||
|
|
eba6267495 | ||
|
|
d9a4bd19eb | ||
|
|
89ab9b7b83 | ||
|
|
d5d78a2b14 | ||
|
|
391b0b265e | ||
|
|
736b9c824e | ||
|
|
e3c21d6e81 | ||
|
|
72b4d670fe | ||
|
|
42b11a5df8 | ||
|
|
497bc87c24 | ||
|
|
67d4197b7f | ||
|
|
1b619c44a0 | ||
|
|
f1512febde | ||
|
|
776a269d6d | ||
|
|
1425094107 | ||
|
|
f74dc4c4b7 | ||
|
|
7825f0eb30 | ||
|
|
422a6781c5 | ||
|
|
0e809ebb99 | ||
|
|
ff67a6bf26 | ||
|
|
5145217481 | ||
|
|
21d1dc355d | ||
|
|
8c47217003 | ||
|
|
a331f8b30a |
@@ -56,10 +56,33 @@
|
||||
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
||||
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
|
||||
"Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(xargs cat:*)",
|
||||
"Bash(xargs grep:*)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_dd08c8854e486d12__ echo \"=== Fertilization Plans \\(check is_confirmed/confirmed_at\\) ===\" curl -s http://localhost:8000/api/fertilizer/plans/?year=2026 -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Bash(python -m json.tool)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_0b7fd53b80bd968a__ echo \"=== Stock summary \\(should show reserved\\) ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Bash(git diff:*)",
|
||||
"mcp__serena__find_symbol",
|
||||
"mcp__serena__get_symbols_overview",
|
||||
"Bash(git status:*)",
|
||||
"Bash(npx next:*)",
|
||||
"mcp__butler__butler__list_skills",
|
||||
"mcp__butler__butler__get_skill_usage",
|
||||
"mcp__butler__inspect_runtime_config",
|
||||
"mcp__butler__execute_task",
|
||||
"Bash(git -C /home/akira/develop/keinasystem remote -v)",
|
||||
"Bash(cat butler/skills/read_from_gitea*)",
|
||||
"Bash(bash ~/.claude/scripts/gitea.sh GET /repos/akira/keinasystem/issues/11)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"C:\\Users\\akira\\AppData\\Local\\Temp"
|
||||
"C:\\Users\\akira\\AppData\\Local\\Temp",
|
||||
"C:\\Users\\akira\\Develop\\keinasystem_t02",
|
||||
"/home/akira/develop",
|
||||
"/home/akira/.docker",
|
||||
"/tmp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
26
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
26
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: バグ報告
|
||||
about: 不具合・予期しない動作の報告
|
||||
labels: バグ
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 問題の概要
|
||||
|
||||
## 再現手順
|
||||
|
||||
## 期待する動作
|
||||
|
||||
## 実際の動作
|
||||
|
||||
## 関連
|
||||
24
.gitea/ISSUE_TEMPLATE/design.md
Normal file
24
.gitea/ISSUE_TEMPLATE/design.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 設計・方針決定
|
||||
about: 実装前の設計議論・方針決定が必要なもの
|
||||
labels: "種別: 設計待ち"
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 問題・背景
|
||||
|
||||
## 検討事項
|
||||
|
||||
## 完了条件
|
||||
|
||||
## 関連
|
||||
24
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
24
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 機能追加
|
||||
about: 新機能・改善提案
|
||||
labels: 機能
|
||||
---
|
||||
|
||||
## 現在の状態(なぜOpenか)
|
||||
(1行で)
|
||||
|
||||
## 次にすること(Next Action)
|
||||
(1行で)
|
||||
|
||||
## ブロック要因
|
||||
(なければ「なし」)
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
## 背景・目的
|
||||
|
||||
## 完了条件
|
||||
|
||||
## 関連
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ out/
|
||||
db.sqlite3
|
||||
postgres_data/
|
||||
nul
|
||||
|
||||
*.tsbuildinfo
|
||||
.mcp.json
|
||||
.codex
|
||||
|
||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
1
.serena/memories/project_overview.md
Normal file
1
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1 @@
|
||||
keinasystem_t02 は農業生産者向けの作付け計画・圃場管理システム。主要スタックは Django/DRF/PostgreSQL(PostGIS) のバックエンドと Next.js 14 App Router + TypeScript + Tailwind CSS のフロントエンド。backend/apps に fields, plans, weather, reports, fertilizer, materials, mail があり、frontend/src/app に各画面がある。ドキュメント駆動で、CLAUDE.md と document/*.md が重要な仕様ソース。Windows 環境で Docker Compose による開発を前提としている。
|
||||
1
.serena/memories/style_and_completion.md
Normal file
1
.serena/memories/style_and_completion.md
Normal file
@@ -0,0 +1 @@
|
||||
コードと仕様の変更はドキュメントドリブンで進める。仕様変更時は document 配下や CLAUDE.md の更新が重要。バックエンドは Django/DRF の標準的なモデル・serializer・viewset 構成、フロントは Next.js App Router と TypeScript。完了時は影響範囲に応じて少なくとも関連ドキュメント確認、必要な migration 確認、frontend lint (`npm run lint`) や対象 API/画面の動作確認を行う。既存の dirty worktree は勝手に戻さない。
|
||||
1
.serena/memories/suggested_commands.md
Normal file
1
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1 @@
|
||||
Windows 環境の主要コマンド: `git status`, `rg <pattern>`, `Get-ChildItem`, `Get-Content <file>`, `docker compose -f docker-compose.develop.yml up -d`, `docker compose exec backend python manage.py migrate`, `docker compose exec backend python manage.py makemigrations`, `docker compose exec backend python manage.py runserver 0.0.0.0:8000`, `cd frontend; npm install; npm run dev`, `cd frontend; npm run lint`。開発用 compose では backend は `python manage.py runserver 0.0.0.0:8000`、frontend は `npm run dev` を利用する。
|
||||
149
.serena/project.yml
Normal file
149
.serena/project.yml
Normal file
@@ -0,0 +1,149 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "keinasystem_t02"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- python
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
22
.vscode/mcp.json
vendored
Normal file
22
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"butler": {
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "butler.mcp_facade"],
|
||||
"cwd": "../butler2"
|
||||
},
|
||||
"serena": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"--from",
|
||||
"git+https://github.com/oraios/serena",
|
||||
"serena",
|
||||
"start-mcp-server",
|
||||
"--context",
|
||||
"ide-assistant",
|
||||
"--project",
|
||||
"."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
548
CLAUDE.md
548
CLAUDE.md
@@ -1,512 +1,128 @@
|
||||
# Keina System - Claude 向けガイド
|
||||
|
||||
> **最終更新**: 2026-02-28
|
||||
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
|
||||
## プロジェクト概要
|
||||
|
||||
## 📌 このファイルの目的
|
||||
|
||||
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
|
||||
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
|
||||
|
||||
## ⚠️ Claude への重要な指示
|
||||
|
||||
**このファイルは、セッションごとに必ず最初に読んでください。**
|
||||
|
||||
さらに、以下のルールを厳守してください:
|
||||
|
||||
### 📝 更新義務
|
||||
|
||||
**ドキュメントドリブンの徹底**
|
||||
- ✅ 仕様に変更がある時は、まず関連するドキュメントから更新する事。
|
||||
|
||||
**機能追加・変更時は、必ずこのファイルを更新すること。**
|
||||
|
||||
- ✅ 新機能実装時 → 「実装状況」セクションを更新
|
||||
- ✅ データモデル変更時 → 「データモデル概要」を更新
|
||||
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
|
||||
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
|
||||
- ✅ 問題解決時 → 「トラブルシューティング」に追加
|
||||
- ✅ 更新時は必ず「更新履歴」セクションに記録
|
||||
|
||||
|
||||
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 プロジェクト概要(30秒で理解)
|
||||
|
||||
**何を作っているか:**
|
||||
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
|
||||
ユーザーは65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理。
|
||||
|
||||
**ユーザー:**
|
||||
65歳の農家(元プログラマー)、シングルユーザー、39筆の圃場を管理
|
||||
**技術スタック:** Django 5.2 + DRF + PostGIS / Next.js 14 (App Router) + TypeScript + Tailwind / PostgreSQL 16 + PostGIS 3.4
|
||||
|
||||
**技術スタック:**
|
||||
- Backend: Django 5.2 + DRF + PostGIS
|
||||
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
|
||||
- Database: PostgreSQL 16 + PostGIS 3.4
|
||||
|
||||
**開発方針:**
|
||||
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||
**開発方針:** シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
|
||||
|
||||
---
|
||||
|
||||
## 📂 プロジェクト構造
|
||||
## 絶対に守るべき制約
|
||||
|
||||
1. **Field ↔ OfficialKyosaiField / OfficialChusankanField は M:N** — 決してFK (1:N) に戻さない
|
||||
2. **年度+圃場の組み合わせは1つの Plan のみ** (`unique_together`)
|
||||
3. **面積**: 表示=反(tan)、計算・保存=m2、変換: 1反=1000m2
|
||||
4. **FertilizationEntry.fertilizer は PROTECT** — 使用中の肥料は削除不可
|
||||
5. **3回同じコードを書くまでは抽象化しない**
|
||||
6. **ドキュメントドリブン**: 仕様変更時はまず関連ドキュメントから更新する
|
||||
|
||||
## コーディング規約
|
||||
|
||||
- **Backend**: Django ベストプラクティス、日本語フィールドは `verbose_name` で対応
|
||||
- **Frontend**: TypeScript strict mode、ESLint に従う
|
||||
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
|
||||
|
||||
---
|
||||
|
||||
## プロジェクト構造
|
||||
|
||||
```
|
||||
keinasystem_t02/
|
||||
├── CLAUDE.md # このファイル(Claude向けガイド)
|
||||
├── .cursor/
|
||||
│ └── rules/
|
||||
│ └── 30_Cursorガイド.md # Cursor専用ガイド
|
||||
├── document/ # 詳細設計書(人間向け)
|
||||
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
||||
│ ├── 01_プロダクトビジョン.md
|
||||
│ ├── 02_ユーザーストーリー.md
|
||||
│ ├── 03_データ仕様書.md
|
||||
│ ├── 04_画面設計書.md
|
||||
│ └── 05_実装優先順位.md
|
||||
├── CLAUDE.md # このファイル
|
||||
├── TASK_CONTEXT.md # 実装状況・課題・次のマイルストーン
|
||||
├── document/ # 設計書・マスタードキュメント
|
||||
├── backend/
|
||||
│ ├── keinasystem/ # Django設定
|
||||
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
|
||||
│ │ └── urls.py # ルートURL設定
|
||||
│ ├── keinasystem/ # Django設定 (settings.py, urls.py)
|
||||
│ └── apps/
|
||||
│ ├── fields/ # 圃場管理アプリ
|
||||
│ │ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
|
||||
│ │ ├── views.py # インポート機能、CRUD API
|
||||
│ │ └── urls.py
|
||||
│ ├── plans/ # 作付け計画アプリ
|
||||
│ │ ├── models.py # Plan, Crop(+base_temp), Variety
|
||||
│ │ └── views.py # 作付け計画API、集計API
|
||||
│ ├── weather/ # 気象データアプリ
|
||||
│ │ ├── models.py # WeatherRecord (1日1行)
|
||||
│ │ ├── views.py # sync(APIキー), records, summary, gdd, similarity
|
||||
│ │ ├── urls.py
|
||||
│ │ └── management/commands/fetch_weather.py # 初回一括取得・差分取得
|
||||
│ └── reports/ # 申請書生成アプリ
|
||||
│ ├── views.py # PDF生成API
|
||||
│ └── templates/ # PDF用HTMLテンプレート
|
||||
└── frontend/
|
||||
└── src/app/
|
||||
├── allocation/ # 作付け計画編集画面(メイン)
|
||||
│ ├── fields/ # 圃場管理(Field, OfficialKyosaiField, OfficialChusankanField)
|
||||
│ ├── plans/ # 作付け計画(Plan, Crop, Variety)
|
||||
│ ├── weather/ # 気象データ(WeatherRecord)
|
||||
│ ├── reports/ # 申請書PDF生成
|
||||
│ ├── fertilizer/ # 施肥計画・散布実績・運搬計画
|
||||
│ ├── workrecords/ # 作業記録索引
|
||||
│ └── mail/ # メールフィルタリング(Windmill連携)
|
||||
└── frontend/src/app/
|
||||
├── allocation/ # 作付け計画編集(メイン画面)
|
||||
├── fields/ # 圃場一覧・詳細
|
||||
├── reports/ # 申請書ダウンロード
|
||||
├── import/ # データ取込画面
|
||||
├── mail/
|
||||
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
|
||||
│ ├── history/ # メール処理履歴
|
||||
│ └── rules/ # 送信者ルール管理
|
||||
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
|
||||
└── settings/
|
||||
└── password/ # パスワード変更
|
||||
├── fertilizer/ # 施肥計画・散布実績
|
||||
├── distribution/ # 運搬計画
|
||||
├── weather/ # 気象データ
|
||||
├── reports/ # 申請書DL
|
||||
├── import/ # データ取込
|
||||
├── mail/ # メール管理
|
||||
└── settings/ # パスワード変更
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ データモデル概要
|
||||
|
||||
### コアエンティティ
|
||||
|
||||
```
|
||||
Field (実圃場)
|
||||
├── 39筆の実際の農地
|
||||
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
|
||||
├── group_name, display_order (グループ分け・表示順)
|
||||
└── ManyToMany関係
|
||||
├── kyosai_fields (共済マスタ、M:N)
|
||||
└── chusankan_fields (中山間マスタ、M:N)
|
||||
|
||||
OfficialKyosaiField (共済マスタ)
|
||||
└── 31区画(水稲共済細目書用)
|
||||
|
||||
OfficialChusankanField (中山間マスタ)
|
||||
├── 71区画(中山間地域等直接支払交付金用)
|
||||
└── 17フィールド: c_id, chusankan_flag, oaza, aza, chiban,
|
||||
branch_num, land_type, area, planting_area,
|
||||
original_crop, manager, owner, slope,
|
||||
base_amount, steep_slope_addition, smart_agri_addition,
|
||||
payment_amount
|
||||
|
||||
Plan (作付け計画)
|
||||
├── field (FK to Field)
|
||||
├── year (年度)
|
||||
├── crop (FK to Crop)
|
||||
├── variety (FK to Variety, nullable)
|
||||
└── unique_together = ['field', 'year']
|
||||
|
||||
Crop (作物マスタ)
|
||||
├── name(米、トウモロコシ、エンドウ、野菜、その他)
|
||||
└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加
|
||||
|
||||
Variety (品種マスタ)
|
||||
├── crop (FK to Crop)
|
||||
├── name (品種名)
|
||||
└── unique_together = ['crop', 'name']
|
||||
|
||||
MailSender (送信者ルール)
|
||||
├── email (EmailField, nullable)
|
||||
├── domain (CharField, nullable)
|
||||
├── rule ('never_notify' | 'always_notify')
|
||||
└── ConstraintCheck: email/domain どちらか一方のみ
|
||||
|
||||
MailEmail (受信メール記録)
|
||||
├── account (xserver/gmail/hotmail等)
|
||||
├── message_id (unique)
|
||||
├── sender_email, sender_domain
|
||||
├── subject, body_preview
|
||||
├── received_at, llm_verdict (important/not_important)
|
||||
├── notified_at (LINE通知日時、nullable)
|
||||
└── feedback (important/not_important/never_notify/always_notify, nullable)
|
||||
|
||||
MailNotificationToken (フィードバックURL用トークン)
|
||||
├── email (OneToOne FK to MailEmail)
|
||||
└── token (UUID, unique)
|
||||
|
||||
WeatherRecord (日次気象記録)
|
||||
├── date (DateField, unique)
|
||||
├── temp_mean, temp_max, temp_min (気温℃)
|
||||
├── sunshine_h (日照時間h)
|
||||
├── precip_mm (降水量mm)
|
||||
├── wind_max (最大風速m/s)
|
||||
└── pressure_min (最低気圧hPa)
|
||||
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
||||
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
||||
|
||||
Fertilizer (肥料マスタ)
|
||||
├── name(肥料名、必須・unique)
|
||||
├── maker(メーカー、任意)
|
||||
├── capacity_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(本番稼働中)
|
||||
- Windmill フロー: `f/mail/mail_filter`(本番: windmill.keinafarm.net にデプロイ済み、10分間隔スケジュール)
|
||||
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||
8. **パスワード変更機能**:
|
||||
- Backend: `POST /api/auth/change-password/`(JWT認証、`ChangePasswordView` in `keinasystem/urls.py`)
|
||||
- Frontend: `/settings/password` ページ
|
||||
- Navbar: KeyRound アイコンボタン(ログアウトボタンの左隣)
|
||||
9. **気象データ基盤**(Windmill連携):
|
||||
- Django `apps/weather` アプリ(WeatherRecord: 1日1行、2016-01-01〜)
|
||||
- データソース: Open-Meteo archive API(窪川 lat=33.213, lon=133.133)
|
||||
- Windmill向け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` にモデルクラスを追加
|
||||
2. `python manage.py makemigrations`
|
||||
3. `python manage.py migrate`
|
||||
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
|
||||
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
|
||||
6. ViewSet 作成 (`apps/<app_name>/views.py`)
|
||||
7. URL登録 (`apps/<app_name>/urls.py`)
|
||||
1. `apps/<app>/models.py` → 2. `makemigrations` → 3. `migrate` → 4. `admin.py` 登録
|
||||
5. Serializer → 6. ViewSet → 7. URL登録
|
||||
|
||||
### 新しいAPI エンドポイントを追加する場合
|
||||
### 新しいAPI / 画面を追加する場合
|
||||
|
||||
1. `apps/<app_name>/views.py` にビューを追加
|
||||
2. `apps/<app_name>/urls.py` にパスを追加
|
||||
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
|
||||
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
|
||||
|
||||
### 新しい画面を追加する場合
|
||||
|
||||
1. `frontend/src/app/<page_name>/page.tsx` を作成
|
||||
2. 必要に応じてレイアウト調整 (`layout.tsx`)
|
||||
3. API呼び出しは `useEffect` + `fetch` で実装
|
||||
4. ローディング状態、エラー状態を適切に処理
|
||||
- API: `views.py` → `urls.py` → フロントの型定義 (`lib/types.ts`) → API呼び出し
|
||||
- 画面: `frontend/src/app/<page>/page.tsx` → ローディング/エラー状態を処理
|
||||
|
||||
---
|
||||
|
||||
## 🔍 トラブルシューティング
|
||||
|
||||
### 本番デプロイコマンド(必須)
|
||||
## デプロイ・トラブルシューティング
|
||||
|
||||
```bash
|
||||
# ⚠️ --env-file .env.production を必ず付けること(省略するとSECRET_KEYが空でbackendが起動しない)
|
||||
# ⚠️ 本番ファイルは keinasystem ユーザー所有。git pull は sudo -u keinasystem で実行
|
||||
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'
|
||||
```
|
||||
# 本番デプロイ(git pull → build → up -d を一括実行)
|
||||
ssh keinafarm-claude 'sudo -u keinasystem bash /home/keinasystem/keinasystem_t02/deploy.sh'
|
||||
|
||||
### 本番確認手順(デプロイ後の必須チェック)
|
||||
|
||||
**⚠️ Playwright(ビジュアルテスト)を使う前に、必ずcurlで先に確認すること。**
|
||||
curlはキャッシュの影響を受けず、偽装不可能な確認手段。
|
||||
|
||||
```bash
|
||||
# ステップ1: curlヘルスチェック(全9項目、所要約10秒)
|
||||
# 本番ヘルスチェック(9項目、curlベース)
|
||||
bash scripts/check_prod.sh claude keina1234
|
||||
# → 全 9 項目 PASS が出れば本番が正常稼働中
|
||||
|
||||
# ステップ2(任意): Playwrightでビジュアル確認する場合のプロンプト原則
|
||||
# - 「認証できなければ即中止して報告せよ」を必ず明記
|
||||
# - 「スクリーンショットには今日の日付が画面内に見えること」を要求
|
||||
# - 「成功の証跡(HTTP レスポンスの実テキスト)を必ず添付すること」を要求
|
||||
```
|
||||
|
||||
**本番バックエンドのマイグレーション適用(バックエンド変更時のみ):**
|
||||
```bash
|
||||
# 本番マイグレーション(バックエンド変更時のみ)
|
||||
ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
|
||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production build backend && \
|
||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production up -d && \
|
||||
sleep 5 && \
|
||||
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production exec backend python manage.py migrate'
|
||||
sudo -u keinasystem docker compose build backend && \
|
||||
sudo -u keinasystem docker compose up -d && sleep 5 && \
|
||||
sudo -u keinasystem docker compose exec backend python manage.py migrate'
|
||||
```
|
||||
|
||||
### マイグレーションエラー
|
||||
|
||||
```bash
|
||||
# マイグレーションをリセット(開発環境のみ!)
|
||||
docker-compose exec backend python manage.py migrate <app_name> zero
|
||||
docker-compose exec backend python manage.py makemigrations
|
||||
docker-compose exec backend python manage.py migrate
|
||||
```
|
||||
|
||||
### CORS エラー
|
||||
|
||||
- `backend/keinasystem/settings.py` の `CORS_ALLOWED_ORIGINS` を確認
|
||||
- 現在は `http://localhost:3000` と `http://127.0.0.1:3000` を許可
|
||||
|
||||
### JWT トークンエラー
|
||||
|
||||
- トークンの有効期限を確認(アクセストークン: 24時間)
|
||||
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`)
|
||||
|
||||
### PDF 生成エラー
|
||||
|
||||
- WeasyPrint のインストールを確認
|
||||
- 日本語フォントの設定を確認(HTMLテンプレートのCSS)
|
||||
- **Docker Compose**: `docker-compose.yml`=本番、`docker-compose.develop.yml`=開発
|
||||
- **CORS**: `settings.py` の `CORS_ALLOWED_ORIGINS`(localhost:3000 許可済み)
|
||||
- **JWT**: アクセストークン24h、リフレッシュ: `/api/auth/jwt/refresh/`
|
||||
|
||||
---
|
||||
|
||||
## 📚 詳細情報へのリンク
|
||||
## マスタードキュメント(機能別リファレンス)
|
||||
|
||||
### マスタードキュメント(機能別の網羅的リファレンス)
|
||||
特定機能の詳細を知りたい場合、**まずマスタードキュメントを参照**すること。
|
||||
データモデル・API仕様・画面仕様がソースコード参照不要なレベルで記載されている。
|
||||
|
||||
**特定機能の実装詳細を知りたい場合、まずマスタードキュメントを参照すること。**
|
||||
マスタードキュメントにはデータモデル・API仕様・画面仕様・インポート/エクスポート仕様が
|
||||
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
|
||||
|
||||
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
||||
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
|
||||
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md`
|
||||
- **分配計画機能**: `document/14_マスタードキュメント_分配計画編.md`
|
||||
|
||||
### 設計ドキュメント(プロジェクト横断)
|
||||
|
||||
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
|
||||
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
|
||||
- **データモデル詳細**: `document/03_データ仕様書.md`
|
||||
- **画面設計**: `document/04_画面設計書.md`
|
||||
- **実装手順**: `document/00_Gemini向け統合指示書.md`
|
||||
- **差異レポート・タスク一覧**: `document/06_ドキュメントvs実装_差異レポート.md`
|
||||
| 機能 | ドキュメント |
|
||||
|------|------------|
|
||||
| 圃場管理 | `document/10_マスタードキュメント_圃場管理編.md` |
|
||||
| メール通知 | `document/11_マスタードキュメント_メール通知関連編.md` |
|
||||
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
|
||||
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
|
||||
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
|
||||
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
|
||||
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
|
||||
| TODO管理 | `document/19_マスタードキュメント_TODO管理編.md` |
|
||||
| データモデル全体 | `document/03_データ仕様書.md` |
|
||||
|
||||
---
|
||||
|
||||
## 💡 新しいセッションでの推奨フロー
|
||||
## セッション開始・終了フロー
|
||||
|
||||
### 開始時
|
||||
1. この `CLAUDE.md` を読む
|
||||
2. タスク対象の機能に対応する**マスタードキュメント**を読む(例: 圃場関連 → `document/10_マスタードキュメント_圃場管理編.md`)
|
||||
3. マスタードキュメントで不足する場合のみ、ソースコードや他のドキュメントを参照
|
||||
4. 実装・修正を行う
|
||||
5. 重要な設計判断があれば、この `CLAUDE.md` と該当マスタードキュメントを更新
|
||||
2. `HANDOVER.md` で前回の引き継ぎを確認する
|
||||
3. `TASK_CONTEXT.md` で現在の状況を把握する
|
||||
4. タスク対象の**マスタードキュメント**を読む
|
||||
|
||||
---
|
||||
|
||||
## 📝 更新履歴
|
||||
|
||||
- 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 .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Fertilizer)
|
||||
@@ -14,30 +18,58 @@ class FertilizationEntryInline(admin.TabularInline):
|
||||
|
||||
@admin.register(FertilizationPlan)
|
||||
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'variety']
|
||||
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
|
||||
list_filter = ['year']
|
||||
inlines = [FertilizationEntryInline]
|
||||
|
||||
|
||||
class DistributionGroupFieldInline(admin.TabularInline):
|
||||
model = DistributionGroupField
|
||||
class DeliveryGroupFieldInline(admin.TabularInline):
|
||||
model = DeliveryGroupField
|
||||
extra = 0
|
||||
readonly_fields = ['distribution_plan']
|
||||
readonly_fields = ['delivery_plan']
|
||||
|
||||
|
||||
class DistributionGroupInline(admin.TabularInline):
|
||||
model = DistributionGroup
|
||||
class DeliveryGroupInline(admin.TabularInline):
|
||||
model = DeliveryGroup
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(DistributionPlan)
|
||||
class DistributionPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'fertilization_plan', 'created_at']
|
||||
list_filter = ['fertilization_plan__year']
|
||||
inlines = [DistributionGroupInline]
|
||||
class DeliveryTripItemInline(admin.TabularInline):
|
||||
model = DeliveryTripItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(DistributionGroup)
|
||||
class DistributionGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'distribution_plan', 'order']
|
||||
inlines = [DistributionGroupFieldInline]
|
||||
class DeliveryTripInline(admin.TabularInline):
|
||||
model = DeliveryTrip
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(DeliveryPlan)
|
||||
class DeliveryPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'created_at']
|
||||
list_filter = ['year']
|
||||
inlines = [DeliveryGroupInline, DeliveryTripInline]
|
||||
|
||||
|
||||
@admin.register(DeliveryGroup)
|
||||
class DeliveryGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'delivery_plan', 'order']
|
||||
inlines = [DeliveryGroupFieldInline]
|
||||
|
||||
|
||||
@admin.register(DeliveryTrip)
|
||||
class DeliveryTripAdmin(admin.ModelAdmin):
|
||||
list_display = ['delivery_plan', 'order', 'name', 'date']
|
||||
inlines = [DeliveryTripItemInline]
|
||||
|
||||
|
||||
class SpreadingSessionItemInline(admin.TabularInline):
|
||||
model = SpreadingSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(SpreadingSession)
|
||||
class SpreadingSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['year', 'date', 'name']
|
||||
list_filter = ['year', 'date']
|
||||
inlines = [SpreadingSessionItemInline]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0003_distributionplan_distributiongroup_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='fertilizationplan',
|
||||
name='calc_settings',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='計算設定'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_materials_for_existing_fertilizers(apps, schema_editor):
|
||||
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
|
||||
Material = apps.get_model('materials', 'Material')
|
||||
FertilizerProfile = apps.get_model('materials', 'FertilizerProfile')
|
||||
|
||||
for fertilizer in Fertilizer.objects.all():
|
||||
material = Material.objects.create(
|
||||
name=fertilizer.name,
|
||||
material_type='fertilizer',
|
||||
maker=fertilizer.maker or '',
|
||||
stock_unit='bag',
|
||||
is_active=True,
|
||||
notes=fertilizer.notes or '',
|
||||
)
|
||||
FertilizerProfile.objects.create(
|
||||
material=material,
|
||||
capacity_kg=fertilizer.capacity_kg,
|
||||
nitrogen_pct=fertilizer.nitrogen_pct,
|
||||
phosphorus_pct=fertilizer.phosphorus_pct,
|
||||
potassium_pct=fertilizer.potassium_pct,
|
||||
)
|
||||
fertilizer.material = material
|
||||
fertilizer.save(update_fields=['material'])
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
|
||||
Fertilizer.objects.all().update(material=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0004_fertilizationplan_calc_settings'),
|
||||
('materials', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='fertilizer',
|
||||
name='material',
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='legacy_fertilizer',
|
||||
to='materials.material',
|
||||
verbose_name='資材マスタ',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(create_materials_for_existing_fertilizers, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0005_fertilizer_material'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='fertilizationplan',
|
||||
name='is_confirmed',
|
||||
field=models.BooleanField(default=False, verbose_name='散布確定済み'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='fertilizationplan',
|
||||
name='confirmed_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='散布確定日時'),
|
||||
),
|
||||
]
|
||||
127
backend/apps/fertilizer/migrations/0007_delivery_models.py
Normal file
127
backend/apps/fertilizer/migrations/0007_delivery_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Generated by Django 5.0 on 2026-03-16 07:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0006_fertilizationplan_confirmation'),
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DeliveryGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='グループ名')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='表示順')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '配送先グループ',
|
||||
'verbose_name_plural': '配送先グループ',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryPlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('name', models.CharField(max_length=200, verbose_name='計画名')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '運搬計画',
|
||||
'verbose_name_plural': '運搬計画',
|
||||
'ordering': ['-year', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='distributiongroupfield',
|
||||
name='group',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='distributiongroupfield',
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='distributiongroupfield',
|
||||
name='distribution_plan',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='distributiongroupfield',
|
||||
name='field',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='distributionplan',
|
||||
name='fertilization_plan',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryGroupField',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_assignments', to='fertilizer.deliverygroup', verbose_name='グループ')),
|
||||
('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.deliveryplan', verbose_name='運搬計画')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'グループ圃場割り当て',
|
||||
'verbose_name_plural': 'グループ圃場割り当て',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('delivery_plan', 'field')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deliverygroup',
|
||||
name='delivery_plan',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.deliveryplan', verbose_name='運搬計画'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryTrip',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='何回目')),
|
||||
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
|
||||
('date', models.DateField(blank=True, null=True, verbose_name='運搬日')),
|
||||
('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trips', to='fertilizer.deliveryplan', verbose_name='運搬計画')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '運搬回',
|
||||
'verbose_name_plural': '運搬回',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryTripItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='袋数')),
|
||||
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('trip', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.deliverytrip', verbose_name='運搬回')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '運搬明細',
|
||||
'verbose_name_plural': '運搬明細',
|
||||
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
|
||||
'unique_together': {('trip', 'field', 'fertilizer')},
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='DistributionGroup',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='DistributionGroupField',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='DistributionPlan',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='deliverygroup',
|
||||
unique_together={('delivery_plan', 'name')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0007_delivery_models'),
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SpreadingSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='散布日')),
|
||||
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '散布実績',
|
||||
'verbose_name_plural': '散布実績',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='fertilizationentry',
|
||||
name='actual_bags',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='実績袋数'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SpreadingSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('actual_bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='実散布袋数')),
|
||||
('planned_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='計画袋数スナップショット')),
|
||||
('delivered_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='運搬済み袋数スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.spreadingsession', verbose_name='散布実績')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '散布実績明細',
|
||||
'verbose_name_plural': '散布実績明細',
|
||||
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
|
||||
'unique_together': {('session', 'field', 'fertilizer')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -17,6 +17,14 @@ class Fertilizer(models.Model):
|
||||
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
||||
)
|
||||
notes = models.TextField(blank=True, null=True, verbose_name='備考')
|
||||
material = models.OneToOneField(
|
||||
'materials.Material',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='legacy_fertilizer',
|
||||
verbose_name='資材マスタ',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '肥料マスタ'
|
||||
@@ -34,6 +42,9 @@ class FertilizationPlan(models.Model):
|
||||
'plans.Variety', on_delete=models.PROTECT,
|
||||
related_name='fertilization_plans', verbose_name='品種'
|
||||
)
|
||||
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
|
||||
is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み')
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -58,6 +69,13 @@ class FertilizationEntry(models.Model):
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||
actual_bags = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='実績袋数',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '施肥エントリ'
|
||||
@@ -69,51 +87,48 @@ class FertilizationEntry(models.Model):
|
||||
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋"
|
||||
|
||||
|
||||
class DistributionPlan(models.Model):
|
||||
"""分配計画:施肥計画の圃場をカスタムグループに割り当て、配置場所単位で集計する"""
|
||||
fertilization_plan = models.ForeignKey(
|
||||
FertilizationPlan, on_delete=models.CASCADE,
|
||||
related_name='distribution_plans', verbose_name='施肥計画'
|
||||
)
|
||||
class DeliveryPlan(models.Model):
|
||||
"""運搬計画:施肥計画の肥料を軽トラで運ぶ単位で計画・記録する"""
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '分配計画'
|
||||
verbose_name_plural = '分配計画'
|
||||
ordering = ['-fertilization_plan__year', 'name']
|
||||
verbose_name = '運搬計画'
|
||||
verbose_name_plural = '運搬計画'
|
||||
ordering = ['-year', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.fertilization_plan.year} {self.name}"
|
||||
return f"{self.year} {self.name}"
|
||||
|
||||
|
||||
class DistributionGroup(models.Model):
|
||||
"""分配グループ:ある場所にまとめて置く圃場のグループ"""
|
||||
distribution_plan = models.ForeignKey(
|
||||
DistributionPlan, on_delete=models.CASCADE,
|
||||
related_name='groups', verbose_name='分配計画'
|
||||
class DeliveryGroup(models.Model):
|
||||
"""配送先グループ:まとめて運ぶ圃場のグループ"""
|
||||
delivery_plan = models.ForeignKey(
|
||||
DeliveryPlan, on_delete=models.CASCADE,
|
||||
related_name='groups', verbose_name='運搬計画'
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name='グループ名')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='表示順')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '分配グループ'
|
||||
verbose_name_plural = '分配グループ'
|
||||
unique_together = [['distribution_plan', 'name']]
|
||||
verbose_name = '配送先グループ'
|
||||
verbose_name_plural = '配送先グループ'
|
||||
unique_together = [['delivery_plan', 'name']]
|
||||
ordering = ['order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.distribution_plan} / {self.name}"
|
||||
return f"{self.delivery_plan} / {self.name}"
|
||||
|
||||
|
||||
class DistributionGroupField(models.Model):
|
||||
"""圃場のグループへの割り当て(1圃場=1グループ/1分配計画)"""
|
||||
distribution_plan = models.ForeignKey(
|
||||
DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
|
||||
class DeliveryGroupField(models.Model):
|
||||
"""圃場のグループへの割り当て(1圃場=1グループ/1運搬計画)"""
|
||||
delivery_plan = models.ForeignKey(
|
||||
DeliveryPlan, on_delete=models.CASCADE, verbose_name='運搬計画'
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
DistributionGroup, on_delete=models.CASCADE,
|
||||
DeliveryGroup, on_delete=models.CASCADE,
|
||||
related_name='field_assignments', verbose_name='グループ'
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
@@ -123,8 +138,111 @@ class DistributionGroupField(models.Model):
|
||||
class Meta:
|
||||
verbose_name = 'グループ圃場割り当て'
|
||||
verbose_name_plural = 'グループ圃場割り当て'
|
||||
unique_together = [['distribution_plan', 'field']]
|
||||
unique_together = [['delivery_plan', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.group.name} / {self.field.name}"
|
||||
|
||||
|
||||
class DeliveryTrip(models.Model):
|
||||
"""運搬回:軽トラ1回分の積載"""
|
||||
delivery_plan = models.ForeignKey(
|
||||
DeliveryPlan, on_delete=models.CASCADE,
|
||||
related_name='trips', verbose_name='運搬計画'
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='何回目')
|
||||
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
|
||||
date = models.DateField(null=True, blank=True, verbose_name='運搬日')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '運搬回'
|
||||
verbose_name_plural = '運搬回'
|
||||
ordering = ['order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.delivery_plan} / {self.order + 1}回目"
|
||||
|
||||
|
||||
class DeliveryTripItem(models.Model):
|
||||
"""運搬明細:圃場×肥料単位の袋数"""
|
||||
trip = models.ForeignKey(
|
||||
DeliveryTrip, on_delete=models.CASCADE,
|
||||
related_name='items', verbose_name='運搬回'
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
|
||||
)
|
||||
fertilizer = models.ForeignKey(
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='袋数')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '運搬明細'
|
||||
verbose_name_plural = '運搬明細'
|
||||
unique_together = [['trip', 'field', 'fertilizer']]
|
||||
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}袋"
|
||||
|
||||
|
||||
class SpreadingSession(models.Model):
|
||||
"""散布日単位の実績"""
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='散布日')
|
||||
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '散布実績'
|
||||
verbose_name_plural = '散布実績'
|
||||
ordering = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
label = self.name.strip() or f'{self.date}'
|
||||
return f'{self.year} {label}'
|
||||
|
||||
|
||||
class SpreadingSessionItem(models.Model):
|
||||
"""散布実績明細:圃場×肥料ごとの実績"""
|
||||
session = models.ForeignKey(
|
||||
SpreadingSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='散布実績',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
|
||||
)
|
||||
fertilizer = models.ForeignKey(
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
actual_bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='実散布袋数')
|
||||
planned_bags_snapshot = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
verbose_name='計画袋数スナップショット',
|
||||
)
|
||||
delivered_bags_snapshot = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
verbose_name='運搬済み袋数スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '散布実績明細'
|
||||
verbose_name_plural = '散布実績明細'
|
||||
unique_together = [['session', 'field', 'fertilizer']]
|
||||
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'{self.session} / {self.field.name} / '
|
||||
f'{self.fertilizer.name}: {self.actual_bags}袋'
|
||||
)
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum
|
||||
from rest_framework import serializers
|
||||
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
|
||||
|
||||
from apps.workrecords.services import sync_delivery_work_record
|
||||
from .models import (
|
||||
DeliveryGroup,
|
||||
DeliveryGroupField,
|
||||
DeliveryPlan,
|
||||
DeliveryTrip,
|
||||
DeliveryTripItem,
|
||||
FertilizationEntry,
|
||||
FertilizationPlan,
|
||||
Fertilizer,
|
||||
SpreadingSession,
|
||||
SpreadingSessionItem,
|
||||
)
|
||||
from .services import sync_actual_bags_for_pairs, sync_spreading_session_side_effects
|
||||
|
||||
|
||||
class FertilizerSerializer(serializers.ModelSerializer):
|
||||
material_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
@@ -17,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = FertilizationEntry
|
||||
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'actual_bags',
|
||||
]
|
||||
|
||||
|
||||
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
@@ -26,12 +68,36 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
fertilizer_count = serializers.SerializerMethodField()
|
||||
planned_total_bags = serializers.SerializerMethodField()
|
||||
spread_total_bags = serializers.SerializerMethodField()
|
||||
remaining_total_bags = serializers.SerializerMethodField()
|
||||
spread_status = serializers.SerializerMethodField()
|
||||
is_confirmed = serializers.BooleanField(read_only=True)
|
||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||
is_variety_change_plan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = [
|
||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
||||
'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'calc_settings',
|
||||
'entries',
|
||||
'field_count',
|
||||
'fertilizer_count',
|
||||
'planned_total_bags',
|
||||
'spread_total_bags',
|
||||
'remaining_total_bags',
|
||||
'spread_status',
|
||||
'is_confirmed',
|
||||
'confirmed_at',
|
||||
'is_variety_change_plan',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_variety_name(self, obj):
|
||||
@@ -46,19 +112,46 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
def get_fertilizer_count(self, obj):
|
||||
return obj.entries.values('fertilizer').distinct().count()
|
||||
|
||||
def get_planned_total_bags(self, obj):
|
||||
total = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_spread_total_bags(self, obj):
|
||||
total = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_remaining_total_bags(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(planned - actual)
|
||||
|
||||
def get_spread_status(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
if actual <= 0:
|
||||
return 'unspread'
|
||||
if actual > planned:
|
||||
return 'over_applied'
|
||||
if actual < planned:
|
||||
return 'partial'
|
||||
return 'completed'
|
||||
|
||||
def get_is_variety_change_plan(self, obj):
|
||||
return obj.name.endswith('(品種変更移動)')
|
||||
|
||||
|
||||
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
"""保存用(entries を一括で受け取る)"""
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = ['id', 'name', 'year', 'variety', 'entries']
|
||||
fields = ['id', 'name', 'year', 'variety', 'calc_settings', 'entries']
|
||||
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = FertilizationPlan.objects.create(**validated_data)
|
||||
self._save_entries(plan, entries_data)
|
||||
pairs = self._save_entries(plan, entries_data)
|
||||
sync_actual_bags_for_pairs(plan.year, pairs)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -68,22 +161,24 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
self._save_entries(instance, entries_data)
|
||||
pairs = self._save_entries(instance, entries_data)
|
||||
sync_actual_bags_for_pairs(instance.year, pairs)
|
||||
return instance
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
pairs = set()
|
||||
for entry in entries_data:
|
||||
pairs.add((entry['field_id'], entry['fertilizer_id']))
|
||||
FertilizationEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
fertilizer_id=entry['fertilizer_id'],
|
||||
bags=entry['bags'],
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
# ─── 分配計画 ────────────────────────────────────────────────────────────
|
||||
|
||||
class DistributionGroupFieldSerializer(serializers.ModelSerializer):
|
||||
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(source='field.id', read_only=True)
|
||||
name = serializers.CharField(source='field.name', read_only=True)
|
||||
area_tan = serializers.DecimalField(
|
||||
@@ -91,128 +186,307 @@ class DistributionGroupFieldSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DistributionGroupField
|
||||
model = DeliveryGroupField
|
||||
fields = ['id', 'name', 'area_tan']
|
||||
|
||||
|
||||
class DistributionGroupReadSerializer(serializers.ModelSerializer):
|
||||
fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
|
||||
class DeliveryGroupReadSerializer(serializers.ModelSerializer):
|
||||
fields = DeliveryGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DistributionGroup
|
||||
model = DeliveryGroup
|
||||
fields = ['id', 'name', 'order', 'fields']
|
||||
|
||||
|
||||
class FertilizationPlanForDistributionSerializer(serializers.ModelSerializer):
|
||||
"""分配計画詳細に埋め込む施肥計画情報(肥料一覧・entries 含む)"""
|
||||
variety_name = serializers.SerializerMethodField()
|
||||
crop_name = serializers.SerializerMethodField()
|
||||
fertilizers = serializers.SerializerMethodField()
|
||||
entries = serializers.SerializerMethodField()
|
||||
class DeliveryTripItemSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
spread_bags = serializers.SerializerMethodField()
|
||||
remaining_bags = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = ['id', 'name', 'year', 'variety_name', 'crop_name', 'fertilizers', 'entries']
|
||||
|
||||
def get_variety_name(self, obj):
|
||||
return obj.variety.name
|
||||
|
||||
def get_crop_name(self, obj):
|
||||
return obj.variety.crop.name
|
||||
|
||||
def get_fertilizers(self, obj):
|
||||
fert_ids = obj.entries.values_list('fertilizer_id', flat=True).distinct()
|
||||
from .models import Fertilizer as F
|
||||
fertilizers = F.objects.filter(id__in=fert_ids).order_by('name')
|
||||
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
||||
|
||||
def get_entries(self, obj):
|
||||
return [
|
||||
{'field': e.field_id, 'fertilizer': e.fertilizer_id, 'bags': str(e.bags)}
|
||||
for e in obj.entries.all()
|
||||
]
|
||||
|
||||
|
||||
class DistributionPlanListSerializer(serializers.ModelSerializer):
|
||||
fertilization_plan_id = serializers.IntegerField(source='fertilization_plan.id', read_only=True)
|
||||
fertilization_plan_name = serializers.CharField(source='fertilization_plan.name', read_only=True)
|
||||
year = serializers.IntegerField(source='fertilization_plan.year', read_only=True)
|
||||
variety_name = serializers.SerializerMethodField()
|
||||
crop_name = serializers.SerializerMethodField()
|
||||
group_count = serializers.SerializerMethodField()
|
||||
field_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DistributionPlan
|
||||
model = DeliveryTripItem
|
||||
fields = [
|
||||
'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
|
||||
'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
|
||||
'created_at', 'updated_at',
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'spread_bags',
|
||||
'remaining_bags',
|
||||
]
|
||||
|
||||
def get_variety_name(self, obj):
|
||||
return obj.fertilization_plan.variety.name
|
||||
def get_spread_bags(self, obj):
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=obj.trip.delivery_plan.year,
|
||||
field_id=obj.field_id,
|
||||
fertilizer_id=obj.fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
return str(total or Decimal('0'))
|
||||
|
||||
def get_crop_name(self, obj):
|
||||
return obj.fertilization_plan.variety.crop.name
|
||||
def get_remaining_bags(self, obj):
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=obj.trip.delivery_plan.year,
|
||||
field_id=obj.field_id,
|
||||
fertilizer_id=obj.fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
spread_total = total or Decimal('0')
|
||||
return str(obj.bags - spread_total)
|
||||
|
||||
|
||||
class DeliveryTripReadSerializer(serializers.ModelSerializer):
|
||||
items = DeliveryTripItemSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeliveryTrip
|
||||
fields = ['id', 'order', 'name', 'date', 'work_record_id', 'items']
|
||||
|
||||
|
||||
class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
||||
group_count = serializers.SerializerMethodField()
|
||||
trip_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DeliveryPlan
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'name',
|
||||
'group_count',
|
||||
'trip_count',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_group_count(self, obj):
|
||||
return obj.groups.count()
|
||||
|
||||
def get_field_count(self, obj):
|
||||
return obj.distributiongroupfield_set.count()
|
||||
def get_trip_count(self, obj):
|
||||
return obj.trips.count()
|
||||
|
||||
|
||||
class DistributionPlanReadSerializer(serializers.ModelSerializer):
|
||||
fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
|
||||
groups = DistributionGroupReadSerializer(many=True, read_only=True)
|
||||
class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
||||
groups = DeliveryGroupReadSerializer(many=True, read_only=True)
|
||||
trips = DeliveryTripReadSerializer(many=True, read_only=True)
|
||||
unassigned_fields = serializers.SerializerMethodField()
|
||||
available_fertilizers = serializers.SerializerMethodField()
|
||||
all_entries = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DistributionPlan
|
||||
fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
|
||||
model = DeliveryPlan
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'name',
|
||||
'groups',
|
||||
'trips',
|
||||
'unassigned_fields',
|
||||
'available_fertilizers',
|
||||
'all_entries',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_unassigned_fields(self, obj):
|
||||
assigned_ids = obj.distributiongroupfield_set.values_list('field_id', flat=True)
|
||||
plan_field_ids = obj.fertilization_plan.entries.values_list('field_id', flat=True).distinct()
|
||||
from apps.fields.models import Field as F
|
||||
unassigned = F.objects.filter(id__in=plan_field_ids).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
||||
assigned_ids = DeliveryGroupField.objects.filter(
|
||||
delivery_plan=obj
|
||||
).values_list('field_id', flat=True)
|
||||
plan_field_ids = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).values_list('field_id', flat=True).distinct()
|
||||
from apps.fields.models import Field
|
||||
|
||||
unassigned = Field.objects.filter(
|
||||
id__in=plan_field_ids
|
||||
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
||||
return [{'id': f.id, 'name': f.name, 'area_tan': str(f.area_tan)} for f in unassigned]
|
||||
|
||||
def get_available_fertilizers(self, obj):
|
||||
fert_ids = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).values_list('fertilizer_id', flat=True).distinct()
|
||||
fertilizers = Fertilizer.objects.filter(id__in=fert_ids).order_by('name')
|
||||
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
||||
|
||||
class DistributionPlanWriteSerializer(serializers.ModelSerializer):
|
||||
fertilization_plan_id = serializers.IntegerField(write_only=True)
|
||||
def get_all_entries(self, obj):
|
||||
entries = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).select_related('field', 'fertilizer')
|
||||
return [
|
||||
{
|
||||
'field': entry.field_id,
|
||||
'field_name': entry.field.name,
|
||||
'field_area_tan': str(entry.field.area_tan),
|
||||
'fertilizer': entry.fertilizer_id,
|
||||
'fertilizer_name': entry.fertilizer.name,
|
||||
'bags': str(entry.bags),
|
||||
'actual_bags': str(entry.actual_bags) if entry.actual_bags is not None else None,
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
|
||||
class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
|
||||
groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
trips = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = DistributionPlan
|
||||
fields = ['id', 'name', 'fertilization_plan_id', 'groups']
|
||||
model = DeliveryPlan
|
||||
fields = ['id', 'year', 'name', 'groups', 'trips']
|
||||
|
||||
def create(self, validated_data):
|
||||
groups_data = validated_data.pop('groups', [])
|
||||
plan = DistributionPlan.objects.create(**validated_data)
|
||||
trips_data = validated_data.pop('trips', [])
|
||||
plan = DeliveryPlan.objects.create(**validated_data)
|
||||
self._save_groups(plan, groups_data)
|
||||
self._save_trips(plan, trips_data)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
groups_data = validated_data.pop('groups', None)
|
||||
trips_data = validated_data.pop('trips', None)
|
||||
instance.name = validated_data.get('name', instance.name)
|
||||
instance.year = validated_data.get('year', instance.year)
|
||||
instance.save()
|
||||
if groups_data is not None:
|
||||
instance.groups.all().delete()
|
||||
self._save_groups(instance, groups_data)
|
||||
if trips_data is not None:
|
||||
instance.trips.all().delete()
|
||||
self._save_trips(instance, trips_data)
|
||||
return instance
|
||||
|
||||
def _save_groups(self, plan, groups_data):
|
||||
for g_data in groups_data:
|
||||
group = DistributionGroup.objects.create(
|
||||
distribution_plan=plan,
|
||||
name=g_data['name'],
|
||||
order=g_data.get('order', 0),
|
||||
for group_data in groups_data:
|
||||
group = DeliveryGroup.objects.create(
|
||||
delivery_plan=plan,
|
||||
name=group_data['name'],
|
||||
order=group_data.get('order', 0),
|
||||
)
|
||||
for field_id in g_data.get('field_ids', []):
|
||||
DistributionGroupField.objects.create(
|
||||
distribution_plan=plan,
|
||||
for field_id in group_data.get('field_ids', []):
|
||||
DeliveryGroupField.objects.create(
|
||||
delivery_plan=plan,
|
||||
group=group,
|
||||
field_id=field_id,
|
||||
)
|
||||
|
||||
def _save_trips(self, plan, trips_data):
|
||||
for trip_data in trips_data:
|
||||
trip = DeliveryTrip.objects.create(
|
||||
delivery_plan=plan,
|
||||
order=trip_data.get('order', 0),
|
||||
name=trip_data.get('name', ''),
|
||||
date=trip_data.get('date'),
|
||||
)
|
||||
for item in trip_data.get('items', []):
|
||||
DeliveryTripItem.objects.create(
|
||||
trip=trip,
|
||||
field_id=item['field_id'],
|
||||
fertilizer_id=item['fertilizer_id'],
|
||||
bags=item['bags'],
|
||||
)
|
||||
sync_delivery_work_record(trip)
|
||||
|
||||
|
||||
class SpreadingSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'actual_bags',
|
||||
'planned_bags_snapshot',
|
||||
'delivered_bags_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class SpreadingSessionSerializer(serializers.ModelSerializer):
|
||||
items = SpreadingSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'name',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
|
||||
class SpreadingSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field_id = serializers.IntegerField()
|
||||
fertilizer_id = serializers.IntegerField()
|
||||
actual_bags = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
planned_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
delivered_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
|
||||
|
||||
class SpreadingSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = SpreadingSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSession
|
||||
fields = ['id', 'year', 'date', 'name', 'notes', 'items']
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
if item['actual_bags'] <= 0:
|
||||
raise serializers.ValidationError('actual_bags は 0 より大きい値を指定してください。')
|
||||
key = (item['field_id'], item['fertilizer_id'])
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で field + fertilizer を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
session = SpreadingSession.objects.create(**validated_data)
|
||||
new_pairs = self._replace_items(session, items_data)
|
||||
sync_spreading_session_side_effects(session, new_pairs)
|
||||
return session
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
old_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
new_pairs = self._replace_items(instance, items_data)
|
||||
sync_spreading_session_side_effects(instance, old_pairs | new_pairs)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
new_pairs = set()
|
||||
for item in items_data:
|
||||
new_pairs.add((item['field_id'], item['fertilizer_id']))
|
||||
SpreadingSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field_id'],
|
||||
fertilizer_id=item['fertilizer_id'],
|
||||
actual_bags=item['actual_bags'],
|
||||
planned_bags_snapshot=item['planned_bags_snapshot'],
|
||||
delivered_bags_snapshot=item['delivered_bags_snapshot'],
|
||||
)
|
||||
return new_pairs
|
||||
|
||||
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.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||
router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
|
||||
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
||||
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||
path('spreading/candidates/', views.SpreadingCandidatesView.as_view(), name='spreading-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from rest_framework import viewsets, status
|
||||
@@ -10,15 +11,31 @@ from rest_framework.views import APIView
|
||||
from weasyprint import HTML
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.plans.models import Plan, Variety
|
||||
from .models import Fertilizer, FertilizationPlan, DistributionPlan
|
||||
from apps.materials.stock_service import (
|
||||
create_reserves_for_plan,
|
||||
delete_reserves_for_plan,
|
||||
)
|
||||
from apps.plans.models import Plan
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
from .serializers import (
|
||||
FertilizerSerializer,
|
||||
FertilizationPlanSerializer,
|
||||
FertilizationPlanWriteSerializer,
|
||||
DistributionPlanListSerializer,
|
||||
DistributionPlanReadSerializer,
|
||||
DistributionPlanWriteSerializer,
|
||||
DeliveryPlanListSerializer,
|
||||
DeliveryPlanReadSerializer,
|
||||
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):
|
||||
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')
|
||||
if year:
|
||||
@@ -45,6 +62,18 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
return FertilizationPlanWriteSerializer
|
||||
return FertilizationPlanSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
delete_reserves_for_plan(instance)
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def pdf(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
@@ -99,6 +128,54 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def merge_targets(self, request, pk=None):
|
||||
source_plan = self.get_object()
|
||||
targets = (
|
||||
FertilizationPlan.objects
|
||||
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
|
||||
.exclude(id=source_plan.id)
|
||||
.prefetch_related('entries')
|
||||
.order_by('-updated_at', 'id')
|
||||
)
|
||||
data = [
|
||||
{
|
||||
'id': plan.id,
|
||||
'name': plan.name,
|
||||
'field_count': plan.entries.values('field').distinct().count(),
|
||||
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
|
||||
'is_confirmed': plan.is_confirmed,
|
||||
}
|
||||
for plan in targets
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def merge_into(self, request, pk=None):
|
||||
source_plan = self.get_object()
|
||||
target_plan_id = request.data.get('target_plan_id')
|
||||
if not target_plan_id:
|
||||
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
|
||||
except FertilizationPlan.DoesNotExist:
|
||||
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
result = merge_fertilization_plan_into(source_plan, target_plan)
|
||||
except FertilizationPlanMergeConflict as exc:
|
||||
return Response(
|
||||
{
|
||||
'error': '競合する圃場・肥料があるためマージできません。',
|
||||
'conflicts': exc.conflicts,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except FertilizationPlanMergeError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(result)
|
||||
|
||||
class CandidateFieldsView(APIView):
|
||||
"""作付け計画から圃場候補を返す"""
|
||||
@@ -199,126 +276,373 @@ class CalculateView(APIView):
|
||||
return Response(results)
|
||||
|
||||
|
||||
class DistributionPlanViewSet(viewsets.ModelViewSet):
|
||||
class DeliveryPlanViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = DistributionPlan.objects.select_related(
|
||||
'fertilization_plan', 'fertilization_plan__variety', 'fertilization_plan__variety__crop'
|
||||
).prefetch_related(
|
||||
qs = DeliveryPlan.objects.prefetch_related(
|
||||
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
|
||||
'fertilization_plan__entries', 'fertilization_plan__entries__field',
|
||||
'fertilization_plan__entries__fertilizer',
|
||||
'distributiongroupfield_set',
|
||||
'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
|
||||
)
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
qs = qs.filter(fertilization_plan__year=year)
|
||||
qs = qs.filter(year=year)
|
||||
return qs
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return DistributionPlanWriteSerializer
|
||||
return DeliveryPlanWriteSerializer
|
||||
if self.action == 'list':
|
||||
return DistributionPlanListSerializer
|
||||
return DistributionPlanReadSerializer
|
||||
return DeliveryPlanListSerializer
|
||||
return DeliveryPlanReadSerializer
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def pdf(self, request, pk=None):
|
||||
dist_plan = self.get_object()
|
||||
fert_plan = dist_plan.fertilization_plan
|
||||
plan = self.get_object()
|
||||
|
||||
# 施肥計画の肥料一覧(名前順)
|
||||
fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
|
||||
# 全tripのitemから使用肥料を収集
|
||||
all_items = DeliveryTripItem.objects.filter(
|
||||
trip__delivery_plan=plan
|
||||
).select_related('field', 'fertilizer')
|
||||
|
||||
fert_ids = all_items.values_list('fertilizer_id', flat=True).distinct()
|
||||
fertilizers = sorted(
|
||||
Fertilizer.objects.filter(id__in=fert_ids),
|
||||
key=lambda f: f.name
|
||||
)
|
||||
|
||||
# entries を (field_id, fertilizer_id) → bags のマトリクスに変換
|
||||
entry_map = {}
|
||||
for e in fert_plan.entries.all():
|
||||
entry_map[(e.field_id, e.fertilizer_id)] = e.bags
|
||||
# グループ情報: field_id → group_name
|
||||
field_group_map = {}
|
||||
for gf in DeliveryGroupField.objects.filter(
|
||||
delivery_plan=plan
|
||||
).select_related('group', 'field'):
|
||||
field_group_map[gf.field_id] = gf.group
|
||||
|
||||
# 回ごとにページを構築
|
||||
trip_pages = []
|
||||
for trip in plan.trips.prefetch_related('items__field', 'items__fertilizer').all():
|
||||
items = trip.items.all()
|
||||
if not items:
|
||||
continue
|
||||
|
||||
# この回の肥料一覧
|
||||
trip_fert_ids = set(item.fertilizer_id for item in items)
|
||||
trip_fertilizers = [f for f in fertilizers if f.id in trip_fert_ids]
|
||||
|
||||
# items を (field_id, fertilizer_id) → bags のマトリクスに変換
|
||||
item_map = {}
|
||||
for item in items:
|
||||
item_map[(item.field_id, item.fertilizer_id)] = item.bags
|
||||
|
||||
# グループごとにまとめる
|
||||
groups_dict = {} # group_name → {'group': group, 'fields': [field, ...]}
|
||||
ungrouped_fields = []
|
||||
for item in items:
|
||||
group = field_group_map.get(item.field_id)
|
||||
if group:
|
||||
if group.name not in groups_dict:
|
||||
groups_dict[group.name] = {'group': group, 'fields': []}
|
||||
if item.field not in groups_dict[group.name]['fields']:
|
||||
groups_dict[group.name]['fields'].append(item.field)
|
||||
else:
|
||||
if item.field not in ungrouped_fields:
|
||||
ungrouped_fields.append(item.field)
|
||||
|
||||
# グループを order 順にソート
|
||||
sorted_groups = sorted(groups_dict.values(), key=lambda g: (g['group'].order, g['group'].id))
|
||||
|
||||
# グループ行の構築
|
||||
groups = dist_plan.groups.prefetch_related('field_assignments__field').all()
|
||||
group_rows = []
|
||||
for group in groups:
|
||||
fields_in_group = [
|
||||
a.field for a in group.field_assignments.select_related('field').order_by('field__display_order', 'field__id')
|
||||
]
|
||||
# グループ合計(肥料ごと)
|
||||
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 fertilizers:
|
||||
for fert in trip_fertilizers:
|
||||
total = sum(
|
||||
entry_map.get((f.id, fert.id), Decimal('0'))
|
||||
item_map.get((f.id, fert.id), Decimal('0'))
|
||||
for f in fields_in_group
|
||||
)
|
||||
group_totals.append(total)
|
||||
group_row_total = sum(group_totals)
|
||||
|
||||
# 圃場サブ行
|
||||
field_rows = []
|
||||
for field in fields_in_group:
|
||||
cells = [entry_map.get((field.id, fert.id), '') for fert in fertilizers]
|
||||
row_total = sum(v for v in cells if v != '')
|
||||
field_rows.append({'field': field, 'cells': cells, 'total': row_total})
|
||||
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
|
||||
field_rows.append({'field': field, 'cells': cells})
|
||||
|
||||
group_rows.append({
|
||||
'name': group.name,
|
||||
'name': g_data['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': '未割り当て',
|
||||
# 未グループ圃場
|
||||
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,
|
||||
'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
|
||||
],
|
||||
}]
|
||||
'field_rows': field_rows,
|
||||
})
|
||||
|
||||
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))
|
||||
sum(r['totals'][i] for r in group_rows)
|
||||
for i in range(len(trip_fertilizers))
|
||||
]
|
||||
|
||||
context = {
|
||||
'dist_plan': dist_plan,
|
||||
'fert_plan': fert_plan,
|
||||
'fertilizers': fertilizers,
|
||||
'group_rows': all_group_rows,
|
||||
trip_pages.append({
|
||||
'trip': trip,
|
||||
'fertilizers': trip_fertilizers,
|
||||
'group_rows': group_rows,
|
||||
'fert_totals': fert_totals,
|
||||
'grand_total': sum(fert_totals),
|
||||
})
|
||||
|
||||
context = {
|
||||
'plan': plan,
|
||||
'trip_pages': trip_pages,
|
||||
}
|
||||
html_string = render_to_string('fertilizer/distribution_pdf.html', context)
|
||||
html_string = render_to_string('fertilizer/delivery_pdf.html', context)
|
||||
pdf_file = HTML(string=html_string).write_pdf()
|
||||
response = HttpResponse(pdf_file, content_type='application/pdf')
|
||||
response['Content-Disposition'] = (
|
||||
f'attachment; filename="distribution_{fert_plan.year}_{dist_plan.id}.pdf"'
|
||||
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SpreadingSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = SpreadingSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__fertilizer',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return SpreadingSessionWriteSerializer
|
||||
return SpreadingSessionSerializer
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
from apps.materials.models import StockTransaction
|
||||
year = instance.year
|
||||
affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||
StockTransaction.objects.filter(spreading_item__session=instance).delete()
|
||||
instance.delete()
|
||||
sync_actual_bags_for_pairs(year, affected_pairs)
|
||||
|
||||
|
||||
class SpreadingCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
session_id = request.query_params.get('session_id')
|
||||
delivery_plan_id = request.query_params.get('delivery_plan_id')
|
||||
plan_id = request.query_params.get('plan_id')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if delivery_plan_id:
|
||||
try:
|
||||
delivery_plan_id = int(delivery_plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'delivery_plan_id は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if plan_id:
|
||||
try:
|
||||
plan_id = int(plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'plan_id は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_session = None
|
||||
current_map = {}
|
||||
if session_id:
|
||||
try:
|
||||
current_session = SpreadingSession.objects.prefetch_related('items').get(
|
||||
pk=session_id,
|
||||
year=year,
|
||||
)
|
||||
except SpreadingSession.DoesNotExist:
|
||||
return Response(
|
||||
{'detail': '散布実績が見つかりません。'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
current_map = {
|
||||
(item.field_id, item.fertilizer_id): {
|
||||
'actual_bags': item.actual_bags,
|
||||
'field_name': item.field.name,
|
||||
'field_area_tan': str(item.field.area_tan),
|
||||
'fertilizer_name': item.fertilizer.name,
|
||||
}
|
||||
for item in current_session.items.all()
|
||||
}
|
||||
|
||||
candidates = {}
|
||||
|
||||
plan_queryset = FertilizationEntry.objects.filter(plan__year=year)
|
||||
if plan_id:
|
||||
plan_queryset = plan_queryset.filter(plan_id=plan_id)
|
||||
plan_rows = (
|
||||
plan_queryset
|
||||
.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
)
|
||||
.annotate(planned_bags=Sum('bags'))
|
||||
)
|
||||
for row in plan_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['planned_bags'] = row['planned_bags'] or Decimal('0')
|
||||
|
||||
delivery_queryset = DeliveryTripItem.objects.filter(trip__delivery_plan__year=year)
|
||||
if delivery_plan_id:
|
||||
delivery_queryset = delivery_queryset.filter(trip__delivery_plan_id=delivery_plan_id)
|
||||
else:
|
||||
delivery_queryset = delivery_queryset.filter(trip__date__isnull=False)
|
||||
delivery_rows = delivery_queryset.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
).annotate(delivered_bags=Sum('bags'))
|
||||
for row in delivery_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['delivered_bags'] = row['delivered_bags'] or Decimal('0')
|
||||
|
||||
spread_queryset = SpreadingSessionItem.objects.filter(session__year=year)
|
||||
if current_session is not None:
|
||||
spread_queryset = spread_queryset.exclude(session=current_session)
|
||||
spread_rows = (
|
||||
spread_queryset
|
||||
.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
)
|
||||
.annotate(spread_bags=Sum('actual_bags'))
|
||||
)
|
||||
for row in spread_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['spread_bags'] = row['spread_bags'] or Decimal('0')
|
||||
|
||||
for key, current_data in current_map.items():
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': key[0],
|
||||
'field_name': current_data['field_name'],
|
||||
'field_area_tan': current_data['field_area_tan'],
|
||||
'fertilizer': key[1],
|
||||
'fertilizer_name': current_data['fertilizer_name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['current_session_bags'] = current_data['actual_bags'] or Decimal('0')
|
||||
|
||||
rows = []
|
||||
for candidate in candidates.values():
|
||||
delivered = candidate['delivered_bags']
|
||||
planned = candidate['planned_bags']
|
||||
current_bags = candidate['current_session_bags']
|
||||
if delivery_plan_id:
|
||||
include_row = delivered > 0 or current_bags > 0
|
||||
elif plan_id:
|
||||
include_row = planned > 0 or current_bags > 0
|
||||
else:
|
||||
include_row = delivered > 0 or current_bags > 0
|
||||
if not include_row:
|
||||
continue
|
||||
remaining = delivered - candidate['spread_bags']
|
||||
rows.append(
|
||||
{
|
||||
'field': candidate['field'],
|
||||
'field_name': candidate['field_name'],
|
||||
'field_area_tan': candidate['field_area_tan'],
|
||||
'fertilizer': candidate['fertilizer'],
|
||||
'fertilizer_name': candidate['fertilizer_name'],
|
||||
'planned_bags': str(planned),
|
||||
'delivered_bags': str(delivered),
|
||||
'spread_bags': str(candidate['spread_bags'] + current_bags),
|
||||
'spread_bags_other': str(candidate['spread_bags']),
|
||||
'current_session_bags': str(current_bags),
|
||||
'remaining_bags': str(remaining),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row['field_name'], row['fertilizer_name']))
|
||||
return Response(rows)
|
||||
|
||||
1
backend/apps/levee_work/__init__.py
Normal file
1
backend/apps/levee_work/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
backend/apps/levee_work/admin.py
Normal file
17
backend/apps/levee_work/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemInline(admin.TabularInline):
|
||||
model = LeveeWorkSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(LeveeWorkSession)
|
||||
class LeveeWorkSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['date', 'title', 'year', 'created_at']
|
||||
list_filter = ['year', 'date']
|
||||
search_fields = ['title', 'items__field__name']
|
||||
inlines = [LeveeWorkSessionItemInline]
|
||||
|
||||
8
backend/apps/levee_work/apps.py
Normal file
8
backend/apps/levee_work/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LeveeWorkConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.levee_work'
|
||||
verbose_name = '畔塗作業'
|
||||
|
||||
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2 on 2026-04-04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0004_crop_base_temp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='畔塗日')),
|
||||
('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗記録',
|
||||
'verbose_name_plural': '畔塗記録',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
|
||||
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗対象圃場',
|
||||
'verbose_name_plural': '畔塗対象圃場',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('session', 'field')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
1
backend/apps/levee_work/migrations/__init__.py
Normal file
1
backend/apps/levee_work/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
59
backend/apps/levee_work/models.py
Normal file
59
backend/apps/levee_work/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class LeveeWorkSession(models.Model):
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='畔塗日')
|
||||
title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗記録'
|
||||
verbose_name_plural = '畔塗記録'
|
||||
ordering = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.date} {self.title}'
|
||||
|
||||
|
||||
class LeveeWorkSessionItem(models.Model):
|
||||
session = models.ForeignKey(
|
||||
LeveeWorkSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='畔塗記録',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field',
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name='圃場',
|
||||
)
|
||||
plan = models.ForeignKey(
|
||||
'plans.Plan',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='+',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
|
||||
variety_name_snapshot = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='品種名スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗対象圃場'
|
||||
verbose_name_plural = '畔塗対象圃場'
|
||||
unique_together = [['session', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.session} / {self.field.name}'
|
||||
|
||||
149
backend/apps/levee_work/serializers.py
Normal file
149
backend/apps/levee_work/serializers.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from django.db import transaction
|
||||
from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from apps.workrecords.services import sync_levee_work_record
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'group_name',
|
||||
'plan',
|
||||
'crop_name_snapshot',
|
||||
'variety_name_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
item_count = serializers.SerializerMethodField()
|
||||
total_area_tan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'title',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'item_count',
|
||||
'total_area_tan',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_item_count(self, obj):
|
||||
return len(obj.items.all())
|
||||
|
||||
def get_total_area_tan(self, obj):
|
||||
total = sum((item.field.area_tan or Decimal('0')) for item in obj.items.all())
|
||||
return str(total)
|
||||
|
||||
|
||||
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field = serializers.IntegerField()
|
||||
plan = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
|
||||
|
||||
def validate(self, attrs):
|
||||
year = attrs.get('year', getattr(self.instance, 'year', None))
|
||||
date = attrs.get('date', getattr(self.instance, 'date', None))
|
||||
if year is not None and date is not None and year != date.year:
|
||||
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
|
||||
return attrs
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
key = item['field']
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
|
||||
session = LeveeWorkSession.objects.create(**validated_data)
|
||||
self._replace_items(session, items_data)
|
||||
sync_levee_work_record(session)
|
||||
return session
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', None)
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'title':
|
||||
value = (value or '').strip() or '水稲畔塗'
|
||||
setattr(instance, attr, value)
|
||||
if 'title' not in validated_data:
|
||||
instance.title = (instance.title or '').strip() or '水稲畔塗'
|
||||
instance.save()
|
||||
if items_data is not None:
|
||||
self._replace_items(instance, items_data)
|
||||
sync_levee_work_record(instance)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
for item in items_data:
|
||||
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field'],
|
||||
plan=plan,
|
||||
crop_name_snapshot=plan.crop.name,
|
||||
variety_name_snapshot=plan.variety.name if plan.variety else '',
|
||||
)
|
||||
|
||||
def _resolve_plan(self, year, field_id, plan_id):
|
||||
queryset = Plan.objects.select_related('crop', 'variety').filter(
|
||||
year=year,
|
||||
field_id=field_id,
|
||||
crop__name='水稲',
|
||||
)
|
||||
if plan_id is not None:
|
||||
try:
|
||||
return queryset.get(id=plan_id)
|
||||
except Plan.DoesNotExist as exc:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
|
||||
) from exc
|
||||
|
||||
plan = queryset.first()
|
||||
if plan is None:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
|
||||
)
|
||||
return plan
|
||||
58
backend/apps/levee_work/tests.py
Normal file
58
backend/apps/levee_work/tests.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.plans.models import Crop, Plan, Variety
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
from .serializers import LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializerTests(TestCase):
|
||||
def test_total_area_tan_is_included(self):
|
||||
crop = Crop.objects.create(name='水稲')
|
||||
variety = Variety.objects.create(crop=crop, name='にこまる')
|
||||
field_a = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
field_b = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
plan_a = Plan.objects.create(field=field_a, year=2026, crop=crop, variety=variety, notes='')
|
||||
plan_b = Plan.objects.create(field=field_b, year=2026, crop=crop, variety=variety, notes='')
|
||||
session = LeveeWorkSession.objects.create(
|
||||
year=2026,
|
||||
date='2026-04-06',
|
||||
title='水稲畔塗',
|
||||
notes='',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_a,
|
||||
plan=plan_a,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_b,
|
||||
plan=plan_b,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
|
||||
data = LeveeWorkSessionSerializer(session).data
|
||||
|
||||
self.assertEqual(data['item_count'], 2)
|
||||
self.assertEqual(data['total_area_tan'], '2.0000')
|
||||
13
backend/apps/levee_work/urls.py
Normal file
13
backend/apps/levee_work/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
70
backend/apps/levee_work/views.py
Normal file
70
backend/apps/levee_work/views.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from .models import LeveeWorkSession
|
||||
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = LeveeWorkSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__plan',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return LeveeWorkSessionWriteSerializer
|
||||
return LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
plans = (
|
||||
Plan.objects.select_related('field', 'crop', 'variety')
|
||||
.filter(year=year, crop__name='水稲')
|
||||
.order_by('field__display_order', 'field__id')
|
||||
)
|
||||
|
||||
data = [
|
||||
{
|
||||
'field_id': plan.field_id,
|
||||
'field_name': plan.field.name,
|
||||
'field_area_tan': str(plan.field.area_tan),
|
||||
'group_name': plan.field.group_name,
|
||||
'plan_id': plan.id,
|
||||
'crop_name': plan.crop.name,
|
||||
'variety_name': plan.variety.name if plan.variety else '',
|
||||
'selected': True,
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mail', '0004_rename_infoseek_to_gmail_service'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='mailemail',
|
||||
name='account',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('gmail', 'Gmail'),
|
||||
('gmail_service', 'Gmail (サービス用)'),
|
||||
('hotmail', 'Hotmail'),
|
||||
('xserver1', 'Xserver (akira@keinafarm.com)'),
|
||||
('xserver2', 'Xserver (service@keinafarm.com)'),
|
||||
('xserver3', 'Xserver (midori@keinafarm.com)'),
|
||||
('xserver4', 'Xserver (kouseiren@keinafarm.com)'),
|
||||
('xserver5', 'Xserver (post@keinafarm.com)'),
|
||||
('xserver6', 'Xserver (sales@keinafarm.com)'),
|
||||
('xserver', 'Xserver (legacy)'),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name='アカウント',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -42,10 +42,16 @@ class MailSender(models.Model):
|
||||
|
||||
|
||||
ACCOUNT_CHOICES = [
|
||||
('xserver', 'Xserver'),
|
||||
('gmail', 'Gmail'),
|
||||
('hotmail', 'Hotmail'),
|
||||
('gmail_service', 'Gmail (サービス用)'),
|
||||
('hotmail', 'Hotmail'),
|
||||
('xserver1', 'Xserver (akira@keinafarm.com)'),
|
||||
('xserver2', 'Xserver (service@keinafarm.com)'),
|
||||
('xserver3', 'Xserver (midori@keinafarm.com)'),
|
||||
('xserver4', 'Xserver (kouseiren@keinafarm.com)'),
|
||||
('xserver5', 'Xserver (post@keinafarm.com)'),
|
||||
('xserver6', 'Xserver (sales@keinafarm.com)'),
|
||||
('xserver', 'Xserver (legacy)'),
|
||||
]
|
||||
|
||||
FEEDBACK_CHOICES = [
|
||||
@@ -105,3 +111,4 @@ class MailNotificationToken(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return str(self.token)
|
||||
|
||||
|
||||
1
backend/apps/materials/__init__.py
Normal file
1
backend/apps/materials/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
backend/apps/materials/admin.py
Normal file
28
backend/apps/materials/admin.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import FertilizerProfile, Material, PesticideProfile, StockTransaction
|
||||
|
||||
|
||||
class FertilizerProfileInline(admin.StackedInline):
|
||||
model = FertilizerProfile
|
||||
extra = 0
|
||||
|
||||
|
||||
class PesticideProfileInline(admin.StackedInline):
|
||||
model = PesticideProfile
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Material)
|
||||
class MaterialAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'material_type', 'maker', 'stock_unit', 'is_active']
|
||||
list_filter = ['material_type', 'is_active']
|
||||
search_fields = ['name', 'maker']
|
||||
inlines = [FertilizerProfileInline, PesticideProfileInline]
|
||||
|
||||
|
||||
@admin.register(StockTransaction)
|
||||
class StockTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = ['material', 'transaction_type', 'quantity', 'occurred_on']
|
||||
list_filter = ['transaction_type', 'occurred_on']
|
||||
search_fields = ['material__name']
|
||||
8
backend/apps/materials/apps.py
Normal file
8
backend/apps/materials/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MaterialsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.materials'
|
||||
verbose_name = '資材管理'
|
||||
|
||||
87
backend/apps/materials/migrations/0001_initial.py
Normal file
87
backend/apps/materials/migrations/0001_initial.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import decimal
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Material',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='資材名')),
|
||||
('material_type', models.CharField(choices=[('fertilizer', '肥料'), ('pesticide', '農薬'), ('seedling', '種苗'), ('other', 'その他')], max_length=20, verbose_name='資材種別')),
|
||||
('maker', models.CharField(blank=True, default='', max_length=100, verbose_name='メーカー')),
|
||||
('stock_unit', models.CharField(choices=[('bag', '袋'), ('bottle', '本'), ('kg', 'kg'), ('liter', 'L'), ('piece', '個')], default='bag', max_length=20, verbose_name='在庫単位')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='使用中')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '資材',
|
||||
'verbose_name_plural': '資材',
|
||||
'ordering': ['material_type', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FertilizerProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('capacity_kg', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True, verbose_name='1袋重量(kg)')),
|
||||
('nitrogen_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='窒素(%)')),
|
||||
('phosphorus_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='リン酸(%)')),
|
||||
('potassium_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='カリ(%)')),
|
||||
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fertilizer_profile', to='materials.material')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '肥料詳細',
|
||||
'verbose_name_plural': '肥料詳細',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PesticideProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('registration_no', models.CharField(blank=True, default='', max_length=100, verbose_name='農薬登録番号')),
|
||||
('formulation', models.CharField(blank=True, default='', max_length=100, verbose_name='剤型')),
|
||||
('usage_unit', models.CharField(blank=True, default='', max_length=50, verbose_name='使用単位')),
|
||||
('dilution_ratio', models.CharField(blank=True, default='', max_length=100, verbose_name='希釈倍率')),
|
||||
('active_ingredient', models.CharField(blank=True, default='', max_length=200, verbose_name='有効成分')),
|
||||
('category', models.CharField(blank=True, default='', max_length=100, verbose_name='分類')),
|
||||
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pesticide_profile', to='materials.material')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '農薬詳細',
|
||||
'verbose_name_plural': '農薬詳細',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('transaction_type', models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別')),
|
||||
('quantity', models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(decimal.Decimal('0.001'))], verbose_name='数量')),
|
||||
('occurred_on', models.DateField(verbose_name='発生日')),
|
||||
('note', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stock_transactions', to='materials.material', verbose_name='資材')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '入出庫履歴',
|
||||
'verbose_name_plural': '入出庫履歴',
|
||||
'ordering': ['-occurred_on', '-created_at', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='material',
|
||||
constraint=models.UniqueConstraint(fields=('material_type', 'name'), name='uniq_material_type_name'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0001_initial'),
|
||||
('fertilizer', '0005_fertilizer_material'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stocktransaction',
|
||||
name='fertilization_plan',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='stock_reservations',
|
||||
to='fertilizer.fertilizationplan',
|
||||
verbose_name='施肥計画',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0002_stocktransaction_fertilization_plan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='transaction_type',
|
||||
field=models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('reserve', '引当'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 10:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0003_stocktransaction_spreading_item_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
]
|
||||
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0004_fix_spreading_item_on_delete'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='material',
|
||||
name='material_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('fertilizer', '肥料'),
|
||||
('pesticide', '農薬'),
|
||||
('seed', '種子'),
|
||||
('seedling', '種苗'),
|
||||
('other', 'その他'),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name='資材種別',
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/materials/migrations/__init__.py
Normal file
1
backend/apps/materials/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
229
backend/apps/materials/models.py
Normal file
229
backend/apps/materials/models.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Material(models.Model):
|
||||
"""共通資材マスタ"""
|
||||
|
||||
class MaterialType(models.TextChoices):
|
||||
FERTILIZER = 'fertilizer', '肥料'
|
||||
PESTICIDE = 'pesticide', '農薬'
|
||||
SEED = 'seed', '種子'
|
||||
SEEDLING = 'seedling', '種苗'
|
||||
OTHER = 'other', 'その他'
|
||||
|
||||
class StockUnit(models.TextChoices):
|
||||
BAG = 'bag', '袋'
|
||||
BOTTLE = 'bottle', '本'
|
||||
KG = 'kg', 'kg'
|
||||
LITER = 'liter', 'L'
|
||||
PIECE = 'piece', '個'
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name='資材名')
|
||||
material_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=MaterialType.choices,
|
||||
verbose_name='資材種別',
|
||||
)
|
||||
maker = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='メーカー',
|
||||
)
|
||||
stock_unit = models.CharField(
|
||||
max_length=20,
|
||||
choices=StockUnit.choices,
|
||||
default=StockUnit.BAG,
|
||||
verbose_name='在庫単位',
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name='使用中')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['material_type', 'name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['material_type', 'name'],
|
||||
name='uniq_material_type_name',
|
||||
),
|
||||
]
|
||||
verbose_name = '資材'
|
||||
verbose_name_plural = '資材'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.get_material_type_display()}: {self.name}'
|
||||
|
||||
|
||||
class FertilizerProfile(models.Model):
|
||||
"""肥料専用属性"""
|
||||
|
||||
material = models.OneToOneField(
|
||||
Material,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='fertilizer_profile',
|
||||
)
|
||||
capacity_kg = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=3,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='1袋重量(kg)',
|
||||
)
|
||||
nitrogen_pct = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='窒素(%)',
|
||||
)
|
||||
phosphorus_pct = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='リン酸(%)',
|
||||
)
|
||||
potassium_pct = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='カリ(%)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '肥料詳細'
|
||||
verbose_name_plural = '肥料詳細'
|
||||
|
||||
def __str__(self):
|
||||
return f'肥料詳細: {self.material.name}'
|
||||
|
||||
|
||||
class PesticideProfile(models.Model):
|
||||
"""農薬専用属性"""
|
||||
|
||||
material = models.OneToOneField(
|
||||
Material,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='pesticide_profile',
|
||||
)
|
||||
registration_no = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='農薬登録番号',
|
||||
)
|
||||
formulation = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='剤型',
|
||||
)
|
||||
usage_unit = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='使用単位',
|
||||
)
|
||||
dilution_ratio = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='希釈倍率',
|
||||
)
|
||||
active_ingredient = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='有効成分',
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='分類',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '農薬詳細'
|
||||
verbose_name_plural = '農薬詳細'
|
||||
|
||||
def __str__(self):
|
||||
return f'農薬詳細: {self.material.name}'
|
||||
|
||||
|
||||
class StockTransaction(models.Model):
|
||||
"""入出庫履歴"""
|
||||
|
||||
class TransactionType(models.TextChoices):
|
||||
PURCHASE = 'purchase', '入庫'
|
||||
USE = 'use', '使用'
|
||||
RESERVE = 'reserve', '引当'
|
||||
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
||||
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
||||
DISCARD = 'discard', '廃棄'
|
||||
|
||||
INCREASE_TYPES = {
|
||||
TransactionType.PURCHASE,
|
||||
TransactionType.ADJUSTMENT_PLUS,
|
||||
}
|
||||
DECREASE_TYPES = {
|
||||
TransactionType.USE,
|
||||
TransactionType.RESERVE,
|
||||
TransactionType.ADJUSTMENT_MINUS,
|
||||
TransactionType.DISCARD,
|
||||
}
|
||||
|
||||
material = models.ForeignKey(
|
||||
Material,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='stock_transactions',
|
||||
verbose_name='資材',
|
||||
)
|
||||
transaction_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=TransactionType.choices,
|
||||
verbose_name='取引種別',
|
||||
)
|
||||
quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=3,
|
||||
validators=[MinValueValidator(Decimal('0.001'))],
|
||||
verbose_name='数量',
|
||||
)
|
||||
occurred_on = models.DateField(verbose_name='発生日')
|
||||
note = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
fertilization_plan = models.ForeignKey(
|
||||
'fertilizer.FertilizationPlan',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='stock_reservations',
|
||||
verbose_name='施肥計画',
|
||||
)
|
||||
spreading_item = models.ForeignKey(
|
||||
'fertilizer.SpreadingSessionItem',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='stock_transactions',
|
||||
verbose_name='散布実績明細',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-occurred_on', '-created_at', '-id']
|
||||
verbose_name = '入出庫履歴'
|
||||
verbose_name_plural = '入出庫履歴'
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'{self.material.name} '
|
||||
f'{self.get_transaction_type_display()} '
|
||||
f'{self.quantity}'
|
||||
)
|
||||
225
backend/apps/materials/serializers.py
Normal file
225
backend/apps/materials/serializers.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
FertilizerProfile,
|
||||
Material,
|
||||
PesticideProfile,
|
||||
StockTransaction,
|
||||
)
|
||||
|
||||
|
||||
class FertilizerProfileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FertilizerProfile
|
||||
fields = ['capacity_kg', 'nitrogen_pct', 'phosphorus_pct', 'potassium_pct']
|
||||
|
||||
|
||||
class PesticideProfileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PesticideProfile
|
||||
fields = [
|
||||
'registration_no',
|
||||
'formulation',
|
||||
'usage_unit',
|
||||
'dilution_ratio',
|
||||
'active_ingredient',
|
||||
'category',
|
||||
]
|
||||
|
||||
|
||||
class MaterialReadSerializer(serializers.ModelSerializer):
|
||||
material_type_display = serializers.CharField(
|
||||
source='get_material_type_display',
|
||||
read_only=True,
|
||||
)
|
||||
stock_unit_display = serializers.CharField(
|
||||
source='get_stock_unit_display',
|
||||
read_only=True,
|
||||
)
|
||||
fertilizer_profile = FertilizerProfileSerializer(read_only=True)
|
||||
pesticide_profile = PesticideProfileSerializer(read_only=True)
|
||||
current_stock = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Material
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'material_type',
|
||||
'material_type_display',
|
||||
'maker',
|
||||
'stock_unit',
|
||||
'stock_unit_display',
|
||||
'is_active',
|
||||
'notes',
|
||||
'fertilizer_profile',
|
||||
'pesticide_profile',
|
||||
'current_stock',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_current_stock(self, obj):
|
||||
transactions = list(obj.stock_transactions.all())
|
||||
increase = sum(
|
||||
transaction.quantity
|
||||
for transaction in transactions
|
||||
if transaction.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
)
|
||||
decrease = sum(
|
||||
transaction.quantity
|
||||
for transaction in transactions
|
||||
if transaction.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
)
|
||||
return increase - decrease
|
||||
|
||||
|
||||
class MaterialWriteSerializer(serializers.ModelSerializer):
|
||||
fertilizer_profile = FertilizerProfileSerializer(required=False, allow_null=True)
|
||||
pesticide_profile = PesticideProfileSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Material
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'material_type',
|
||||
'maker',
|
||||
'stock_unit',
|
||||
'is_active',
|
||||
'notes',
|
||||
'fertilizer_profile',
|
||||
'pesticide_profile',
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
material_type = attrs.get('material_type')
|
||||
if self.instance is not None and material_type is None:
|
||||
material_type = self.instance.material_type
|
||||
|
||||
fertilizer_profile = attrs.get('fertilizer_profile')
|
||||
pesticide_profile = attrs.get('pesticide_profile')
|
||||
|
||||
if material_type == Material.MaterialType.FERTILIZER and pesticide_profile:
|
||||
raise serializers.ValidationError(
|
||||
{'pesticide_profile': '肥料には農薬詳細を設定できません。'}
|
||||
)
|
||||
if material_type == Material.MaterialType.PESTICIDE and fertilizer_profile:
|
||||
raise serializers.ValidationError(
|
||||
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
|
||||
)
|
||||
if (
|
||||
material_type in {
|
||||
Material.MaterialType.SEED,
|
||||
Material.MaterialType.SEEDLING,
|
||||
Material.MaterialType.OTHER,
|
||||
}
|
||||
and (fertilizer_profile or pesticide_profile)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'種子・種苗・その他には詳細プロファイルを設定できません。'
|
||||
)
|
||||
return attrs
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
|
||||
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
|
||||
|
||||
material = Material.objects.create(**validated_data)
|
||||
self._save_profiles(material, fertilizer_profile_data, pesticide_profile_data)
|
||||
return material
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
|
||||
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
self._save_profiles(instance, fertilizer_profile_data, pesticide_profile_data)
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance):
|
||||
return MaterialReadSerializer(instance, context=self.context).data
|
||||
|
||||
def _save_profiles(self, material, fertilizer_profile_data, pesticide_profile_data):
|
||||
if material.material_type == Material.MaterialType.FERTILIZER:
|
||||
if fertilizer_profile_data is not None:
|
||||
profile, _ = FertilizerProfile.objects.get_or_create(material=material)
|
||||
for attr, value in fertilizer_profile_data.items():
|
||||
setattr(profile, attr, value)
|
||||
profile.save()
|
||||
PesticideProfile.objects.filter(material=material).delete()
|
||||
return
|
||||
|
||||
if material.material_type == Material.MaterialType.PESTICIDE:
|
||||
if pesticide_profile_data is not None:
|
||||
profile, _ = PesticideProfile.objects.get_or_create(material=material)
|
||||
for attr, value in pesticide_profile_data.items():
|
||||
setattr(profile, attr, value)
|
||||
profile.save()
|
||||
FertilizerProfile.objects.filter(material=material).delete()
|
||||
return
|
||||
|
||||
FertilizerProfile.objects.filter(material=material).delete()
|
||||
PesticideProfile.objects.filter(material=material).delete()
|
||||
|
||||
|
||||
class StockTransactionSerializer(serializers.ModelSerializer):
|
||||
material_name = serializers.CharField(source='material.name', read_only=True)
|
||||
material_type = serializers.CharField(source='material.material_type', read_only=True)
|
||||
stock_unit = serializers.CharField(source='material.stock_unit', read_only=True)
|
||||
stock_unit_display = serializers.CharField(
|
||||
source='material.get_stock_unit_display',
|
||||
read_only=True,
|
||||
)
|
||||
transaction_type_display = serializers.CharField(
|
||||
source='get_transaction_type_display',
|
||||
read_only=True,
|
||||
)
|
||||
is_locked = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StockTransaction
|
||||
fields = [
|
||||
'id',
|
||||
'material',
|
||||
'material_name',
|
||||
'material_type',
|
||||
'transaction_type',
|
||||
'transaction_type_display',
|
||||
'quantity',
|
||||
'stock_unit',
|
||||
'stock_unit_display',
|
||||
'occurred_on',
|
||||
'note',
|
||||
'fertilization_plan',
|
||||
'spreading_item',
|
||||
'is_locked',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
def get_is_locked(self, obj):
|
||||
return bool(obj.fertilization_plan_id or obj.spreading_item_id)
|
||||
|
||||
|
||||
class StockSummarySerializer(serializers.Serializer):
|
||||
material_id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
material_type = serializers.CharField()
|
||||
material_type_display = serializers.CharField()
|
||||
maker = serializers.CharField()
|
||||
stock_unit = serializers.CharField()
|
||||
stock_unit_display = serializers.CharField()
|
||||
is_active = serializers.BooleanField()
|
||||
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
|
||||
reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
|
||||
available_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
|
||||
last_transaction_date = serializers.DateField(allow_null=True)
|
||||
97
backend/apps/materials/stock_service.py
Normal file
97
backend/apps/materials/stock_service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import StockTransaction
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_reserves_for_plan(plan):
|
||||
"""施肥計画の引当を全置換で作り直す。"""
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).delete()
|
||||
|
||||
occurred_on = (
|
||||
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
|
||||
)
|
||||
|
||||
for entry in plan.entries.select_related('fertilizer__material'):
|
||||
material = getattr(entry.fertilizer, 'material', None)
|
||||
if material is None:
|
||||
continue
|
||||
StockTransaction.objects.create(
|
||||
material=material,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
quantity=entry.bags,
|
||||
occurred_on=occurred_on,
|
||||
note=f'施肥計画「{plan.name}」からの引当',
|
||||
fertilization_plan=plan,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_reserves_for_plan(plan):
|
||||
"""施肥計画に紐づく引当のみ削除する。"""
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).delete()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_spreading(plan, actual_entries):
|
||||
"""引当を使用実績へ変換して施肥計画を確定済みにする。"""
|
||||
from apps.fertilizer.models import Fertilizer
|
||||
|
||||
delete_reserves_for_plan(plan)
|
||||
|
||||
for entry_data in actual_entries:
|
||||
actual_bags = _to_decimal(entry_data.get('actual_bags'))
|
||||
if actual_bags <= 0:
|
||||
continue
|
||||
|
||||
fertilizer = (
|
||||
Fertilizer.objects.select_related('material')
|
||||
.filter(id=entry_data['fertilizer_id'])
|
||||
.first()
|
||||
)
|
||||
if fertilizer is None or fertilizer.material is None:
|
||||
continue
|
||||
|
||||
StockTransaction.objects.create(
|
||||
material=fertilizer.material,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
quantity=actual_bags,
|
||||
occurred_on=timezone.localdate(),
|
||||
note=f'施肥計画「{plan.name}」散布確定',
|
||||
fertilization_plan=plan,
|
||||
)
|
||||
|
||||
plan.is_confirmed = True
|
||||
plan.confirmed_at = timezone.now()
|
||||
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def unconfirm_spreading(plan):
|
||||
"""散布確定を取り消し、USE トランザクションを削除して引当を再作成する。"""
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=plan,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
).delete()
|
||||
|
||||
plan.is_confirmed = False
|
||||
plan.confirmed_at = None
|
||||
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
|
||||
|
||||
create_reserves_for_plan(plan)
|
||||
|
||||
|
||||
def _to_decimal(value):
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Decimal('0')
|
||||
18
backend/apps/materials/urls.py
Normal file
18
backend/apps/materials/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'materials', views.MaterialViewSet, basename='material')
|
||||
router.register(
|
||||
r'stock-transactions',
|
||||
views.StockTransactionViewSet,
|
||||
basename='stock-transaction',
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
|
||||
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'),
|
||||
]
|
||||
191
backend/apps/materials/views.py
Normal file
191
backend/apps/materials/views.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import generics, status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Material, StockTransaction
|
||||
from .serializers import (
|
||||
MaterialReadSerializer,
|
||||
MaterialWriteSerializer,
|
||||
StockSummarySerializer,
|
||||
StockTransactionSerializer,
|
||||
)
|
||||
|
||||
|
||||
class MaterialViewSet(viewsets.ModelViewSet):
|
||||
"""資材マスタ CRUD"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Material.objects.select_related(
|
||||
'fertilizer_profile',
|
||||
'pesticide_profile',
|
||||
).prefetch_related('stock_transactions')
|
||||
|
||||
material_type = self.request.query_params.get('material_type')
|
||||
if material_type:
|
||||
queryset = queryset.filter(material_type=material_type)
|
||||
|
||||
active = self.request.query_params.get('active')
|
||||
if active is not None:
|
||||
queryset = queryset.filter(is_active=active.lower() == 'true')
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return MaterialWriteSerializer
|
||||
return MaterialReadSerializer
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.stock_transactions.exists():
|
||||
return Response(
|
||||
{'detail': 'この資材には入出庫履歴があるため削除できません。無効化してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StockTransactionViewSet(viewsets.ModelViewSet):
|
||||
"""入出庫履歴 CRUD"""
|
||||
|
||||
serializer_class = StockTransactionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = StockTransaction.objects.select_related('material')
|
||||
|
||||
material_id = self.request.query_params.get('material_id')
|
||||
if material_id:
|
||||
queryset = queryset.filter(material_id=material_id)
|
||||
|
||||
material_type = self.request.query_params.get('material_type')
|
||||
if material_type:
|
||||
queryset = queryset.filter(material__material_type=material_type)
|
||||
|
||||
date_from = self.request.query_params.get('date_from')
|
||||
if date_from:
|
||||
queryset = queryset.filter(occurred_on__gte=date_from)
|
||||
|
||||
date_to = self.request.query_params.get('date_to')
|
||||
if date_to:
|
||||
queryset = queryset.filter(occurred_on__lte=date_to)
|
||||
|
||||
return queryset
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.fertilization_plan_id or instance.spreading_item_id:
|
||||
return Response(
|
||||
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.fertilization_plan_id or instance.spreading_item_id:
|
||||
return Response(
|
||||
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.fertilization_plan_id or instance.spreading_item_id:
|
||||
return Response(
|
||||
{'detail': '計画や実績に紐づく入出庫履歴は削除できません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StockSummaryView(generics.ListAPIView):
|
||||
"""在庫集計一覧"""
|
||||
|
||||
serializer_class = StockSummarySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Material.objects.none()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = Material.objects.prefetch_related('stock_transactions').order_by(
|
||||
'material_type',
|
||||
'name',
|
||||
)
|
||||
|
||||
material_type = request.query_params.get('material_type')
|
||||
if material_type:
|
||||
queryset = queryset.filter(material_type=material_type)
|
||||
|
||||
active = request.query_params.get('active')
|
||||
if active is not None:
|
||||
queryset = queryset.filter(is_active=active.lower() == 'true')
|
||||
|
||||
results = []
|
||||
for material in queryset:
|
||||
results.append(_build_stock_summary(material))
|
||||
|
||||
serializer = self.get_serializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class FertilizerStockView(generics.ListAPIView):
|
||||
"""施肥計画画面用: 肥料の在庫情報を返す"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = StockSummarySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Material.objects.none()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = Material.objects.filter(
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
is_active=True,
|
||||
).prefetch_related('stock_transactions').order_by('name')
|
||||
|
||||
results = [_build_stock_summary(material) for material in queryset]
|
||||
serializer = self.get_serializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def _build_stock_summary(material):
|
||||
transactions = list(material.stock_transactions.all())
|
||||
increase = sum(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
)
|
||||
decrease = sum(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
)
|
||||
reserved = sum(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type == StockTransaction.TransactionType.RESERVE
|
||||
)
|
||||
available = increase - decrease if transactions else Decimal('0')
|
||||
last_date = max((txn.occurred_on for txn in transactions), default=None)
|
||||
return {
|
||||
'material_id': material.id,
|
||||
'name': material.name,
|
||||
'material_type': material.material_type,
|
||||
'material_type_display': material.get_material_type_display(),
|
||||
'maker': material.maker,
|
||||
'stock_unit': material.stock_unit,
|
||||
'stock_unit_display': material.get_stock_unit_display(),
|
||||
'is_active': material.is_active,
|
||||
'current_stock': available + reserved,
|
||||
'reserved_stock': reserved,
|
||||
'available_stock': available,
|
||||
'last_transaction_date': last_date,
|
||||
}
|
||||
@@ -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):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
|
||||
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
|
||||
seed_inventory_kg = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=3,
|
||||
default=0,
|
||||
verbose_name="種もみ在庫(kg)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "作物マスタ"
|
||||
@@ -17,6 +23,21 @@ class Crop(models.Model):
|
||||
class Variety(models.Model):
|
||||
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
|
||||
name = models.CharField(max_length=100, verbose_name="品種名")
|
||||
default_seedling_boxes_per_tan = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="反当苗箱枚数デフォルト",
|
||||
)
|
||||
seed_material = models.ForeignKey(
|
||||
'materials.Material',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='varieties',
|
||||
verbose_name='種子在庫資材',
|
||||
blank=True,
|
||||
null=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "品種マスタ"
|
||||
@@ -42,3 +63,116 @@ class Plan(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.field.name} - {self.year} - {self.crop.name}"
|
||||
|
||||
|
||||
class PlanVarietyChange(models.Model):
|
||||
field = models.ForeignKey(
|
||||
Field,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='plan_variety_changes',
|
||||
verbose_name='圃場',
|
||||
)
|
||||
year = models.IntegerField(verbose_name='作付年度')
|
||||
plan = models.ForeignKey(
|
||||
Plan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='variety_changes',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
changed_at = models.DateTimeField(auto_now_add=True, verbose_name='変更日時')
|
||||
old_variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='old_plan_variety_changes',
|
||||
verbose_name='変更前品種',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
new_variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='new_plan_variety_changes',
|
||||
verbose_name='変更後品種',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
reason = models.TextField(blank=True, default='', verbose_name='変更理由')
|
||||
fertilizer_moved_entry_count = models.IntegerField(default=0, verbose_name='施肥移動エントリ数')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '作付け計画品種変更履歴'
|
||||
verbose_name_plural = '作付け計画品種変更履歴'
|
||||
ordering = ['-changed_at', '-id']
|
||||
|
||||
def __str__(self):
|
||||
old_name = self.old_variety.name if self.old_variety else '未設定'
|
||||
new_name = self.new_variety.name if self.new_variety else '未設定'
|
||||
return f'{self.field.name} {self.year}: {old_name} -> {new_name}'
|
||||
|
||||
|
||||
class RiceTransplantPlan(models.Model):
|
||||
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
variety = models.ForeignKey(
|
||||
Variety,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rice_transplant_plans',
|
||||
verbose_name='品種',
|
||||
)
|
||||
default_seed_grams_per_box = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
|
||||
)
|
||||
seedling_boxes_per_tan = models.DecimalField(
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name='反当苗箱枚数',
|
||||
)
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '田植え計画'
|
||||
verbose_name_plural = '田植え計画'
|
||||
ordering = ['-year', 'variety']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.year} {self.name}'
|
||||
|
||||
|
||||
class RiceTransplantEntry(models.Model):
|
||||
plan = models.ForeignKey(
|
||||
RiceTransplantPlan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='entries',
|
||||
verbose_name='田植え計画',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
Field,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rice_transplant_entries',
|
||||
verbose_name='圃場',
|
||||
)
|
||||
installed_seedling_boxes = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
verbose_name='設置苗箱枚数',
|
||||
)
|
||||
seed_grams_per_box = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
verbose_name='苗箱1枚あたり種もみ(g)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '田植え計画エントリ'
|
||||
verbose_name_plural = '田植え計画エントリ'
|
||||
unique_together = [['plan', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.plan} / {self.field} / {self.installed_seedling_boxes}枚'
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import StockTransaction
|
||||
from .models import Crop, Variety, Plan
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
from .services import NO_CHANGE, update_plan_with_variety_tracking
|
||||
|
||||
|
||||
class VarietySerializer(serializers.ModelSerializer):
|
||||
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Variety
|
||||
fields = '__all__'
|
||||
fields = [
|
||||
'id',
|
||||
'crop',
|
||||
'name',
|
||||
'default_seedling_boxes_per_tan',
|
||||
'seed_material',
|
||||
'seed_material_name',
|
||||
]
|
||||
|
||||
|
||||
class CropSerializer(serializers.ModelSerializer):
|
||||
@@ -20,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||
field_name = serializers.ReadOnlyField(source='field.name')
|
||||
variety_change_count = serializers.SerializerMethodField()
|
||||
latest_variety_change = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Plan
|
||||
@@ -30,7 +47,215 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
return Plan.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
return update_plan_with_variety_tracking(
|
||||
instance,
|
||||
crop=validated_data.get('crop', NO_CHANGE),
|
||||
variety=validated_data.get('variety', NO_CHANGE),
|
||||
notes=validated_data.get('notes', NO_CHANGE),
|
||||
)
|
||||
|
||||
def get_variety_change_count(self, obj):
|
||||
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||
changes = prefetched.get('variety_changes')
|
||||
if changes is not None:
|
||||
return len(changes)
|
||||
return obj.variety_changes.count()
|
||||
|
||||
def get_latest_variety_change(self, obj):
|
||||
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||
changes = prefetched.get('variety_changes')
|
||||
if changes is not None:
|
||||
latest = changes[0] if changes else None
|
||||
else:
|
||||
latest = obj.variety_changes.select_related('old_variety', 'new_variety').first()
|
||||
if latest is None:
|
||||
return None
|
||||
return {
|
||||
'id': latest.id,
|
||||
'changed_at': latest.changed_at,
|
||||
'old_variety_id': latest.old_variety_id,
|
||||
'old_variety_name': latest.old_variety.name if latest.old_variety else None,
|
||||
'new_variety_id': latest.new_variety_id,
|
||||
'new_variety_name': latest.new_variety.name if latest.new_variety else None,
|
||||
'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count,
|
||||
}
|
||||
|
||||
|
||||
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
planned_boxes = serializers.SerializerMethodField()
|
||||
default_seedling_boxes = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantEntry
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'installed_seedling_boxes',
|
||||
'default_seedling_boxes',
|
||||
'planned_boxes',
|
||||
]
|
||||
|
||||
def get_default_seedling_boxes(self, obj):
|
||||
area = Decimal(str(obj.field.area_tan))
|
||||
default_boxes_per_tan = obj.plan.seedling_boxes_per_tan
|
||||
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
|
||||
|
||||
def get_planned_boxes(self, obj):
|
||||
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
|
||||
|
||||
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
|
||||
variety_name = serializers.CharField(source='variety.name', read_only=True)
|
||||
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
|
||||
seed_material_name = serializers.CharField(source='variety.seed_material.name', read_only=True)
|
||||
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
total_seedling_boxes = serializers.SerializerMethodField()
|
||||
total_seed_kg = serializers.SerializerMethodField()
|
||||
variety_seed_inventory_kg = serializers.SerializerMethodField()
|
||||
remaining_seed_kg = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'seed_material_name',
|
||||
'entries',
|
||||
'field_count',
|
||||
'total_seedling_boxes',
|
||||
'total_seed_kg',
|
||||
'variety_seed_inventory_kg',
|
||||
'remaining_seed_kg',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_field_count(self, obj):
|
||||
return obj.entries.count()
|
||||
|
||||
def get_total_seedling_boxes(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.01')))
|
||||
|
||||
def get_total_seed_kg(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
* obj.default_seed_grams_per_box
|
||||
/ Decimal('1000')
|
||||
)
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.001')))
|
||||
|
||||
def get_variety_seed_inventory_kg(self, obj):
|
||||
return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001')))
|
||||
|
||||
def get_remaining_seed_kg(self, obj):
|
||||
total_seed = Decimal(self.get_total_seed_kg(obj))
|
||||
return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001')))
|
||||
|
||||
def _get_seed_inventory_kg(self, obj):
|
||||
material = obj.variety.seed_material
|
||||
if material is None:
|
||||
return Decimal('0')
|
||||
|
||||
transactions = list(material.stock_transactions.all())
|
||||
increase = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
decrease = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return increase - decrease
|
||||
|
||||
|
||||
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantPlan
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'entries',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = RiceTransplantPlan.objects.create(**validated_data)
|
||||
self._save_entries(plan, entries_data)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
entries_data = validated_data.pop('entries', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
self._save_entries(instance, entries_data)
|
||||
return instance
|
||||
|
||||
def validate(self, attrs):
|
||||
entries_data = attrs.get('entries')
|
||||
if entries_data is None:
|
||||
return attrs
|
||||
|
||||
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
|
||||
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
|
||||
missing_ids = sorted(set(field_ids) - existing_ids)
|
||||
if missing_ids:
|
||||
raise serializers.ValidationError({
|
||||
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
|
||||
})
|
||||
return attrs
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
for entry in entries_data:
|
||||
RiceTransplantEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
installed_seedling_boxes=entry['installed_seedling_boxes'],
|
||||
seed_grams_per_box=plan.default_seed_grams_per_box,
|
||||
)
|
||||
|
||||
74
backend/apps/plans/services.py
Normal file
74
backend/apps/plans/services.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Plan, PlanVarietyChange
|
||||
|
||||
|
||||
class _NoChange:
|
||||
pass
|
||||
|
||||
|
||||
NO_CHANGE = _NoChange()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_plan_with_variety_tracking(
|
||||
plan: Plan,
|
||||
*,
|
||||
crop=NO_CHANGE,
|
||||
variety=NO_CHANGE,
|
||||
notes=NO_CHANGE,
|
||||
reason: str = '',
|
||||
):
|
||||
old_variety = plan.variety
|
||||
updated_fields = []
|
||||
|
||||
if crop is not NO_CHANGE:
|
||||
plan.crop = crop
|
||||
updated_fields.append('crop')
|
||||
if variety is not NO_CHANGE:
|
||||
plan.variety = variety
|
||||
updated_fields.append('variety')
|
||||
if notes is not NO_CHANGE:
|
||||
plan.notes = notes
|
||||
updated_fields.append('notes')
|
||||
|
||||
if updated_fields:
|
||||
plan.save(update_fields=updated_fields)
|
||||
|
||||
if variety is not NO_CHANGE and _get_variety_id(old_variety) != _get_variety_id(plan.variety):
|
||||
handle_plan_variety_change(plan, old_variety=old_variety, new_variety=plan.variety, reason=reason)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: str = ''):
|
||||
if _get_variety_id(old_variety) == _get_variety_id(new_variety):
|
||||
return None
|
||||
|
||||
change = PlanVarietyChange.objects.create(
|
||||
field=plan.field,
|
||||
year=plan.year,
|
||||
plan=plan,
|
||||
old_variety=old_variety,
|
||||
new_variety=new_variety,
|
||||
reason=reason,
|
||||
)
|
||||
process_plan_variety_change(change)
|
||||
return change
|
||||
|
||||
|
||||
def process_plan_variety_change(change: PlanVarietyChange):
|
||||
from apps.fertilizer.services import move_fertilization_entries_for_variety_change
|
||||
from .services_rice_transplant import move_rice_transplant_entries_for_variety_change
|
||||
|
||||
moved_count = move_fertilization_entries_for_variety_change(change)
|
||||
move_rice_transplant_entries_for_variety_change(change)
|
||||
if moved_count != change.fertilizer_moved_entry_count:
|
||||
change.fertilizer_moved_entry_count = moved_count
|
||||
change.save(update_fields=['fertilizer_moved_entry_count'])
|
||||
return change
|
||||
|
||||
|
||||
def _get_variety_id(variety):
|
||||
return getattr(variety, 'id', None)
|
||||
46
backend/apps/plans/services_rice_transplant.py
Normal file
46
backend/apps/plans/services_rice_transplant.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import transaction
|
||||
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def move_rice_transplant_entries_for_variety_change(change):
|
||||
old_variety_id = change.old_variety_id
|
||||
new_variety = change.new_variety
|
||||
if old_variety_id is None or new_variety is None:
|
||||
return 0
|
||||
|
||||
old_plans = (
|
||||
RiceTransplantPlan.objects
|
||||
.filter(
|
||||
year=change.year,
|
||||
variety_id=old_variety_id,
|
||||
entries__field_id=change.field_id,
|
||||
)
|
||||
.distinct()
|
||||
.prefetch_related('entries')
|
||||
)
|
||||
|
||||
moved_count = 0
|
||||
for old_plan in old_plans:
|
||||
entries_to_move = list(
|
||||
old_plan.entries.filter(field_id=change.field_id).order_by('id')
|
||||
)
|
||||
if not entries_to_move:
|
||||
continue
|
||||
|
||||
new_plan = RiceTransplantPlan.objects.create(
|
||||
name=f'{change.year}年度 {new_variety.name} 田植え計画(品種変更移動)',
|
||||
year=change.year,
|
||||
variety=new_variety,
|
||||
default_seed_grams_per_box=old_plan.default_seed_grams_per_box,
|
||||
seedling_boxes_per_tan=old_plan.seedling_boxes_per_tan,
|
||||
notes=old_plan.notes,
|
||||
)
|
||||
|
||||
RiceTransplantEntry.objects.filter(
|
||||
id__in=[entry.id for entry in entries_to_move]
|
||||
).update(plan=new_plan)
|
||||
moved_count += len(entries_to_move)
|
||||
|
||||
return moved_count
|
||||
@@ -1,3 +1,263 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from decimal import Decimal
|
||||
|
||||
# Create your tests here.
|
||||
from apps.fertilizer.models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import Material, StockTransaction
|
||||
from apps.materials.stock_service import create_reserves_for_plan
|
||||
from .models import (
|
||||
Crop,
|
||||
Plan,
|
||||
PlanVarietyChange,
|
||||
RiceTransplantEntry,
|
||||
RiceTransplantPlan,
|
||||
Variety,
|
||||
)
|
||||
from .serializers import PlanSerializer
|
||||
from .views import PlanViewSet
|
||||
|
||||
|
||||
class PlanVarietyChangeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username='tester',
|
||||
password='secret12345',
|
||||
)
|
||||
self.crop = Crop.objects.create(name='水稲')
|
||||
self.old_variety = Variety.objects.create(crop=self.crop, name='にこまる')
|
||||
self.new_variety = Variety.objects.create(crop=self.crop, name='たちはるか特栽')
|
||||
self.field = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
self.plan = Plan.objects.create(
|
||||
field=self.field,
|
||||
year=2026,
|
||||
crop=self.crop,
|
||||
variety=self.old_variety,
|
||||
notes='',
|
||||
)
|
||||
self.other_field = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
|
||||
def test_serializer_update_creates_history_when_variety_changes(self):
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.field_id, self.field.id)
|
||||
self.assertEqual(change.year, 2026)
|
||||
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||
self.assertEqual(change.fertilizer_moved_entry_count, 0)
|
||||
|
||||
def test_serializer_update_does_not_create_history_without_variety_change(self):
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'notes': 'メモ更新'},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.notes, 'メモ更新')
|
||||
self.assertFalse(PlanVarietyChange.objects.exists())
|
||||
|
||||
def test_bulk_update_creates_history_for_existing_plan(self):
|
||||
view = PlanViewSet.as_view({'post': 'bulk_update'})
|
||||
request = self.factory.post(
|
||||
'/api/plans/bulk_update/',
|
||||
{
|
||||
'field_ids': [self.field.id],
|
||||
'year': 2026,
|
||||
'crop': self.crop.id,
|
||||
'variety': self.new_variety.id,
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = view(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.plan.refresh_from_db()
|
||||
self.assertEqual(self.plan.variety_id, self.new_variety.id)
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.old_variety_id, self.old_variety.id)
|
||||
self.assertEqual(change.new_variety_id, self.new_variety.id)
|
||||
|
||||
def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self):
|
||||
material_target = Material.objects.create(
|
||||
name='高度化成14号',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
material_spread = Material.objects.create(
|
||||
name='分げつ一発',
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
)
|
||||
fertilizer_target = Fertilizer.objects.create(
|
||||
name='高度化成14号',
|
||||
material=material_target,
|
||||
)
|
||||
fertilizer_spread = Fertilizer.objects.create(
|
||||
name='分げつ一発',
|
||||
material=material_spread,
|
||||
)
|
||||
old_fertilization_plan = FertilizationPlan.objects.create(
|
||||
name='2026年度 にこまる 元肥',
|
||||
year=2026,
|
||||
variety=self.old_variety,
|
||||
calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}],
|
||||
)
|
||||
target_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.field,
|
||||
fertilizer=fertilizer_target,
|
||||
bags='4.00',
|
||||
actual_bags=None,
|
||||
)
|
||||
spread_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.field,
|
||||
fertilizer=fertilizer_spread,
|
||||
bags='3.00',
|
||||
actual_bags='1.0000',
|
||||
)
|
||||
untouched_entry = FertilizationEntry.objects.create(
|
||||
plan=old_fertilization_plan,
|
||||
field=self.other_field,
|
||||
fertilizer=fertilizer_target,
|
||||
bags='2.00',
|
||||
actual_bags=None,
|
||||
)
|
||||
create_reserves_for_plan(old_fertilization_plan)
|
||||
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||
self.assertEqual(change.fertilizer_moved_entry_count, 2)
|
||||
|
||||
old_fertilization_plan.refresh_from_db()
|
||||
new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get(
|
||||
year=2026,
|
||||
variety=self.new_variety,
|
||||
)
|
||||
self.assertEqual(
|
||||
new_plan.name,
|
||||
f'2026年度 {self.new_variety.name} 施肥計画(品種変更移動)',
|
||||
)
|
||||
self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings)
|
||||
|
||||
target_entry.refresh_from_db()
|
||||
spread_entry.refresh_from_db()
|
||||
untouched_entry.refresh_from_db()
|
||||
|
||||
self.assertEqual(target_entry.plan_id, new_plan.id)
|
||||
self.assertEqual(spread_entry.plan_id, new_plan.id)
|
||||
self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id)
|
||||
|
||||
old_reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=old_fertilization_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
new_reserves = list(
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=new_plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).order_by('material__name')
|
||||
)
|
||||
self.assertEqual(len(old_reserves), 1)
|
||||
self.assertEqual(len(new_reserves), 2)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in old_reserves},
|
||||
{
|
||||
(material_target.id, untouched_entry.bags),
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{(reserve.material_id, reserve.quantity) for reserve in new_reserves},
|
||||
{
|
||||
(material_target.id, target_entry.bags),
|
||||
(material_spread.id, spread_entry.bags),
|
||||
},
|
||||
)
|
||||
|
||||
def test_serializer_update_moves_rice_transplant_entries_for_target_field(self):
|
||||
old_rice_plan = RiceTransplantPlan.objects.create(
|
||||
name='2026年度 にこまる 田植え計画',
|
||||
year=2026,
|
||||
variety=self.old_variety,
|
||||
default_seed_grams_per_box='200.00',
|
||||
seedling_boxes_per_tan='12.00',
|
||||
notes='旧計画メモ',
|
||||
)
|
||||
target_entry = RiceTransplantEntry.objects.create(
|
||||
plan=old_rice_plan,
|
||||
field=self.field,
|
||||
installed_seedling_boxes='14.40',
|
||||
seed_grams_per_box='200.00',
|
||||
)
|
||||
other_entry = RiceTransplantEntry.objects.create(
|
||||
plan=old_rice_plan,
|
||||
field=self.other_field,
|
||||
installed_seedling_boxes='9.60',
|
||||
seed_grams_per_box='200.00',
|
||||
)
|
||||
|
||||
serializer = PlanSerializer(
|
||||
instance=self.plan,
|
||||
data={'variety': self.new_variety.id},
|
||||
partial=True,
|
||||
)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
serializer.save()
|
||||
|
||||
target_entry.refresh_from_db()
|
||||
other_entry.refresh_from_db()
|
||||
|
||||
new_rice_plan = RiceTransplantPlan.objects.exclude(id=old_rice_plan.id).get(
|
||||
year=2026,
|
||||
variety=self.new_variety,
|
||||
)
|
||||
self.assertEqual(
|
||||
new_rice_plan.name,
|
||||
f'2026年度 {self.new_variety.name} 田植え計画(品種変更移動)',
|
||||
)
|
||||
self.assertEqual(new_rice_plan.default_seed_grams_per_box, Decimal('200.00'))
|
||||
self.assertEqual(new_rice_plan.seedling_boxes_per_tan, Decimal('12.00'))
|
||||
self.assertEqual(new_rice_plan.notes, old_rice_plan.notes)
|
||||
self.assertEqual(target_entry.plan_id, new_rice_plan.id)
|
||||
self.assertEqual(other_entry.plan_id, old_rice_plan.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ from . import views
|
||||
router = DefaultRouter()
|
||||
router.register(r'crops', views.CropViewSet)
|
||||
router.register(r'varieties', views.VarietyViewSet)
|
||||
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
|
||||
router.register(r'', views.PlanViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -2,8 +2,15 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Sum
|
||||
from .models import Crop, Variety, Plan
|
||||
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
|
||||
from .models import Crop, Variety, Plan, RiceTransplantPlan
|
||||
from .serializers import (
|
||||
CropSerializer,
|
||||
VarietySerializer,
|
||||
PlanSerializer,
|
||||
RiceTransplantPlanSerializer,
|
||||
RiceTransplantPlanWriteSerializer,
|
||||
)
|
||||
from .services import update_plan_with_variety_tracking
|
||||
from apps.fields.models import Field
|
||||
|
||||
|
||||
@@ -13,16 +20,20 @@ class CropViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class VarietyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Variety.objects.all()
|
||||
queryset = Variety.objects.select_related('seed_material', 'crop').all()
|
||||
serializer_class = VarietySerializer
|
||||
|
||||
|
||||
class PlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = Plan.objects.all()
|
||||
queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related(
|
||||
'variety_changes',
|
||||
'variety_changes__old_variety',
|
||||
'variety_changes__new_variety',
|
||||
)
|
||||
serializer_class = PlanSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Plan.objects.all()
|
||||
queryset = self.queryset
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
@@ -114,19 +125,78 @@ class PlanViewSet(viewsets.ModelViewSet):
|
||||
updated = 0
|
||||
created = 0
|
||||
for field_id in field_ids:
|
||||
plan, was_created = Plan.objects.update_or_create(
|
||||
plan = Plan.objects.filter(field_id=field_id, year=year).first()
|
||||
if plan is None:
|
||||
Plan.objects.create(
|
||||
field_id=field_id,
|
||||
year=year,
|
||||
defaults={'crop': crop, 'variety': variety}
|
||||
crop=crop,
|
||||
variety=variety,
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
else:
|
||||
continue
|
||||
|
||||
update_plan_with_variety_tracking(
|
||||
plan,
|
||||
crop=crop,
|
||||
variety=variety,
|
||||
)
|
||||
updated += 1
|
||||
|
||||
return Response({'created': created, 'updated': updated, 'total': created + updated})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def get_crops_with_varieties(self, request):
|
||||
crops = Crop.objects.prefetch_related('varieties').all()
|
||||
crops = Crop.objects.prefetch_related('varieties__seed_material').all()
|
||||
return Response(CropSerializer(crops, many=True).data)
|
||||
|
||||
|
||||
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = RiceTransplantPlan.objects.select_related(
|
||||
'variety',
|
||||
'variety__crop',
|
||||
'variety__seed_material',
|
||||
).prefetch_related(
|
||||
'variety__seed_material__stock_transactions',
|
||||
'entries',
|
||||
'entries__field',
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return RiceTransplantPlanWriteSerializer
|
||||
return RiceTransplantPlanSerializer
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def candidate_fields(self, request):
|
||||
year = request.query_params.get('year')
|
||||
variety_id = request.query_params.get('variety_id')
|
||||
if not year or not variety_id:
|
||||
return Response(
|
||||
{'error': 'year と variety_id が必要です'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
field_ids = Plan.objects.filter(
|
||||
year=year,
|
||||
variety_id=variety_id,
|
||||
).values_list('field_id', flat=True)
|
||||
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
|
||||
data = [
|
||||
{
|
||||
'id': field.id,
|
||||
'name': field.name,
|
||||
'area_tan': str(field.area_tan),
|
||||
'area_m2': field.area_m2,
|
||||
'group_name': field.group_name,
|
||||
}
|
||||
for field in fields
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
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.weather',
|
||||
'apps.fertilizer',
|
||||
'apps.materials',
|
||||
'apps.workrecords',
|
||||
'apps.levee_work',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -58,4 +58,7 @@ urlpatterns = [
|
||||
path('api/mail/', include('apps.mail.urls')),
|
||||
path('api/weather/', include('apps.weather.urls')),
|
||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||
path('api/materials/', include('apps.materials.urls')),
|
||||
path('api/workrecords/', include('apps.workrecords.urls')),
|
||||
path('api/levee-work/', include('apps.levee_work.urls')),
|
||||
]
|
||||
|
||||
1
butler.pid
Normal file
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:
|
||||
db:
|
||||
image: postgis/postgis:16-3.4
|
||||
container_name: keinasystem_db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: keinasystem
|
||||
POSTGRES_USER: keinasystem
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -17,12 +20,15 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: keinasystem_backend
|
||||
restart: always
|
||||
environment:
|
||||
DB_NAME: keinasystem
|
||||
DB_USER: keinasystem
|
||||
@@ -30,32 +36,47 @@ services:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
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}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
FRONTEND_URL: https://main.keinafarm.net
|
||||
depends_on:
|
||||
db:
|
||||
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:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.prod
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: https://main.keinafarm.net
|
||||
container_name: keinasystem_frontend
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||
WATCHPACK_POLLING: "true"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
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,6 +1,6 @@
|
||||
# マスタードキュメント - メール通知関連編
|
||||
|
||||
> **最終更新**: 2026-02-25
|
||||
> **最終更新**: 2026-03-05
|
||||
> **対象バージョン**: Phase 1 完了時点(本番稼働中)
|
||||
> **目的**: このドキュメントだけでメール通知機能の全容を把握できること
|
||||
|
||||
@@ -47,16 +47,18 @@
|
||||
|
||||
```
|
||||
1. IMAP 接続 → 前回処理済み UID 以降の新着メールを取得
|
||||
2. 送信者ルール確認(GET /api/mail/sender-rule/)
|
||||
2. 宛先補正(To ヘッダー)
|
||||
└── @keinafarm.com 宛先は xserver1〜xserver6 に正規化(Gmail先行取り込み時の誤表示防止)
|
||||
3. 送信者ルール確認(GET /api/mail/sender-rule/)
|
||||
├── never_notify → スキップ(記録しない)
|
||||
├── always_notify → LLMスキップ、即 LINE 通知
|
||||
└── ルールなし → 3へ
|
||||
3. 過去フィードバック集計取得(GET /api/mail/sender-context/)
|
||||
4. Gemini API で重要度判定(LLM)
|
||||
5. KeinaSystem に記録(POST /api/mail/emails/)
|
||||
└── ルールなし → 4へ
|
||||
4. 過去フィードバック集計取得(GET /api/mail/sender-context/)
|
||||
5. Gemini API で重要度判定(LLM)
|
||||
6. KeinaSystem に記録(POST /api/mail/emails/)
|
||||
├── not_important → 記録のみ、通知なし
|
||||
└── important → フィードバックURLを発行、LINE 通知
|
||||
6. 処理済み最終 UID を Windmill Variable に保存
|
||||
7. 処理済み最終 UID を Windmill Variable に保存
|
||||
```
|
||||
|
||||
### 10分ごとの定期実行
|
||||
@@ -92,7 +94,7 @@ Windmill スケジュール `0 */10 * * * *` で自動実行。サーバー上
|
||||
| フィールド | 型 | 説明 |
|
||||
|---|---|---|
|
||||
| `id` | BigAutoField | PK |
|
||||
| `account` | CharField(20) | `gmail` / `gmail_service` / `xserver` / `hotmail` |
|
||||
| `account` | CharField(20) | `gmail` / `gmail_service` / `hotmail` / `xserver1`〜`xserver6`(旧データは `xserver`) |
|
||||
| `message_id` | CharField(500, unique) | メールの Message-ID ヘッダー(重複防止に使用)|
|
||||
| `sender_email` | EmailField | 送信者メールアドレス |
|
||||
| `sender_domain` | CharField(255) | 送信者ドメイン |
|
||||
@@ -370,6 +372,12 @@ Hotmail は定義済みだがコメントアウト(未有効化)。
|
||||
|
||||
回答: `1`(重要)/ `2`(重要でない)の1文字。`1` で始まる場合 `important`。
|
||||
|
||||
|
||||
### 4.7 宛先補正ロジック
|
||||
|
||||
- 対象: Gmail 側で先に取得された転送メール
|
||||
- 方法: `To` ヘッダーの宛先アドレスを `recipient_map` で `xserver1`〜`xserver6` に変換
|
||||
- 目的: message_id 重複時に Gmail で先着しても、実際の受信メールボックス(Xserver側)を通知文・履歴で保持する
|
||||
### 4.6 LINE 通知文フォーマット
|
||||
|
||||
```
|
||||
@@ -514,7 +522,7 @@ UUID v4 のランダムトークンのみで認証。有効期限なし。LINE
|
||||
|
||||
### 重複メール処理
|
||||
|
||||
同じメールが複数アカウントで受信される場合(転送設定等)、`message_id` の unique 制約で2件目以降を自動スキップ。最初に処理したアカウントの `account_code` でDBに記録される。
|
||||
同じメールが複数アカウントで受信される場合(転送設定等)、`message_id` の unique 制約で2件目以降を自動スキップ。先着レコードを採用するが、Gmail先行時でも `To` ヘッダー宛先補正により `xserver1`〜`xserver6` を優先して記録する。
|
||||
|
||||
---
|
||||
|
||||
@@ -597,3 +605,6 @@ curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
|
||||
本番 Windmill でのパス: `f/mail/mail_filter`
|
||||
スケジュール: `f/mail/mail_filter_schedule`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
# マスタードキュメント:施肥計画機能
|
||||
|
||||
> **作成**: 2026-03-01
|
||||
> **最終更新**: 2026-03-01
|
||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理)
|
||||
> **実装状況**: 実装完了・本番稼働中(最終 commit deb03ef)
|
||||
> **最終更新**: 2026-03-17
|
||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布実績記録)
|
||||
> **実装状況**: 実装完了・本番稼働中(散布実績連携追加)
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力する。
|
||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力・在庫引当・散布実績記録・作業記録索引生成まで一連で扱う。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
| IN(実装済み) | OUT(対象外) |
|
||||
|---|---|
|
||||
| 肥料マスタ管理 | 肥料購入管理 |
|
||||
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て) |
|
||||
| 3方式の自動計算 | 施肥作業の実績記録 |
|
||||
| 作付け計画からの圃場自動取得 | |
|
||||
| PDF出力(圃場×肥料マトリクス表) | |
|
||||
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照) |
|
||||
| 3方式の自動計算 | 運搬便ごとの散布充当追跡 |
|
||||
| 作付け計画からの圃場自動取得 | 相手先ごとのPDF様式実装 |
|
||||
| PDF出力(圃場×肥料マトリクス表) | 残肥返却・再入庫管理 |
|
||||
| 在庫引当・引当解除 | |
|
||||
| 散布実績記録(日付単位・運搬済み肥料ベース) | |
|
||||
| 作業記録索引(WorkRecord)自動生成 | |
|
||||
| 在庫USE連携(散布実績保存時) | |
|
||||
| 施肥計画進捗表示(未散布/一部散布/完了/計画超過) | |
|
||||
|
||||
---
|
||||
|
||||
@@ -47,9 +52,20 @@
|
||||
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
||||
| year | int | required | 年度 |
|
||||
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
||||
| is_confirmed | bool | default=False | ~~散布確定済みフラグ~~(deprecated: 新UIでは使用しない) |
|
||||
| confirmed_at | datetime | nullable | ~~散布確定日時~~(deprecated: 新UIでは使用しない) |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
#### 表示用計算項目(APIレスポンスに含まれる)
|
||||
|
||||
| 項目 | 型 | 説明 |
|
||||
|---|---|---|
|
||||
| spread_status | string | `unspread` / `partial` / `completed` / `over_applied` |
|
||||
| planned_total_bags | decimal | 計画袋数合計(全entries.bagsの合計) |
|
||||
| spread_total_bags | decimal | 散布済み袋数合計(全entries.actual_bagsの合計) |
|
||||
| remaining_total_bags | decimal | 残袋数(planned_total_bags - spread_total_bags) |
|
||||
|
||||
### FertilizationEntry(施肥エントリ:圃場×肥料×袋数)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
@@ -58,11 +74,60 @@
|
||||
| plan | FK(FertilizationPlan) | CASCADE | |
|
||||
| field | FK(fields.Field) | CASCADE | |
|
||||
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
|
||||
| bags | decimal(8,2) | required | 袋数 |
|
||||
| bags | decimal(8,2) | required | 袋数(計画値) |
|
||||
| actual_bags | decimal(10,4) | nullable | 散布実績集計値(SpreadingSessionItemから自動集計) |
|
||||
|
||||
- `unique_together = ['plan', 'field', 'fertilizer']`
|
||||
- 順序: `field__display_order, field__id, fertilizer__name`
|
||||
|
||||
### SpreadingSession(散布実績セッション)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| year | int | required | 年度フィルタ用 |
|
||||
| date | DateField | required | 散布日 |
|
||||
| name | varchar(100) | required | セッション名(必須) |
|
||||
| notes | text | blank | 備考 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `year + date` の一意制約は付けない(同日に午前・午後やエリア別で複数記録可能)
|
||||
|
||||
### SpreadingSessionItem(散布実績明細)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| session | FK(SpreadingSession) | CASCADE | |
|
||||
| field | FK(fields.Field) | PROTECT | |
|
||||
| fertilizer | FK(Fertilizer) | PROTECT | |
|
||||
| actual_bags | decimal(10,4) | required | 実散布袋数 |
|
||||
| planned_bags_snapshot | decimal(10,4) | required | 表示時点の計画値 |
|
||||
| delivered_bags_snapshot | decimal(10,4) | required | 表示時点の運搬済み合計 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `unique_together = ['session', 'field', 'fertilizer']`
|
||||
|
||||
### WorkRecord(作業記録索引)
|
||||
|
||||
別アプリ `apps/workrecords/` で管理。施肥・運搬の作業を日付順に一覧するための索引テーブル。
|
||||
詳細の本体は各業務テーブル側(DeliveryTrip / SpreadingSession)に持つ。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| work_date | DateField | required | 作業日 |
|
||||
| work_type | varchar | required | `fertilizer_delivery` / `fertilizer_spreading` |
|
||||
| title | varchar(200) | required | 一覧表示名 |
|
||||
| year | int | required | 年度フィルタ補助 |
|
||||
| auto_created | bool | default=True | 自動生成フラグ |
|
||||
| delivery_trip | OneToOne FK(DeliveryTrip) | nullable | 運搬由来 |
|
||||
| spreading_session | OneToOne FK(SpreadingSession) | nullable | 散布由来 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
---
|
||||
|
||||
## API エンドポイント
|
||||
@@ -102,6 +167,8 @@
|
||||
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
||||
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
||||
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
||||
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | ~~散布確定~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | ~~散布確定取消~~(deprecated: UI上で廃止、バックエンドは互換維持) |
|
||||
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
||||
|
||||
一覧レスポンス例(FertilizationPlan):
|
||||
@@ -113,6 +180,8 @@
|
||||
"variety": 3,
|
||||
"variety_name": "コシヒカリ",
|
||||
"crop_name": "米",
|
||||
"is_confirmed": false,
|
||||
"confirmed_at": null,
|
||||
"field_count": 12,
|
||||
"fertilizer_count": 2,
|
||||
"entries": [
|
||||
@@ -146,6 +215,61 @@ POST/PUT リクエスト例:
|
||||
|
||||
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`)
|
||||
|
||||
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数
|
||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布進捗
|
||||
- 操作ボタン: PDF出力・編集・削除
|
||||
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
||||
- 進捗表示: `未散布` / `一部散布 3.5 / 8.0袋` / `散布完了` / `計画超過`
|
||||
- 計画値と実績値を並べて表示
|
||||
|
||||
### 肥料マスタ(`/fertilizer/masters`)
|
||||
|
||||
@@ -295,6 +421,12 @@ GET /api/plans/crops/
|
||||
6. **手動調整**: マトリクス表のセルを直接編集
|
||||
7. **保存**: 「保存」ボタンで entries を一括送信
|
||||
|
||||
#### 在庫連携・実績表示
|
||||
|
||||
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
|
||||
- マトリクス表で `bags`(計画値)を編集可能、`actual_bags`(実績値)は読み取り専用で参照表示
|
||||
- 散布実績画面(`/fertilizer/spreading`)へのリンクを表示
|
||||
|
||||
#### マトリクスの表示仕様
|
||||
|
||||
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
|
||||
@@ -302,7 +434,24 @@ GET /api/plans/crops/
|
||||
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
||||
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
||||
|
||||
#### State 構成
|
||||
### 散布実績画面(`/fertilizer/spreading`)
|
||||
|
||||
- 年度セレクタ(localStorage `fertilizerYear` と連動)
|
||||
- 散布日入力(DateField)
|
||||
- セッション名入力(必須)
|
||||
- 運搬済み・未散布候補一覧を表示(`candidates` APIから取得)
|
||||
- 圃場単位で選択可能(全部または一部)
|
||||
- 実績袋数の編集
|
||||
- 差異がある場合はインライン警告表示
|
||||
- 保存時に在庫USE連携・WorkRecord自動生成・FertilizationEntry.actual_bags再集計を実行
|
||||
|
||||
### 作業記録画面(`/workrecords`)
|
||||
|
||||
- 年度セレクタ
|
||||
- 日付・作業種別・タイトルの一覧表示
|
||||
- 元データ(運搬回 / 散布セッション)への遷移リンク
|
||||
|
||||
#### State 構成(施肥計画編集画面)
|
||||
|
||||
```typescript
|
||||
// 基本情報
|
||||
@@ -338,13 +487,15 @@ backend/apps/fertilizer/
|
||||
├── __init__.py
|
||||
├── admin.py # Django admin 登録
|
||||
├── apps.py # FertilizerConfig
|
||||
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry
|
||||
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
|
||||
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
|
||||
├── urls.py # DefaultRouter + candidate_fields/ + calculate/
|
||||
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry, SpreadingSession, SpreadingSessionItem
|
||||
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer, SpreadingSessionSerializer
|
||||
├── services.py # actual_bags再集計、WorkRecord自動生成、在庫USE連携
|
||||
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, SpreadingSessionViewSet, CandidateFieldsView, CalculateView
|
||||
├── urls.py # DefaultRouter + candidate_fields/ + calculate/ + spreading/
|
||||
├── migrations/
|
||||
│ ├── 0001_initial.py
|
||||
│ └── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
||||
│ ├── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
|
||||
│ └── ... # SpreadingSession, SpreadingSessionItem, actual_bags 追加
|
||||
└── templates/
|
||||
└── fertilizer/
|
||||
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
||||
@@ -362,25 +513,131 @@ frontend/src/app/fertilizer/
|
||||
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
||||
├── masters/
|
||||
│ └── page.tsx # 肥料マスタ管理
|
||||
├── spreading/
|
||||
│ └── ... # 散布実績画面(一覧・作成・編集)
|
||||
└── _components/
|
||||
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
||||
|
||||
frontend/src/app/workrecords/
|
||||
└── ... # 作業記録画面(一覧・詳細)
|
||||
```
|
||||
|
||||
### 変更されたファイル
|
||||
|
||||
| ファイル | 変更内容 |
|
||||
|---|---|
|
||||
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'` を追加 |
|
||||
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
|
||||
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
|
||||
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'`, `'apps.workrecords'` を追加 |
|
||||
| `backend/keinasystem/urls.py` | `api/fertilizer/`, `api/workrecords/` を追加 |
|
||||
| `backend/apps/materials/models.py` | `StockTransaction.spreading_item` FK 追加(`on_delete=SET_NULL`) |
|
||||
| `backend/apps/workrecords/` | 作業記録索引アプリ(WorkRecord モデル・API・services) |
|
||||
| `frontend/src/types/index.ts` | 施肥・散布・作業記録の型を追加 |
|
||||
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |
|
||||
|
||||
---
|
||||
|
||||
## 在庫連携
|
||||
|
||||
### RESERVE(施肥計画保存時)
|
||||
|
||||
- 従来どおり計画値 `bags` ベースで維持
|
||||
- 施肥計画の entries 保存時に RESERVE トランザクションを作成
|
||||
|
||||
### USE(散布実績保存時)
|
||||
|
||||
- `SpreadingSessionItem` ごとに USE を1件作成
|
||||
- `material`: `item.fertilizer.material`
|
||||
- `quantity`: `actual_bags`
|
||||
- `occurred_on`: `session.date`
|
||||
- `note`: `散布実績「{session.name or session.date}」`
|
||||
|
||||
### StockTransaction 追加フィールド
|
||||
|
||||
- `spreading_item = FK(SpreadingSessionItem, null=True, blank=True, on_delete=SET_NULL)`
|
||||
|
||||
### 更新・削除
|
||||
|
||||
- 散布実績更新時: その session に紐づく USE を全置換で作り直す
|
||||
- 散布実績削除時: 対応 USE を削除する
|
||||
|
||||
### RESERVE と USE の整合
|
||||
|
||||
- RESERVE は計画値 `bags` ベース
|
||||
- USE は散布実績 `actual_bags` ベース
|
||||
- 計画値と実績値は併存する
|
||||
|
||||
---
|
||||
|
||||
## 集計ルール
|
||||
|
||||
### planned_total(圃場×肥料×年度)
|
||||
|
||||
`FertilizationEntry.bags` の合計
|
||||
|
||||
### delivered_total(圃場×肥料×年度)
|
||||
|
||||
`DeliveryTrip.date != null` の `DeliveryTripItem.bags` 合計
|
||||
|
||||
### spread_total(圃場×肥料×年度)
|
||||
|
||||
`SpreadingSessionItem.actual_bags` の合計
|
||||
|
||||
### actual_bags 再集計ルール
|
||||
|
||||
- `SUM(SpreadingSessionItem.actual_bags)` を同一 year, field, fertilizer で集計
|
||||
- 散布実績の保存・更新・削除時に該当する `FertilizationEntry.actual_bags` を即時再計算
|
||||
- `SUM(...) = 0` の場合は `actual_bags = null`
|
||||
|
||||
### remaining_bags(表示用の残量)
|
||||
|
||||
`delivered_total - spread_total`
|
||||
|
||||
### remaining_plan_bags(計画進捗用の残量)
|
||||
|
||||
`planned_total - spread_total`
|
||||
|
||||
### 差異の扱い
|
||||
|
||||
- `remaining_bags < 0`: 運搬実績不足
|
||||
- `remaining_plan_bags < 0`: 計画超過
|
||||
- 圃場+肥料単位で差異が分かることを優先する
|
||||
|
||||
---
|
||||
|
||||
## WorkRecord 自動生成ルール
|
||||
|
||||
### 運搬(fertilizer_delivery)
|
||||
|
||||
- `DeliveryTrip.date` 保存時に upsert
|
||||
- `title = 肥料運搬: {delivery_plan.name} {n}回目`
|
||||
- 日付削除時は対応 WorkRecord を削除
|
||||
|
||||
### 散布(fertilizer_spreading)
|
||||
|
||||
- `SpreadingSession` 保存時に upsert
|
||||
- `title = 肥料散布: {session.name or session.date}`
|
||||
- 削除時は対応 WorkRecord を削除
|
||||
|
||||
### 実装方針
|
||||
|
||||
自動生成は view に直書きせず、サービス層(`services.py`)で idempotent に実装する。
|
||||
|
||||
---
|
||||
|
||||
## 前年度コピー
|
||||
|
||||
`copy_from_previous_year` で前年度の `FertilizationEntry` をコピーする際のルール:
|
||||
|
||||
- `actual_bags` がある場合: `actual_bags` を新年度の `bags` 初期値として使用
|
||||
- `actual_bags` が `null` の場合: 従来どおり `bags` をコピー
|
||||
|
||||
前年度に実際に散布した量を次年度計画の初期値として再利用できる。
|
||||
|
||||
---
|
||||
|
||||
## 型定義(TypeScript)
|
||||
|
||||
```typescript
|
||||
// frontend/src/types/index.ts
|
||||
// frontend/src/types/index.ts(主要な型のみ抜粋)
|
||||
|
||||
export interface Fertilizer {
|
||||
id: number;
|
||||
@@ -401,6 +658,7 @@ export interface FertilizationEntry {
|
||||
fertilizer: number;
|
||||
fertilizer_name: string;
|
||||
bags: string;
|
||||
actual_bags: string | null; // 散布実績集計値
|
||||
}
|
||||
|
||||
export interface FertilizationPlan {
|
||||
@@ -413,6 +671,10 @@ export interface FertilizationPlan {
|
||||
field_count: number;
|
||||
fertilizer_count: number;
|
||||
entries: FertilizationEntry[];
|
||||
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
||||
planned_total_bags: string;
|
||||
spread_total_bags: string;
|
||||
remaining_total_bags: string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -451,6 +713,25 @@ plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-v
|
||||
PUT 時は entries を全削除→再作成する「全置換」方式。
|
||||
部分更新は非対応(PATCH でも entries がある場合は全置換)。
|
||||
|
||||
### 散布実績の在庫連携
|
||||
|
||||
- 施肥計画保存時: `RESERVE`(計画値 `bags` ベース)
|
||||
- 散布実績保存時: `USE`(実績値 `actual_bags` ベース)
|
||||
- `RESERVE` と `USE` は併存する(計画値と実績値は別管理)
|
||||
- 散布実績更新時は `session` に紐づく `USE` を全置換で作り直す
|
||||
- 散布実績削除時は対応 `USE` を削除する(`StockTransaction.spreading_item` は `SET_NULL`)
|
||||
- `perform_destroy` で明示的に `StockTransaction` を削除してから `session.delete()` を呼ぶ
|
||||
|
||||
### 散布セッション名は必須
|
||||
|
||||
`SpreadingSession.name` は必須フィールド。WorkRecord のタイトル生成や一覧表示に使用するため、
|
||||
空文字での保存は許可しない。
|
||||
|
||||
### useSearchParams と Suspense(Next.js 14)
|
||||
|
||||
散布実績画面(`/fertilizer/spreading`)では `useSearchParams()` を使用するため、
|
||||
`Suspense` boundary でラップする必要がある(本番ビルドで必須)。
|
||||
|
||||
### Next.js ホットリロードが効かない問題(Windows + Docker)
|
||||
|
||||
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
||||
@@ -463,6 +744,8 @@ Windows 環境では Docker ボリュームマウント経由のファイル変
|
||||
|
||||
## 将来の拡張(スコープ外)
|
||||
|
||||
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||
- **相手先別PDF様式**: 客先ごとの提出資料フォーマット(元データは散布実績から取得可能)
|
||||
- **残肥返却・再入庫管理**: 散布後の残りを在庫に戻す処理
|
||||
- **SpreadingAllocation**: 運搬便単位の散布充当追跡(現状は集計ベースで十分)
|
||||
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
||||
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数)
|
||||
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||
|
||||
@@ -1,65 +1,140 @@
|
||||
# マスタードキュメント:分配計画機能
|
||||
# マスタードキュメント:運搬計画機能(旧・分配計画)
|
||||
|
||||
> **作成**: 2026-03-02
|
||||
> **最終更新**: 2026-03-02
|
||||
> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計)
|
||||
> **実装状況**: 実装完了
|
||||
> **最終更新**: 2026-03-16
|
||||
> **対象機能**: 運搬計画(施肥計画の肥料を軽トラで運ぶ単位で計画・記録する)
|
||||
> **実装状況**: 本番稼働中
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
施肥計画(FertilizationPlan)で決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。
|
||||
例:「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。
|
||||
施肥計画で決めた圃場ごとの肥料袋数を、**軽トラ1回分の積載単位**で運搬計画にまとめる機能。
|
||||
実際の作業では一度に全部運べないため、「何回目にどのグループのどの肥料を何袋運ぶか」を計画・記録する。
|
||||
|
||||
### 旧設計(分配計画)からの変更理由
|
||||
|
||||
旧設計は「1つの施肥計画の圃場をグループ分けする」だけだった。
|
||||
実運用で以下のギャップが判明(2026-03-16):
|
||||
|
||||
1. **複数の施肥計画が混在する** - 軽トラには品種をまたいで積む
|
||||
2. **単一の施肥計画が分割される** - 1回で運びきれない
|
||||
3. **全肥料を一度に運ぶわけではない** - 運ぶ肥料を指定する必要がある
|
||||
4. **圃場単位の合計袋数は不要** - グループ×肥料の合計が重要
|
||||
5. **同じグループの圃場を回ごとに分割する** - 載りきらないときは次の回に
|
||||
6. **作業記録でもある** - 運搬した日付を記録したい
|
||||
|
||||
### 機能スコープ
|
||||
|
||||
| IN(実装済み) | OUT(対象外) |
|
||||
| IN(実装対象) | OUT(対象外) |
|
||||
|---|---|
|
||||
| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 |
|
||||
| グループ×肥料の集計表(画面表示) | 実施記録 |
|
||||
| PDF出力(グループ合計行+圃場サブ行) | |
|
||||
| グループの順序変更・名前変更 | |
|
||||
| 年度単位の運搬計画作成 | 購入管理 |
|
||||
| 配送先グループへの圃場割り当て | 肥料の在庫管理 |
|
||||
| 運搬回ごとの圃場×肥料割り当て | ルート最適化 |
|
||||
| 回ごとの積載合計リアルタイム表示 | |
|
||||
| 圃場を回の間で移動する操作 | |
|
||||
| 「残り全部」一括割り当て | |
|
||||
| 回ごとの運搬日記録 | |
|
||||
| PDF出力(回ごとに1ページ) | |
|
||||
|
||||
---
|
||||
|
||||
## データモデル
|
||||
|
||||
### DistributionPlan(分配計画)
|
||||
### 旧モデルからの移行
|
||||
|
||||
| 旧(削除) | 新(追加) | 備考 |
|
||||
|---|---|---|
|
||||
| DistributionPlan | DeliveryPlan | FK(FertilizationPlan) 廃止 → year ベース |
|
||||
| DistributionGroup | DeliveryGroup | ほぼ同等 |
|
||||
| DistributionGroupField | DeliveryGroupField | ほぼ同等 |
|
||||
| (なし) | DeliveryTrip | 新規:運搬回 |
|
||||
| (なし) | DeliveryTripItem | 新規:運搬明細(圃場×肥料単位) |
|
||||
|
||||
### DeliveryPlan(運搬計画)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| fertilization_plan | FK(FertilizationPlan) | CASCADE | |
|
||||
| year | int | required | 年度 |
|
||||
| name | varchar(200) | required | 計画名 |
|
||||
| created_at / updated_at | datetime | auto | |
|
||||
|
||||
- `ordering = ['-fertilization_plan__year', 'name']`
|
||||
- 1つの施肥計画に対して複数の分配計画を作れる(OneToOneではなくFK)
|
||||
- `ordering = ['-year', 'name']`
|
||||
- 施肥計画への直接FK なし(年度ベースで全施肥計画を横断)
|
||||
|
||||
### DistributionGroup(分配グループ)
|
||||
### DeliveryGroup(配送先グループ)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| distribution_plan | FK(DistributionPlan) | CASCADE | |
|
||||
| name | varchar(100) | required | グループ名 |
|
||||
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
|
||||
| name | varchar(100) | required | グループ名(例: キウイ, 足川北) |
|
||||
| order | PositiveIntegerField | default=0 | 表示順 |
|
||||
|
||||
- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可
|
||||
- `unique_together = [['delivery_plan', 'name']]`
|
||||
- `ordering = ['order', 'id']`
|
||||
|
||||
### DistributionGroupField(グループ圃場割り当て)
|
||||
### DeliveryGroupField(グループ圃場割り当て)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 |
|
||||
| group | FK(DistributionGroup) | CASCADE | |
|
||||
| field | FK(fields.Field) | PROTECT | 圃場 |
|
||||
| delivery_plan | FK(DeliveryPlan) | CASCADE | 一意制約用 |
|
||||
| group | FK(DeliveryGroup) | CASCADE | |
|
||||
| field | FK(fields.Field) | PROTECT | |
|
||||
|
||||
- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画
|
||||
- `ordering = ['field__display_order', 'field__id']`
|
||||
- `unique_together = [['delivery_plan', 'field']]` → 1圃場=1グループ/1計画
|
||||
|
||||
### DeliveryTrip(運搬回)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
|
||||
| order | PositiveIntegerField | default=0 | 何回目(表示順) |
|
||||
| name | varchar(100) | blank | 任意の名前(例: "たちはるか電気炉さい") |
|
||||
| date | DateField | nullable | 運搬日(デフォルト: 1回目の日付を引き継ぎ) |
|
||||
|
||||
- `ordering = ['order', 'id']`
|
||||
|
||||
### DeliveryTripItem(運搬明細)
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| trip | FK(DeliveryTrip) | CASCADE | |
|
||||
| field | FK(fields.Field) | PROTECT | |
|
||||
| fertilizer | FK(Fertilizer) | PROTECT | |
|
||||
| bags | Decimal(10,4) | required | 袋数 |
|
||||
|
||||
- `unique_together = [['trip', 'field', 'fertilizer']]`
|
||||
- bags は施肥計画の FertilizationEntry から自動計算で初期値を設定するが、手動上書きも可能
|
||||
|
||||
### ER図(概念)
|
||||
|
||||
```
|
||||
DeliveryPlan (運搬計画)
|
||||
├── year, name
|
||||
│
|
||||
├── groups → DeliveryGroup (配送先グループ)
|
||||
│ ├── name, order
|
||||
│ └── fields → DeliveryGroupField → Field
|
||||
│
|
||||
└── trips → DeliveryTrip (運搬回)
|
||||
├── order, name, date
|
||||
└── items → DeliveryTripItem
|
||||
├── field → Field
|
||||
├── fertilizer → Fertilizer
|
||||
└── bags
|
||||
```
|
||||
|
||||
### 袋数の算出ルール
|
||||
|
||||
1. 運搬計画作成時、年度の全 FertilizationEntry を参照して「グループ×肥料→圃場×袋数」を自動算出
|
||||
2. ユーザーが運搬回に圃場を割り当てると、該当する FertilizationEntry の bags が DeliveryTripItem.bags にコピーされる
|
||||
3. 手動で bags を上書きすることも可能(施肥計画との差異は許容)
|
||||
4. 「残り全部」操作: 施肥計画の合計 − 既に割り当て済みの回の合計 = 残り
|
||||
|
||||
---
|
||||
|
||||
@@ -69,143 +144,212 @@
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/fertilizer/distribution/?year={year}` | 一覧(年度フィルタ) |
|
||||
| POST | `/api/fertilizer/distribution/` | 新規作成 |
|
||||
| GET | `/api/fertilizer/distribution/{id}/` | 詳細(groups/entries/unassigned込み) |
|
||||
| PUT | `/api/fertilizer/distribution/{id}/` | 更新(groups全置換) |
|
||||
| DELETE | `/api/fertilizer/distribution/{id}/` | 削除 |
|
||||
| GET | `/api/fertilizer/distribution/{id}/pdf/` | PDF出力(application/pdf) |
|
||||
| GET | `/api/fertilizer/delivery/?year={year}` | 一覧(年度フィルタ) |
|
||||
| POST | `/api/fertilizer/delivery/` | 新規作成 |
|
||||
| GET | `/api/fertilizer/delivery/{id}/` | 詳細(groups/trips/items 込み) |
|
||||
| PUT | `/api/fertilizer/delivery/{id}/` | 更新(groups・trips 全置換) |
|
||||
| DELETE | `/api/fertilizer/delivery/{id}/` | 削除 |
|
||||
| GET | `/api/fertilizer/delivery/{id}/pdf/` | PDF出力 |
|
||||
|
||||
### 一覧レスポンス(DistributionPlanListSerializer)
|
||||
### 一覧レスポンス
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "2025年コシヒカリ 分配計画",
|
||||
"fertilization_plan_id": 3,
|
||||
"fertilization_plan_name": "2025年コシヒカリ施肥計画",
|
||||
"year": 2025,
|
||||
"variety_name": "コシヒカリ",
|
||||
"crop_name": "米",
|
||||
"group_count": 3,
|
||||
"field_count": 12,
|
||||
"year": 2026,
|
||||
"name": "2026春 肥料運搬",
|
||||
"group_count": 5,
|
||||
"trip_count": 3,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 詳細レスポンス(DistributionPlanReadSerializer)
|
||||
### 詳細レスポンス
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "2025年コシヒカリ 分配計画",
|
||||
"fertilization_plan": {
|
||||
"id": 3,
|
||||
"name": "2025年コシヒカリ施肥計画",
|
||||
"year": 2025,
|
||||
"variety_name": "コシヒカリ",
|
||||
"crop_name": "米",
|
||||
"fertilizers": [{"id": 1, "name": "一発肥料"}],
|
||||
"entries": [{"field": 5, "fertilizer": 1, "bags": "2.40"}]
|
||||
},
|
||||
"year": 2026,
|
||||
"name": "2026春 肥料運搬",
|
||||
"groups": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "田中エリア",
|
||||
"name": "キウイ",
|
||||
"order": 0,
|
||||
"fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}]
|
||||
"fields": [
|
||||
{"id": 5, "name": "キウイ畑1", "area_tan": "1.2000"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}]
|
||||
"trips": [
|
||||
{
|
||||
"id": 1,
|
||||
"order": 0,
|
||||
"name": "1回目 たちはるか電気炉さい",
|
||||
"date": "2026-03-16",
|
||||
"items": [
|
||||
{"field": 5, "fertilizer": 1, "bags": "4.00"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"unassigned_fields": [],
|
||||
"available_fertilizers": [
|
||||
{"id": 1, "name": "電気炉さい"},
|
||||
{"id": 2, "name": "ミネラルホウ素"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `available_fertilizers`: 該当年度の全施肥計画で使われている肥料の一覧
|
||||
- `unassigned_fields`: グループに割り当てられていない圃場
|
||||
|
||||
### 書き込みリクエスト(POST/PUT)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "2025年コシヒカリ 分配計画",
|
||||
"fertilization_plan_id": 3,
|
||||
"year": 2026,
|
||||
"name": "2026春 肥料運搬",
|
||||
"groups": [
|
||||
{"name": "田中エリア", "order": 0, "field_ids": [5, 6]},
|
||||
{"name": "奥地エリア", "order": 1, "field_ids": [7]}
|
||||
{"name": "キウイ", "order": 0, "field_ids": [5, 6]}
|
||||
],
|
||||
"trips": [
|
||||
{
|
||||
"order": 0,
|
||||
"name": "1回目",
|
||||
"date": "2026-03-16",
|
||||
"items": [
|
||||
{"field_id": 5, "fertilizer_id": 1, "bags": "4.00"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
PUT は groups を全削除→再作成する全置換方式。
|
||||
PUT は groups・trips を全削除→再作成する全置換方式。
|
||||
|
||||
---
|
||||
|
||||
## フロントエンド画面
|
||||
|
||||
### 分配計画一覧 `/distribution`
|
||||
### 運搬計画一覧 `/distribution`
|
||||
|
||||
- 年度セレクタ(`localStorage distributionYear` で保持)
|
||||
- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場数
|
||||
- テーブル: 計画名・グループ数・回数
|
||||
- アクション: PDF・編集・削除
|
||||
- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示)
|
||||
|
||||
### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit`
|
||||
### 運搬計画編集 `/distribution/new` / `/distribution/[id]/edit`
|
||||
|
||||
**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx`
|
||||
#### 画面レイアウト
|
||||
|
||||
#### State構成
|
||||
```
|
||||
[計画名: ________________] [年度: 2026]
|
||||
|
||||
```typescript
|
||||
// 基本情報
|
||||
const [name, setName] = useState('')
|
||||
const [fertilizationPlanId, setFertilizationPlanId] = useState<number|''>('')
|
||||
━━━ グループ定義 ━━━━━━━━━━━━━━━━━━━
|
||||
(既存の方式: グループ追加・圃場割り当て・並び替え)
|
||||
|
||||
// 施肥計画詳細(施肥計画選択後に取得)
|
||||
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null)
|
||||
━━━ 対象肥料 ━━━━━━━━━━━━━━━━━━━━━
|
||||
☑電気炉さい ☑ミネラルホウ素 ☐有機100号 ...
|
||||
(年度の施肥計画に含まれる肥料をチェックボックスで選択)
|
||||
|
||||
// ローカルグループ(tempId で管理、保存時にサーバーへ送信)
|
||||
const [groups, setGroups] = useState<LocalGroup[]>([])
|
||||
// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string }
|
||||
━━━ 未割り当て ━━━━━━━━━━━━━━━━━━━━
|
||||
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
|
||||
圃場A 電気炉さい:2 ミネラルホウ素:3 [→1回目 ▼]
|
||||
圃場B 電気炉さい:2 ミネラルホウ素:2 [→1回目 ▼]
|
||||
★ 足川北 (小計: 電気炉さい 12, ミネラルホウ素 6)
|
||||
圃場D ...
|
||||
|
||||
━━━ 1回目 (2026-03-16) ━━━ 積載: 46袋 ━━━
|
||||
日付: [2026-03-16] 名前: [たちはるか電気炉さい]
|
||||
★ たちはるか (小計: 電気炉さい 46)
|
||||
圃場X 電気炉さい:10 [←戻す]
|
||||
圃場Y 電気炉さい:12 [←戻す]
|
||||
...
|
||||
|
||||
━━━ 2回目 (2026-03-16) ━━━ 積載: 39袋 ━━━
|
||||
日付: [2026-03-16] 名前: [____________]
|
||||
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
|
||||
...
|
||||
|
||||
[+回を追加] [残り全部→新しい回] [保存]
|
||||
```
|
||||
|
||||
#### UI構成
|
||||
#### 主要な操作
|
||||
|
||||
1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ
|
||||
2. **グループ割り当て**:
|
||||
- 新規グループ追加(名前入力 + 追加ボタン)
|
||||
- グループカード(↑↓順序変更・鉛筆名前変更・×削除)
|
||||
- グループ内圃場(×解除)+ 肥料別袋数をインライン表示
|
||||
- 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て)
|
||||
3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし)
|
||||
| 操作 | 方法 | 説明 |
|
||||
|---|---|---|
|
||||
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
|
||||
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
|
||||
| 圃場を別の回に移動 | 圃場行の「移動...」ドロップダウン | 回の間で移動 |
|
||||
| グループを回に一括割り当て | 回内の「グループを追加...」ドロップダウン | グループの全圃場を一括割り当て |
|
||||
| グループを別の回に移動 | グループ★行の「移動...」ドロップダウン | グループの全圃場を回の間で一括移動 |
|
||||
| グループを未割り当てに戻す | グループ★行の「移動...」→「未割り当てに戻す」 | グループの全圃場を一括で未割り当てに戻す |
|
||||
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て全圃場を新しい回に追加 |
|
||||
| 回の追加 | 「+回を追加」ボタン | 空の回を追加 |
|
||||
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
|
||||
| 回の日付設定 | 日付入力フィールド | デフォルトは1回目の日付 |
|
||||
| 対象肥料の絞り込み | チェックボックス | 選択した肥料だけ表示 |
|
||||
|
||||
#### 積載合計のリアルタイム表示
|
||||
|
||||
各回のヘッダーに、その回の肥料ごとの合計袋数と総袋数を表示。
|
||||
圃場を追加・削除するたびに即時再計算(サーバー通信なし)。
|
||||
|
||||
---
|
||||
|
||||
## PDF 出力
|
||||
|
||||
`GET /api/fertilizer/distribution/{id}/pdf/`
|
||||
`GET /api/fertilizer/delivery/{id}/pdf/`
|
||||
|
||||
- WeasyPrint(既存施肥計画PDFと同じ仕組み)
|
||||
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html`
|
||||
- フォーマット: A4横向き
|
||||
- 内容:
|
||||
- ★グループ合計行(太字・緑背景)
|
||||
- 圃場サブ行(小フォント・灰色背景)
|
||||
- 肥料列合計・総合計
|
||||
- ファイル名: `distribution_{year}_{plan_id}.pdf`
|
||||
### フォーマット
|
||||
|
||||
- WeasyPrint、A4横向き
|
||||
- **回ごとに1ページ**(1回目=1ページ目、2回目=2ページ目...)
|
||||
|
||||
### 各ページの内容
|
||||
|
||||
```
|
||||
━━━ 2回目 2026-03-16 ━━━━━━━━━━━━━━━
|
||||
電気炉さい ミネラルホウ素
|
||||
★ キウイ 4 5
|
||||
圃場A 2 3
|
||||
圃場B 2 2
|
||||
★ 池田さんちの前 2 2
|
||||
圃場C 2 2
|
||||
★ 足川北 12 6
|
||||
圃場D 4 2
|
||||
圃場E 4 2
|
||||
圃場F 4 2
|
||||
★ 出祥邸 - 8
|
||||
圃場G - 4
|
||||
圃場H - 4
|
||||
─────────────────────────────────────
|
||||
合計 18 21
|
||||
```
|
||||
|
||||
- ★行: グループ小計(肥料ごと)、太字・緑背景
|
||||
- 圃場行: 各圃場の肥料ごとの袋数(**合計列なし**)
|
||||
- 最下行: 回全体の肥料ごと合計
|
||||
- 日付を各ページのヘッダーに記載
|
||||
- ファイル名: `delivery_{year}_{plan_id}.pdf`
|
||||
|
||||
---
|
||||
|
||||
## ファイル構成
|
||||
## ファイル構成(予定)
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
backend/apps/fertilizer/
|
||||
├── models.py # DistributionPlan/Group/GroupField 追加(migration 0003)
|
||||
├── serializers.py # Distribution* シリアライザ追加
|
||||
├── views.py # DistributionPlanViewSet 追加
|
||||
├── urls.py # router.register('distribution', ...) 追加
|
||||
├── admin.py # DistributionPlan/Group の admin 登録
|
||||
├── models.py # DeliveryPlan/Group/GroupField/Trip/TripItem
|
||||
├── serializers.py # Delivery* シリアライザ
|
||||
├── views.py # DeliveryPlanViewSet
|
||||
├── urls.py # router.register('delivery', ...)
|
||||
├── admin.py # DeliveryPlan 等の admin 登録
|
||||
├── migrations/
|
||||
│ └── 000X_delivery_*.py # 旧Distribution → 新Delivery マイグレーション
|
||||
└── templates/fertilizer/
|
||||
└── distribution_pdf.html # A4横 PDF テンプレート
|
||||
└── delivery_pdf.html # 回ごと1ページ PDF テンプレート
|
||||
```
|
||||
|
||||
### Frontend
|
||||
@@ -215,27 +359,54 @@ frontend/src/app/distribution/
|
||||
├── page.tsx # 一覧ページ
|
||||
├── new/page.tsx # 新規作成(ラッパー)
|
||||
├── [id]/edit/page.tsx # 編集(ラッパー)
|
||||
└── _components/DistributionEditPage.tsx # 編集共通コンポーネント
|
||||
└── _components/DeliveryEditPage.tsx # 編集共通コンポーネント
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## マイグレーション方針
|
||||
|
||||
### 旧モデル(Distribution*)の扱い
|
||||
|
||||
1. 新モデル(Delivery*)を追加するマイグレーションを作成
|
||||
2. 旧モデル(Distribution*)は削除マイグレーションで除去
|
||||
3. 旧データは少量のため、データ移行は行わない(手動で再作成)
|
||||
|
||||
### マイグレーション順序
|
||||
|
||||
1. `000X_add_delivery_models.py` - DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem を追加
|
||||
2. `000Y_remove_distribution_models.py` - DistributionPlan, DistributionGroup, DistributionGroupField を削除
|
||||
|
||||
---
|
||||
|
||||
## 注意点
|
||||
|
||||
### 集計は全クライアントサイド計算
|
||||
### 施肥計画との関係
|
||||
|
||||
集計プレビューは API を呼ばず、`fertPlanDetail.entries` と `groups.fieldIds` からクライアントで計算する。
|
||||
- 運搬計画は施肥計画への直接FKを持たない
|
||||
- 年度ベースで、その年度の全 FertilizationEntry を参照して圃場×肥料の袋数を取得する
|
||||
- 施肥計画を変更すると、未割り当ての圃場の袋数は自動で反映される
|
||||
- 既に運搬回に割り当て済みの DeliveryTripItem.bags は変わらない(コピー済み)
|
||||
|
||||
### 集計はクライアントサイド計算
|
||||
|
||||
画面上の集計(グループ小計・回の積載合計)は API を呼ばずクライアントで計算。
|
||||
PDF生成時のみサーバーサイドで同じ計算を実施。
|
||||
|
||||
### PUT の全置換方式
|
||||
|
||||
PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。
|
||||
|
||||
### 未割り当て圃場の扱い
|
||||
|
||||
- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示
|
||||
- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし)
|
||||
|
||||
### エラー表示方針
|
||||
|
||||
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
|
||||
|
||||
### 散布実績との連携
|
||||
|
||||
- 運搬計画の `DeliveryTripItem` が散布実績画面(`/fertilizer/spreading`)の候補データソースとなる
|
||||
- `DeliveryTrip.date != null` の明細のみを「運搬済み」とみなし、散布候補に含める
|
||||
- 散布実績画面から運搬計画を指定して遷移する場合(`?delivery_plan_id=N`)、日付フィルタは適用されない(その計画の全明細が候補になる)
|
||||
- 散布実績の保存時に在庫 `USE` が作成される(運搬時点では在庫変動なし)
|
||||
|
||||
### WorkRecord 自動生成
|
||||
|
||||
- `DeliveryTrip` に日付が保存されると、`WorkRecord`(`work_type=fertilizer_delivery`)が自動生成される
|
||||
- 実装: `apps/workrecords/services.py` の `sync_delivery_work_record()`
|
||||
- `DeliveryTrip` の日付が削除されると、対応する `WorkRecord` も削除される
|
||||
- `WorkRecord` は索引として機能し、明細データは `DeliveryTrip` / `DeliveryTripItem` 側が保持する
|
||||
|
||||
411
document/15_マスタードキュメント_トラクター作業編.md
Normal file
411
document/15_マスタードキュメント_トラクター作業編.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# マスタードキュメント:トラクター作業記録機能
|
||||
|
||||
> **作成**: 2026-04-04
|
||||
> **最終更新**: 2026-04-10
|
||||
> **対象機能**: トラクター作業記録(畔塗・荒代掻き・植代掻き・耕耘)
|
||||
> **実装状況**: 畔塗のみ実装済み。荒代掻き・植代掻き・耕耘は設計中(Issue #21)
|
||||
> **対象 Issue**: `akira/keinasystem#21`
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が、水稲作付け圃場に対して実施したトラクター作業を日付単位で記録する機能。
|
||||
対象圃場をまとめて選択し、保存時に作業記録一覧へ自動反映する。
|
||||
|
||||
対象作業種別:
|
||||
|
||||
| 種別 | 日本語名 | 説明 |
|
||||
|---|---|---|
|
||||
| `levee_work` | 畔塗 | 畦畔の補修・造成 |
|
||||
| `rough_harrowing` | 荒代掻き | 田植え前の粗い代掻き |
|
||||
| `transplant_harrowing` | 植代掻き | 田植え直前の仕上げ代掻き |
|
||||
| `cultivation` | 耕耘 | 土起こし・耕起 |
|
||||
|
||||
これらはいずれも**トラクターを用いた資材なし作業**であり、同一のデータモデルで管理する。
|
||||
|
||||
本機能は、施肥計画の散布実績と同様に
|
||||
「作業本体を専用テーブルで持ち、作業記録一覧には索引を自動生成する」
|
||||
という設計方針を採用する。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
| IN(本機能で扱う) | OUT(本機能では扱わない) |
|
||||
|---|---|
|
||||
| 日付単位の作業記録作成(全4種別) | 作業の工程管理 |
|
||||
| 水稲作付け圃場の候補抽出 | 作業者別の工数集計 |
|
||||
| 複数圃場の一括選択・保存 | 機械・資材の在庫管理 |
|
||||
| 作業記録一覧(WorkRecord)への自動反映 | 写真添付 |
|
||||
| 記録の編集・削除 | GPS軌跡連携 |
|
||||
| 対象圃場一覧の参照画面 | 汎用作業日誌への完全統合 |
|
||||
|
||||
---
|
||||
|
||||
## 背景と目的
|
||||
|
||||
現状システムには畔塗の記録機能があるが、同じトラクター作業である荒代掻き・植代掻き・耕耘は登録できない。
|
||||
これらは以下の共通点を持つため、統一モデルで管理する。
|
||||
|
||||
- 1日で複数圃場をまとめて実施することが多い
|
||||
- 対象圃場は当年の作付け計画と密接に関係する
|
||||
- 後から「いつ、どの圃場を実施したか」を一覧で見返したい
|
||||
- 使用資材がない(施肥・農薬とは区別される)
|
||||
|
||||
---
|
||||
|
||||
## データモデル
|
||||
|
||||
### TractorWorkSession(トラクター作業記録本体)
|
||||
|
||||
日付単位のトラクター作業記録。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| work_type | varchar(30) | required | 作業種別(下記参照) |
|
||||
| year | int | required | 年度フィルタ用。原則 `date.year` と一致させる |
|
||||
| date | DateField | required | 作業日 |
|
||||
| title | varchar(100) | required | 一覧表示タイトル。未指定時はサーバー側で work_type に応じたデフォルト値を補完する |
|
||||
| notes | text | blank | 備考 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
#### work_type の値とデフォルトタイトル
|
||||
|
||||
| work_type | 表示名 | デフォルトタイトル |
|
||||
|---|---|---|
|
||||
| `levee_work` | 畔塗 | 水稲畔塗 |
|
||||
| `rough_harrowing` | 荒代掻き | 水稲荒代掻き |
|
||||
| `transplant_harrowing` | 植代掻き | 水稲植代掻き |
|
||||
| `cultivation` | 耕耘 | 水稲耕耘 |
|
||||
|
||||
- `year + date` の一意制約は付けない
|
||||
- 同日に種別違い・地区違いで複数記録を持てるようにする
|
||||
|
||||
### TractorWorkSessionItem(対象圃場明細)
|
||||
|
||||
トラクター作業記録に紐づく対象圃場一覧。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| id | int | PK | |
|
||||
| session | FK(TractorWorkSession) | CASCADE | 親の作業記録 |
|
||||
| field | FK(fields.Field) | PROTECT | 対象圃場 |
|
||||
| plan | FK(plans.Plan) | SET_NULL, nullable | 保存時点の作付け計画参照 |
|
||||
| crop_name_snapshot | varchar(100) | required | 保存時点の作物名 |
|
||||
| variety_name_snapshot | varchar(100) | blank | 保存時点の品種名 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
- `unique_together = ['session', 'field']`
|
||||
- 圃場名は `Field` を参照して表示する
|
||||
- 作物・品種は履歴保全のためスナップショット保持
|
||||
|
||||
### WorkRecord(作業記録索引)
|
||||
|
||||
既存 `apps/workrecords` の `WorkRecord` をトラクター作業に対応させる。
|
||||
|
||||
| 変更点 | 内容 |
|
||||
|---|---|
|
||||
| `work_type` enum | `TRACTOR_WORK = 'tractor_work'` を追加(`LEVEE_WORK` を置換) |
|
||||
| FK | `tractor_work_session = OneToOneField('tractor_work.TractorWorkSession', ...)` に改名 |
|
||||
|
||||
制約:
|
||||
|
||||
- `on_delete=CASCADE`
|
||||
- `null=True`, `blank=True`
|
||||
- `related_name='work_record'`
|
||||
|
||||
一覧表示時の想定値:
|
||||
|
||||
| 項目 | 値 |
|
||||
|---|---|
|
||||
| 作業日 | 作業記録の日付 |
|
||||
| 種別 | トラクター作業(work_type の日本語表示) |
|
||||
| タイトル | session.title |
|
||||
| 参照先 | 対象圃場一覧画面 |
|
||||
|
||||
---
|
||||
|
||||
## 候補圃場抽出ルール
|
||||
|
||||
候補は作付け計画 `Plan` から抽出する。
|
||||
|
||||
### 基本条件
|
||||
|
||||
- 指定年度の `Plan` であること
|
||||
- `crop.name = "水稲"` の圃場であること
|
||||
|
||||
### 補足
|
||||
|
||||
- 品種未設定でも `crop=水稲` なら候補に含める
|
||||
- 並び順は `field.display_order`, `field.id`
|
||||
|
||||
### 候補レスポンスで返す情報
|
||||
|
||||
| 項目 | 説明 |
|
||||
|---|---|
|
||||
| field_id | 圃場ID |
|
||||
| field_name | 圃場名 |
|
||||
| field_area_tan | 面積(反) |
|
||||
| group_name | グループ名 |
|
||||
| plan_id | 対応する作付け計画ID |
|
||||
| crop_name | 作物名 |
|
||||
| variety_name | 品種名 |
|
||||
| selected | 初期選択状態(原則 `true`) |
|
||||
|
||||
---
|
||||
|
||||
## 画面仕様
|
||||
|
||||
### 画面の位置づけ
|
||||
|
||||
日付と作業種別を先に決めて対象圃場を選ぶ「日報型UI」。
|
||||
1回の保存で複数圃場をまとめて記録する。
|
||||
|
||||
### 主要画面
|
||||
|
||||
#### 1. トラクター作業記録一覧画面(`/tractor-work`)
|
||||
|
||||
- 年度内の記録を一覧する
|
||||
- 作業種別でフィルター可能
|
||||
- 新規作成・既存記録の編集・削除
|
||||
|
||||
表示項目: 作業日 / 作業種別 / タイトル / 対象圃場数 / 面積合計 / 備考
|
||||
|
||||
#### 2. 作成・編集画面
|
||||
|
||||
入力項目:
|
||||
|
||||
- **作業種別**(畔塗 / 荒代掻き / 植代掻き / 耕耘)← 新規追加
|
||||
- 日付
|
||||
- タイトル(work_type に連動したデフォルト値を自動セット)
|
||||
- 備考
|
||||
- 対象圃場一覧(チェックボックス)
|
||||
|
||||
### 推奨UIイメージ
|
||||
|
||||
```text
|
||||
トラクター作業記録作成
|
||||
|
||||
[作業種別 荒代掻き ▼]
|
||||
[日付 2026-04-20]
|
||||
[タイトル 水稲荒代掻き]
|
||||
[備考 __________________ ]
|
||||
|
||||
対象圃場一覧
|
||||
[全選択] [全解除]
|
||||
|
||||
☑ 田中上 1.2反 上エリア コシヒカリ
|
||||
☑ 田中下 0.8反 上エリア あきたこまち
|
||||
☐ 山の前 1.5反 南エリア (未設定)
|
||||
|
||||
[保存]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API エンドポイント
|
||||
|
||||
すべて JWT 認証必須。
|
||||
|
||||
### トラクター作業記録
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/tractor-work/sessions/?year={year}` | 年度別一覧 |
|
||||
| POST | `/api/tractor-work/sessions/` | 新規作成 |
|
||||
| GET | `/api/tractor-work/sessions/{id}/` | 詳細取得 |
|
||||
| PUT/PATCH | `/api/tractor-work/sessions/{id}/` | 更新 |
|
||||
| DELETE | `/api/tractor-work/sessions/{id}/` | 削除 |
|
||||
|
||||
### 候補圃場取得
|
||||
|
||||
| メソッド | URL | 説明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/tractor-work/candidates/?year={year}` | 水稲作付け圃場候補を返す |
|
||||
|
||||
### リクエスト例(新規作成)
|
||||
|
||||
```json
|
||||
{
|
||||
"work_type": "rough_harrowing",
|
||||
"year": 2026,
|
||||
"date": "2026-04-20",
|
||||
"title": "水稲荒代掻き",
|
||||
"notes": "",
|
||||
"items": [
|
||||
{ "field": 5, "plan": 12 },
|
||||
{ "field": 6, "plan": 13 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `crop_name_snapshot` / `variety_name_snapshot` はクライアント送信不要。サーバーが `plan` から自動設定する
|
||||
- `plan` が `null` の場合は `field` に対応する当年 `Plan` から補完を試みる
|
||||
|
||||
### レスポンス例(詳細)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"work_type": "rough_harrowing",
|
||||
"year": 2026,
|
||||
"date": "2026-04-20",
|
||||
"title": "水稲荒代掻き",
|
||||
"notes": "",
|
||||
"work_record_id": 15,
|
||||
"item_count": 2,
|
||||
"total_area_tan": "2.0000",
|
||||
"items": [
|
||||
{
|
||||
"id": 11,
|
||||
"field": 5,
|
||||
"field_name": "田中上",
|
||||
"plan": 12,
|
||||
"crop_name_snapshot": "水稲",
|
||||
"variety_name_snapshot": "コシヒカリ"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-04-20T08:00:00Z",
|
||||
"updated_at": "2026-04-20T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 業務フロー
|
||||
|
||||
### 1. 新規作成
|
||||
|
||||
1. ユーザーが作業種別・年度・日付を選ぶ
|
||||
2. システムが当年の水稲作付け圃場を候補表示する
|
||||
3. ユーザーが対象圃場を選択する
|
||||
4. 保存時に `TractorWorkSession` を作成する
|
||||
5. 明細として `TractorWorkSessionItem` を一括作成する
|
||||
6. 各明細の `crop_name_snapshot` / `variety_name_snapshot` をサーバー側で自動設定する
|
||||
7. `WorkRecord` を自動生成する(`update_or_create`)
|
||||
|
||||
### 2. 編集
|
||||
|
||||
1. ユーザーが既存の作業記録を開く
|
||||
2. 作業種別・日付・タイトル・備考・対象圃場を変更する
|
||||
3. 保存時に明細を再構成する
|
||||
4. `WorkRecord` 側の作業日・タイトルも同期更新する
|
||||
|
||||
### 3. 削除
|
||||
|
||||
1. ユーザーが作業記録を削除する
|
||||
2. 紐づく `TractorWorkSessionItem` は `CASCADE` で削除される
|
||||
3. 紐づく `WorkRecord` は `tractor_work_session` の `on_delete=CASCADE` により削除される
|
||||
|
||||
---
|
||||
|
||||
## 作業記録連携仕様
|
||||
|
||||
### 追加する種別
|
||||
|
||||
| enum値 | 表示名 |
|
||||
|---|---|
|
||||
| `tractor_work` | トラクター作業 |
|
||||
|
||||
### 自動生成ルール
|
||||
|
||||
- `work_date` = `session.date`
|
||||
- `work_type` = `tractor_work`
|
||||
- `title` = `session.title`(work_type 別デフォルトで補完済み)
|
||||
- `year` = `session.year`
|
||||
- `auto_created` = `True`
|
||||
- `tractor_work_session` = 対応する作業記録
|
||||
|
||||
### 同期タイミング
|
||||
|
||||
- 作成時・更新時: `update_or_create`
|
||||
- 削除時: `on_delete=CASCADE` により自動削除
|
||||
|
||||
---
|
||||
|
||||
## バリデーションルール
|
||||
|
||||
### 必須
|
||||
|
||||
- `work_type`
|
||||
- `year`
|
||||
- `date`
|
||||
- `items`(1件以上)
|
||||
|
||||
### 保存時チェック
|
||||
|
||||
- 選択圃場が0件の保存を禁止する
|
||||
- 同一セッション内で同じ圃場を重複登録しない
|
||||
- `year` は原則 `date.year` と一致しなければならない
|
||||
- `plan` が指定されている場合、`plan.field` と `field` は一致しなければならない
|
||||
- `plan.year` は `session.year` と一致しなければならない
|
||||
|
||||
### 業務上の許容
|
||||
|
||||
- 品種未設定の水稲圃場は保存可
|
||||
- 同日に別種別・別地区で複数記録を持てる
|
||||
- 一度作業した圃場を別日に再度記録することは可
|
||||
|
||||
---
|
||||
|
||||
## 実装方針
|
||||
|
||||
### 移行方針(levee_work → tractor_work)
|
||||
|
||||
既存 `apps/levee_work` を `apps/tractor_work` にアプリごと改名する。
|
||||
|
||||
- Django の `RenameModel` migration でテーブルを改名する
|
||||
- `work_type` フィールドを追加し、既存レコードは `levee_work` で埋める
|
||||
- `workrecords` の FK名・enum値も migration で更新する
|
||||
- API パスを `/levee-work/` → `/tractor-work/` に変更する
|
||||
- フロントエンドの `app/levee-work/` → `app/tractor-work/` に移動する
|
||||
|
||||
### バックエンド
|
||||
|
||||
- `Session` / `SessionItem` 構成を維持する
|
||||
- Serializer は `read` と `write` を分離する
|
||||
- 候補取得 API は `Plan` を起点に組み立てる
|
||||
- `sync_tractor_work_record(session)` で `WorkRecord` と同期する
|
||||
|
||||
### フロントエンド
|
||||
|
||||
- 既存の levee-work ページを tractor-work に移植する
|
||||
- 作業種別セレクタを追加し、選択に応じてデフォルトタイトルを自動セットする
|
||||
|
||||
---
|
||||
|
||||
## ソースファイル構成
|
||||
|
||||
### バックエンド
|
||||
|
||||
```
|
||||
backend/apps/tractor_work/
|
||||
├── models.py # TractorWorkSession, TractorWorkSessionItem
|
||||
├── serializers.py
|
||||
├── views.py
|
||||
├── urls.py
|
||||
├── admin.py
|
||||
└── migrations/
|
||||
├── 0001_initial.py # (levee_work から移行)
|
||||
└── 0002_rename_and_add_work_type.py
|
||||
```
|
||||
|
||||
変更ファイル:
|
||||
- `backend/apps/workrecords/models.py` — FK名・enum更新
|
||||
- `backend/apps/workrecords/services.py` — sync関数改名
|
||||
- `backend/keinasystem/settings.py` — INSTALLED_APPS更新
|
||||
- `backend/keinasystem/urls.py` — URLパス更新
|
||||
|
||||
### フロントエンド
|
||||
|
||||
```
|
||||
frontend/src/app/tractor-work/
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
変更ファイル:
|
||||
- `frontend/src/types/index.ts` — 型定義更新
|
||||
- `frontend/src/components/Navbar.tsx` — リンク更新
|
||||
- `frontend/src/app/workrecords/page.tsx` — 遷移先更新
|
||||
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`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user