Compare commits

..

2 Commits

Author SHA1 Message Date
akira
b7b9818855 feat: add levee work records 2026-04-04 11:32:26 +09:00
akira
c773c7d3b8 docs: add levee work master document 2026-04-04 11:13:11 +09:00
21 changed files with 1486 additions and 6 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import LeveeWorkSession, LeveeWorkSessionItem
class LeveeWorkSessionItemInline(admin.TabularInline):
model = LeveeWorkSessionItem
extra = 0
@admin.register(LeveeWorkSession)
class LeveeWorkSessionAdmin(admin.ModelAdmin):
list_display = ['date', 'title', 'year', 'created_at']
list_filter = ['year', 'date']
search_fields = ['title', 'items__field__name']
inlines = [LeveeWorkSessionItemInline]

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class LeveeWorkConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.levee_work'
verbose_name = '畔塗作業'

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2 on 2026-04-04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fields', '0006_e1c_chusankan_17_fields'),
('plans', '0004_crop_base_temp'),
]
operations = [
migrations.CreateModel(
name='LeveeWorkSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.IntegerField(verbose_name='年度')),
('date', models.DateField(verbose_name='畔塗日')),
('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')),
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '畔塗記録',
'verbose_name_plural': '畔塗記録',
'ordering': ['-date', '-id'],
},
),
migrations.CreateModel(
name='LeveeWorkSessionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
],
options={
'verbose_name': '畔塗対象圃場',
'verbose_name_plural': '畔塗対象圃場',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('session', 'field')},
},
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,59 @@
from django.db import models
class LeveeWorkSession(models.Model):
year = models.IntegerField(verbose_name='年度')
date = models.DateField(verbose_name='畔塗日')
title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル')
notes = models.TextField(blank=True, default='', verbose_name='備考')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '畔塗記録'
verbose_name_plural = '畔塗記録'
ordering = ['-date', '-id']
def __str__(self):
return f'{self.date} {self.title}'
class LeveeWorkSessionItem(models.Model):
session = models.ForeignKey(
LeveeWorkSession,
on_delete=models.CASCADE,
related_name='items',
verbose_name='畔塗記録',
)
field = models.ForeignKey(
'fields.Field',
on_delete=models.PROTECT,
verbose_name='圃場',
)
plan = models.ForeignKey(
'plans.Plan',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name='作付け計画',
)
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
variety_name_snapshot = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='品種名スナップショット',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '畔塗対象圃場'
verbose_name_plural = '畔塗対象圃場'
unique_together = [['session', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f'{self.session} / {self.field.name}'

View File

@@ -0,0 +1,142 @@
from django.db import transaction
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()
class Meta:
model = LeveeWorkSession
fields = [
'id',
'year',
'date',
'title',
'notes',
'work_record_id',
'item_count',
'items',
'created_at',
'updated_at',
]
def get_item_count(self, obj):
return len(obj.items.all())
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
field = serializers.IntegerField()
plan = serializers.IntegerField(required=False, allow_null=True)
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
class Meta:
model = LeveeWorkSession
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
def validate(self, attrs):
year = attrs.get('year', getattr(self.instance, 'year', None))
date = attrs.get('date', getattr(self.instance, 'date', None))
if year is not None and date is not None and year != date.year:
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
return attrs
def validate_items(self, value):
if not value:
raise serializers.ValidationError('items を1件以上指定してください。')
seen = set()
for item in value:
key = item['field']
if key in seen:
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
seen.add(key)
return value
@transaction.atomic
def create(self, validated_data):
items_data = validated_data.pop('items', [])
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
session = LeveeWorkSession.objects.create(**validated_data)
self._replace_items(session, items_data)
sync_levee_work_record(session)
return session
@transaction.atomic
def update(self, instance, validated_data):
items_data = validated_data.pop('items', None)
for attr, value in validated_data.items():
if attr == 'title':
value = (value or '').strip() or '水稲畔塗'
setattr(instance, attr, value)
if 'title' not in validated_data:
instance.title = (instance.title or '').strip() or '水稲畔塗'
instance.save()
if items_data is not None:
self._replace_items(instance, items_data)
sync_levee_work_record(instance)
return instance
def _replace_items(self, session, items_data):
session.items.all().delete()
for item in items_data:
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
LeveeWorkSessionItem.objects.create(
session=session,
field_id=item['field'],
plan=plan,
crop_name_snapshot=plan.crop.name,
variety_name_snapshot=plan.variety.name if plan.variety else '',
)
def _resolve_plan(self, year, field_id, plan_id):
queryset = Plan.objects.select_related('crop', 'variety').filter(
year=year,
field_id=field_id,
crop__name='水稲',
)
if plan_id is not None:
try:
return queryset.get(id=plan_id)
except Plan.DoesNotExist as exc:
raise serializers.ValidationError(
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
) from exc
plan = queryset.first()
if plan is None:
raise serializers.ValidationError(
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
)
return plan

View File

@@ -0,0 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
router = DefaultRouter()
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
urlpatterns = [
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
path('', include(router.urls)),
]

View File

@@ -0,0 +1,70 @@
from rest_framework import status, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.plans.models import Plan
from .models import LeveeWorkSession
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = LeveeWorkSession.objects.prefetch_related(
'items',
'items__field',
'items__plan',
).select_related('work_record')
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return LeveeWorkSessionWriteSerializer
return LeveeWorkSessionSerializer
class LeveeWorkCandidatesView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
year = request.query_params.get('year')
if not year:
return Response(
{'detail': 'year が必要です。'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
year = int(year)
except (TypeError, ValueError):
return Response(
{'detail': 'year は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
plans = (
Plan.objects.select_related('field', 'crop', 'variety')
.filter(year=year, crop__name='水稲')
.order_by('field__display_order', 'field__id')
)
data = [
{
'field_id': plan.field_id,
'field_name': plan.field.name,
'field_area_tan': str(plan.field.area_tan),
'group_name': plan.field.group_name,
'plan_id': plan.id,
'crop_name': plan.crop.name,
'variety_name': plan.variety.name if plan.variety else '',
'selected': True,
}
for plan in plans
]
return Response(data)

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2 on 2026-04-04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('levee_work', '0001_initial'),
('workrecords', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='workrecord',
name='work_type',
field=models.CharField(
choices=[
('fertilizer_delivery', '肥料運搬'),
('fertilizer_spreading', '肥料散布'),
('levee_work', '畔塗'),
],
max_length=40,
verbose_name='作業種別',
),
),
migrations.AddField(
model_name='workrecord',
name='levee_work_session',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='work_record',
to='levee_work.leveeworksession',
verbose_name='畔塗記録',
),
),
]

View File

@@ -5,6 +5,7 @@ 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(
@@ -31,6 +32,14 @@ class WorkRecord(models.Model):
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)
@@ -41,4 +50,3 @@ class WorkRecord(models.Model):
def __str__(self):
return f'{self.work_date} {self.get_work_type_display()}'

View File

@@ -22,6 +22,7 @@ class WorkRecordSerializer(serializers.ModelSerializer):
'delivery_plan_id',
'delivery_plan_name',
'spreading_session',
'levee_work_session',
'created_at',
'updated_at',
]
@@ -35,4 +36,3 @@ class WorkRecordSerializer(serializers.ModelSerializer):
if obj.delivery_trip_id:
return obj.delivery_trip.delivery_plan.name
return None

View File

@@ -31,3 +31,18 @@ def sync_spreading_work_record(session):
'delivery_trip': None,
},
)
def sync_levee_work_record(session):
WorkRecord.objects.update_or_create(
levee_work_session=session,
defaults={
'work_date': session.date,
'work_type': WorkRecord.WorkType.LEVEE_WORK,
'title': session.title,
'year': session.year,
'auto_created': True,
'delivery_trip': None,
'spreading_session': None,
},
)

View File

@@ -14,9 +14,9 @@ class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
'delivery_trip',
'delivery_trip__delivery_plan',
'spreading_session',
'levee_work_session',
)
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset

View File

@@ -46,6 +46,7 @@ INSTALLED_APPS = [
'apps.fertilizer',
'apps.materials',
'apps.workrecords',
'apps.levee_work',
]
MIDDLEWARE = [

View File

@@ -60,4 +60,5 @@ urlpatterns = [
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')),
]

View File

@@ -0,0 +1,557 @@
# マスタードキュメント:畔塗作業機能
> **作成**: 2026-04-04
> **最終更新**: 2026-04-04
> **対象機能**: 畔塗作業記録(日付単位の圃場選択・作業記録索引連携)
> **実装状況**: 実装予定(仕様策定版)
---
## 概要
農業生産者が、水稲作付け圃場に対して実施した「畔塗」作業を日付単位で記録する機能。
対象圃場をまとめて選択し、保存時に作業記録一覧へ自動反映する。
本機能は、施肥計画の散布実績と同様に
「作業本体を専用テーブルで持ち、作業記録一覧には索引を自動生成する」
という設計方針を採用する。
### 機能スコープIN / OUT
| IN本機能で扱う | OUT本機能では扱わない |
|---|---|
| 畔塗日単位の記録作成 | 畔塗作業の工程管理 |
| 水稲作付け圃場の候補抽出 | 作業者別の工数集計 |
| 複数圃場の一括選択・保存 | 機械・資材の在庫管理 |
| 作業記録一覧WorkRecordへの自動反映 | 写真添付 |
| 畔塗記録の編集・削除 | GPS軌跡連携 |
| 対象圃場一覧の参照画面 | 汎用作業日誌への完全統合 |
---
## 背景と目的
現状システムには、運搬や肥料散布のような作業実績を日付順に参照する仕組みがあるが、
春作業の一つである畔塗については記録先が存在しない。
畔塗は次の特徴を持つ。
- 1日で複数圃場をまとめて実施することが多い
- 対象圃場は当年の作付け計画と密接に関係する
- 後から「いつ、どの圃場を畔塗したか」を一覧で見返したい
そのため、圃場ごとに単発レコードを大量に作るのではなく、
`1日 = 1件の畔塗記録` とし、対象圃場を明細としてぶら下げる構成とする。
---
## データモデル
### LeveeWorkSession畔塗記録本体
日付単位の畔塗作業記録。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| year | int | required | 年度フィルタ用。既存機能に合わせて暦年を保持し、原則 `date.year` と一致させる |
| date | DateField | required | 畔塗日 |
| title | varchar(100) | required, default=`水稲畔塗` | 一覧表示タイトル。未指定時はサーバー側で `水稲畔塗` を補完する |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
- `year + date` の一意制約は付けない
- 同日に午前・午後や地区別で複数記録を持てるようにする
### LeveeWorkSessionItem畔塗対象圃場明細
畔塗記録に紐づく対象圃場一覧。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| session | FK(LeveeWorkSession) | CASCADE | 親の畔塗記録 |
| field | FK(fields.Field) | PROTECT | 対象圃場 |
| plan | FK(plans.Plan) | SET_NULL, nullable | 保存時点の作付け計画参照 |
| crop_name_snapshot | varchar(100) | required | 保存時点の作物名 |
| variety_name_snapshot | varchar(100) | blank | 保存時点の品種名 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
- `unique_together = ['session', 'field']`
- 圃場名そのものは `Field` を参照して表示する
- 作物・品種は履歴保全のためスナップショット保持を推奨する
### WorkRecord作業記録索引
既存 `apps/workrecords``WorkRecord` に畔塗種別を追加して連携する。
追加内容:
- `work_type``levee_work` を追加
- `levee_work_session` への `OneToOne FK('levee_work.LeveeWorkSession')` を追加
想定制約:
- `on_delete=models.CASCADE`
- `null=True`
- `blank=True`
- `related_name='work_record'`
削除方針:
- 親である `LeveeWorkSession` 削除時に、関連する `WorkRecord` は DB 制約の `CASCADE` で自動削除する
- アプリケーション側での「紐づく WorkRecord を削除する」は、この DB 制約により満たされるものとして扱う
一覧表示時の想定値:
| 項目 | 値 |
|---|---|
| 作業日 | 畔塗記録の日付 |
| 種別 | 畔塗 |
| タイトル | 水稲畔塗 |
| 参照先 | 畔塗した圃場一覧画面 |
---
## 候補圃場抽出ルール
畔塗対象候補は、作付け計画 `Plan` から抽出する。
### 基本条件
- 指定年度の `Plan` であること
- `crop.name = "水稲"` の圃場であること
- 圃場が存在すること
### 補足
- 判定条件は「品種が水稲」ではなく、原則として「作物が水稲」とする
- `variety` は任意項目のため、品種未設定でも `crop=水稲` なら候補に含める
- 並び順は `field.display_order`, `field.id`
### 候補レスポンスで返したい情報
| 項目 | 説明 |
|---|---|
| field_id | 圃場ID |
| field_name | 圃場名 |
| field_area_tan | 面積(反) |
| group_name | グループ名 |
| plan_id | 対応する作付け計画ID |
| crop_name | 作物名 |
| variety_name | 品種名 |
| selected | 初期選択状態。候補圃場は原則 `true` を返し、全選択をデフォルトとする |
### 初期選択ルール
- 候補として返す水稲圃場は、原則すべて `selected=true` とする
- 品種未設定の水稲圃場も `selected=true` とする
- UI 上のチェック解除は、ユーザーが今回畔塗しない圃場を明示的に外すための操作と位置づける
- 先行イメージ図にあった `☐ 山の前` は例示上の表現であり、初期ルールそのものではない
---
## 画面仕様
### 画面の位置づけ
畔塗機能は、日付を先に決めて対象圃場を選ぶ「日報型UI」とする。
圃場ごとの個別登録画面ではなく、1回の保存で複数圃場をまとめて記録する。
### 主要画面
#### 1. 畔塗記録一覧画面
目的:
- 年度内の畔塗記録を一覧する
- 新規作成画面へ遷移する
- 既存記録の編集・削除を行う
表示項目:
- 畔塗日
- タイトル
- 対象圃場数
- 対象圃場名の要約
- 備考
#### 2. 畔塗記録作成・編集画面
入力項目:
- 日付
- タイトル
- 備考
- 対象圃場一覧
対象圃場一覧の表示項目:
- 選択チェック
- 圃場名
- 面積
- グループ
- 作物
- 品種
操作:
- 全選択
- 全解除
- 個別選択
- 保存
初期表示ルール:
- 初回表示時は候補圃場を全選択状態で表示する
- 編集時は保存済み明細に含まれる圃場を選択状態で復元する
### 推奨UIイメージ
```text
畔塗記録作成
[日付 2026-04-20]
[タイトル 水稲畔塗]
[備考 __________________ ]
対象圃場一覧
[全選択] [全解除]
☑ 田中上 1.2反 上エリア 水稲 コシヒカリ
☑ 田中下 0.8反 上エリア 水稲 あきたこまち
☐ 山の前 1.5反 南エリア 水稲 (未設定)
[保存]
```
### 作業記録一覧への見え方
既存の作業記録一覧には次の形式で表示する。
| 列 | 表示内容 |
|---|---|
| 作業日 | 指定した日付 |
| 種別 | 畔塗 |
| タイトル | 水稲畔塗 |
| 参照先 | 畔塗記録 #ID または対象圃場要約 |
| 開く | 畔塗記録詳細画面へ遷移 |
---
## API エンドポイント
すべて JWT 認証必須。
### 畔塗記録
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/levee-work/sessions/?year={year}` | 年度別一覧 |
| POST | `/api/levee-work/sessions/` | 新規作成 |
| GET | `/api/levee-work/sessions/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/levee-work/sessions/{id}/` | 更新 |
| DELETE | `/api/levee-work/sessions/{id}/` | 削除 |
### 候補圃場取得
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/levee-work/candidates/?year={year}` | 水稲作付け圃場候補を返す |
### レスポンス例(候補圃場)
```json
[
{
"field_id": 5,
"field_name": "田中上",
"field_area_tan": "1.2000",
"group_name": "上エリア",
"plan_id": 12,
"crop_name": "水稲",
"variety_name": "コシヒカリ",
"selected": true
}
]
```
### リクエスト例(新規作成)
```json
{
"year": 2026,
"date": "2026-04-20",
"title": "水稲畔塗",
"notes": "",
"items": [
{
"field": 5,
"plan": 12
},
{
"field": 6,
"plan": 13
}
]
}
```
備考:
- `crop_name_snapshot` / `variety_name_snapshot` はクライアント送信項目ではない
- サーバーが `plan``field` の整合を検証したうえで、保存時に `Plan` から自動設定する
- `plan``null` の場合は、保存時点で参照できる `field` に対応する当年 `Plan` から補完を試みる
### レスポンス例(詳細)
```json
{
"id": 3,
"year": 2026,
"date": "2026-04-20",
"title": "水稲畔塗",
"notes": "",
"work_record_id": 15,
"item_count": 2,
"items": [
{
"id": 11,
"field": 5,
"field_name": "田中上",
"plan": 12,
"crop_name_snapshot": "水稲",
"variety_name_snapshot": "コシヒカリ"
},
{
"id": 12,
"field": 6,
"field_name": "田中下",
"plan": 13,
"crop_name_snapshot": "水稲",
"variety_name_snapshot": "あきたこまち"
}
],
"created_at": "2026-04-20T08:00:00Z",
"updated_at": "2026-04-20T08:00:00Z"
}
```
---
## 業務フロー
### 1. 新規作成
1. ユーザーが年度と日付を選ぶ
2. システムが当年の水稲作付け圃場を候補表示する
3. ユーザーが対象圃場を選択する
4. 保存時に `LeveeWorkSession` を作成する
5. 明細として `LeveeWorkSessionItem` を一括作成する
6. 各明細の `crop_name_snapshot` / `variety_name_snapshot` をサーバー側で自動設定する
7. `WorkRecord` を自動生成または更新する
### 2. 編集
1. ユーザーが既存の畔塗記録を開く
2. 日付・タイトル・備考・対象圃場を変更する
3. 保存時に明細を再構成する
4. `WorkRecord` 側の作業日・タイトルも同期更新する
5. 明細のスナップショットも保存時点情報で再構成する
### 3. 削除
1. ユーザーが畔塗記録を削除する
2. 紐づく `LeveeWorkSessionItem``CASCADE` で削除される
3. 紐づく `WorkRecord``levee_work_session``on_delete=CASCADE` により削除される
---
## 作業記録連携仕様
畔塗記録保存時に `apps/workrecords` 側へ自動反映する。
### 追加する種別
| enum値 | 表示名 |
|---|---|
| `levee_work` | 畔塗 |
### 自動生成ルール
- `work_date` = `session.date`
- `work_type` = `levee_work`
- `title` = `session.title`
- `year` = `session.year`
- `auto_created` = `True`
- `levee_work_session` = 対応する畔塗記録
- `delivery_trip` = `None`
- `spreading_session` = `None`
実装メモ:
- 既存の `sync_spreading_work_record()` と同様に、`update_or_create()``defaults` 内で他系統 FK を明示的に `None` へそろえる
- `title` の未入力は `LeveeWorkSession` 保存時にサーバー側で `水稲畔塗` を補完するため、同期処理では補完済みの `session.title` をそのまま使う
### 同期タイミング
- 畔塗記録作成時: `update_or_create`
- 畔塗記録更新時: `update_or_create`
- 畔塗記録削除時: `levee_work_session``on_delete=CASCADE` により `WorkRecord` も自動削除される
---
## バリデーションルール
### 必須
- `year`
- `date`
- `items`1件以上
### 保存時チェック
- 選択圃場が0件の保存を禁止する
- 同一セッション内で同じ圃場を重複登録しない
- 候補外圃場の保存を原則禁止する
- `year` は原則 `date.year` と一致しなければならない
- `plan` が指定されている場合、その `plan.field``field` は一致しなければならない
- `plan` が指定されている場合、その `plan.year``session.year` と一致しなければならない
### 業務上の許容
- 品種未設定の水稲圃場は保存可
- 同日に別記録を複数作ることは可
- 一度畔塗した圃場を別日に再度記録することは可
---
## 実装方針
### バックエンド
- 新規アプリ `apps/levee_work` を追加する案を第一候補とする
- `Session` / `SessionItem` 構成でモデル化する
- Serializer は `read``write` を分離する
- 候補取得 API は `Plan` を起点に組み立てる
- `sync_levee_work_record(session)` を作成して `WorkRecord` と同期する
- `WorkRecord` から `LeveeWorkSession` への参照は、アプリ間循環参照を避けるため文字列参照 `OneToOneField('levee_work.LeveeWorkSession', ...)` を使う
### フロントエンド
- 画面候補: `frontend/src/app/levee-work/page.tsx`
- 1画面完結の一覧 + 作成/編集パネル、または一覧画面 + 詳細画面のどちらでも可
- 既存の `fertilizer/spreading` の「一覧 + 編集」導線を参考にする
- `workrecords/page.tsx` に遷移先判定を追加する
### 命名方針
- ユーザー向け表示は「畔塗」で統一
- コード上の英語名は `levee_work` または `levee_coating` が候補
- 既存の `WorkRecord.WorkType` に追加する値は、短く意味がぶれない `levee_work` を推奨する
---
## 画面遷移案
```text
作業記録一覧
└─ 畔塗レコードの「開く」
└─ 畔塗記録画面(該当セッションを編集状態で開く)
畔塗記録画面
├─ 新規作成
├─ 既存記録の編集
└─ 保存後、作業記録一覧に反映
```
---
## 将来拡張
- 作業者名の保持
- 使用機械の記録
- 実施済み圃場を地図で確認
- 写真添付
- 代かき、耕起、播種など他作業への横展開
- 汎用作業日誌基盤への統合
---
## 実装タスク案
1. `apps/levee_work` アプリ新設
2. `LeveeWorkSession` / `LeveeWorkSessionItem` モデル追加
3. migration 作成
4. serializer / view / url 実装
5. 候補圃場 API 実装
6. `WorkRecord` に畔塗種別と参照FK追加
7. `sync_levee_work_record` サービス実装
8. フロントエンド一覧・作成画面実装
9. 作業記録一覧の遷移先対応
10. テスト追加
---
## 注意点と設計判断
### なぜ「圃場ごと1件」ではなく「日付ごと1件」か
- 実際の作業単位が日付ベースである
- 一覧が見やすい
- 既存の散布実績機能と整合する
- 作業記録索引との親和性が高い
### なぜ作付け計画を参照するか
- 水稲圃場だけを自然に抽出できる
- 年度との整合が取りやすい
- 将来「未畔塗候補」や「前年比較」に発展させやすい
### スナップショットを持つ理由
- 後から作付け計画が変更されても、記録時点の情報を追える
- 作業記録としての監査性を保ちやすい
### なぜ snapshot をクライアント入力にしないか
- `plan``field` からサーバーが一意に導出できる情報だから
- クライアント送信にすると改ざんや不整合の余地が増えるから
- API 入力を最小限に保った方が UI 実装が単純になるから
---
## ソースファイル追加想定
### バックエンド
- `backend/apps/levee_work/models.py`
- `backend/apps/levee_work/serializers.py`
- `backend/apps/levee_work/views.py`
- `backend/apps/levee_work/urls.py`
- `backend/apps/levee_work/admin.py`
- `backend/apps/levee_work/migrations/0001_initial.py`
- `backend/apps/workrecords/models.py`
- `backend/apps/workrecords/services.py`
- `backend/apps/workrecords/serializers.py`
- `backend/apps/workrecords/views.py`
- `backend/keinasystem/urls.py`
### フロントエンド
- `frontend/src/app/levee-work/page.tsx`
- `frontend/src/types/index.ts`
- `frontend/src/app/workrecords/page.tsx`
---
## まとめ
畔塗作業機能は、
「当年の水稲作付け圃場を候補として出し、日付単位で複数圃場をまとめて記録し、作業記録一覧へ自動反映する」
というシンプルな構成を基本とする。
この構成により、既存の作付け計画・作業記録の設計を壊さずに、
春作業の記録を自然に追加できる。

View File

@@ -0,0 +1,439 @@
'use client';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ChevronLeft, PencilLine, Plus, Save, Trash2 } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { LeveeWorkCandidate, LeveeWorkSession } from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'leveeWorkYear';
type FormState = {
date: string;
title: string;
notes: string;
selectedFieldIds: Set<number>;
};
const extractErrorMessage = (error: any) => {
const data = error?.response?.data;
if (!data) return '保存に失敗しました。';
if (typeof data.detail === 'string') return data.detail;
if (Array.isArray(data.year) && data.year[0]) return data.year[0];
if (Array.isArray(data.items) && data.items[0]) return data.items[0];
if (typeof data.items === 'string') return data.items;
return '保存に失敗しました。';
};
const getDefaultDate = (year: number) => {
const today = new Date();
if (today.getFullYear() !== year) {
return `${year}-01-01`;
}
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export default function LeveeWorkPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-gray-50"><Navbar /><div className="mx-auto max-w-7xl px-4 py-8 text-gray-500">...</div></div>}>
<LeveeWorkPageContent />
</Suspense>
);
}
function LeveeWorkPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
const [sessions, setSessions] = useState<LeveeWorkSession[]>([]);
const [candidates, setCandidates] = useState<LeveeWorkCandidate[]>([]);
const [form, setForm] = useState<FormState | null>(null);
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [formLoading, setFormLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openedFromQuery, setOpenedFromQuery] = useState(false);
useEffect(() => {
localStorage.setItem(YEAR_KEY, String(year));
void fetchSessions();
setForm(null);
setEditingSessionId(null);
setOpenedFromQuery(false);
}, [year]);
useEffect(() => {
const sessionParam = Number(searchParams.get('session') || '0') || null;
if (!sessionParam || openedFromQuery || sessions.length === 0) {
return;
}
const target = sessions.find((session) => session.id === sessionParam);
if (target) {
void openEditor(target);
setOpenedFromQuery(true);
}
}, [openedFromQuery, searchParams, sessions]);
const fetchSessions = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/levee-work/sessions/?year=${year}`);
setSessions(res.data);
} catch (e) {
console.error(e);
setError('畔塗記録の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const loadCandidates = async () => {
const res = await api.get(`/levee-work/candidates/?year=${year}`);
setCandidates(res.data);
return res.data as LeveeWorkCandidate[];
};
const startCreate = async () => {
setFormLoading(true);
setError(null);
try {
const loaded = await loadCandidates();
setEditingSessionId(null);
setForm({
date: getDefaultDate(year),
title: '水稲畔塗',
notes: '',
selectedFieldIds: new Set(loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id)),
});
} catch (e) {
console.error(e);
setError('候補圃場の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const openEditor = async (session: LeveeWorkSession) => {
setFormLoading(true);
setError(null);
try {
const loaded = await loadCandidates();
const selectedIds = new Set(session.items.map((item) => item.field));
const fallbackSelected = loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id);
setEditingSessionId(session.id);
setForm({
date: session.date,
title: session.title,
notes: session.notes,
selectedFieldIds: selectedIds.size > 0 ? selectedIds : new Set(fallbackSelected),
});
} catch (e) {
console.error(e);
setError('編集用データの読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const handleToggleField = (fieldId: number) => {
if (!form) return;
const next = new Set(form.selectedFieldIds);
if (next.has(fieldId)) {
next.delete(fieldId);
} else {
next.add(fieldId);
}
setForm({ ...form, selectedFieldIds: next });
};
const handleSelectAll = () => {
if (!form) return;
setForm({
...form,
selectedFieldIds: new Set(candidates.map((candidate) => candidate.field_id)),
});
};
const handleClearAll = () => {
if (!form) return;
setForm({ ...form, selectedFieldIds: new Set() });
};
const selectedCount = form?.selectedFieldIds.size ?? 0;
const selectedCandidates = useMemo(() => {
if (!form) return [];
return candidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
}, [candidates, form]);
const handleSave = async () => {
if (!form) return;
if (selectedCount === 0) {
setError('対象圃場を1件以上選択してください。');
return;
}
setSaving(true);
setError(null);
try {
const payload = {
year,
date: form.date,
title: form.title,
notes: form.notes,
items: selectedCandidates.map((candidate) => ({
field: candidate.field_id,
plan: candidate.plan_id,
})),
};
if (editingSessionId) {
await api.put(`/levee-work/sessions/${editingSessionId}/`, payload);
} else {
await api.post('/levee-work/sessions/', payload);
}
await fetchSessions();
await startCreate();
} catch (e: any) {
console.error(e);
setError(extractErrorMessage(e));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!editingSessionId) return;
if (!window.confirm('この畔塗記録を削除しますか?')) return;
setSaving(true);
setError(null);
try {
await api.delete(`/levee-work/sessions/${editingSessionId}/`);
await fetchSessions();
setEditingSessionId(null);
setForm(null);
} catch (e) {
console.error(e);
setError('削除に失敗しました。');
} finally {
setSaving(false);
}
};
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/workrecords')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<PencilLine className="h-6 w-6 text-amber-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<button
onClick={() => void startCreate()}
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<section className="overflow-hidden rounded-lg bg-white shadow-sm">
<div className="border-b bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700"></div>
{loading ? (
<div className="px-4 py-8 text-sm text-gray-500">...</div>
) : sessions.length === 0 ? (
<div className="px-4 py-8 text-sm text-gray-400"></div>
) : (
<div className="divide-y divide-gray-100">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => void openEditor(session)}
className={`block w-full px-4 py-4 text-left hover:bg-amber-50 ${
editingSessionId === session.id ? 'bg-amber-50' : ''
}`}
>
<div className="text-sm font-medium text-gray-900">{session.title}</div>
<div className="mt-1 text-sm text-gray-600">{session.date}</div>
<div className="mt-1 text-xs text-gray-500">{session.item_count}</div>
</button>
))}
</div>
)}
</section>
<section className="rounded-lg bg-white shadow-sm">
<div className="border-b bg-gray-50 px-5 py-3 text-sm font-medium text-gray-700">
{editingSessionId ? '畔塗記録を編集' : '畔塗記録を作成'}
</div>
{!form ? (
<div className="px-5 py-10 text-sm text-gray-500">
{formLoading ? 'フォームを準備中...' : '「新規作成」または既存記録の選択で編集を始められます。'}
</div>
) : (
<div className="space-y-6 px-5 py-5">
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<div className="mb-1 text-sm font-medium text-gray-700"></div>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
</label>
<label className="block">
<div className="mb-1 text-sm font-medium text-gray-700"></div>
<input
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
</label>
</div>
<label className="block">
<div className="mb-1 text-sm font-medium text-gray-700"></div>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
</label>
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium text-gray-900"></h2>
<p className="text-xs text-gray-500">{selectedCount} / {candidates.length} </p>
</div>
<div className="flex gap-2">
<button
onClick={handleSelectAll}
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={handleClearAll}
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
</div>
</div>
{formLoading ? (
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
...
</div>
) : candidates.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-400">
</div>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{candidates.map((candidate) => {
const checked = form.selectedFieldIds.has(candidate.field_id);
return (
<tr key={candidate.field_id} className={checked ? 'bg-amber-50/40' : ''}>
<td className="px-4 py-3">
<input
type="checkbox"
checked={checked}
onChange={() => handleToggleField(candidate.field_id)}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
/>
</td>
<td className="px-4 py-3 font-medium text-gray-900">{candidate.field_name}</td>
<td className="px-4 py-3 text-gray-700">{candidate.field_area_tan}</td>
<td className="px-4 py-3 text-gray-700">{candidate.group_name || '-'}</td>
<td className="px-4 py-3 text-gray-700">{candidate.variety_name || '(未設定)'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => void handleSave()}
disabled={saving || formLoading}
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
</button>
{editingSessionId && (
<button
onClick={() => void handleDelete()}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
)}
</section>
</div>
</main>
</div>
);
}

View File

@@ -47,6 +47,10 @@ export default function WorkRecordsPage() {
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
return;
}
if (record.levee_work_session) {
router.push(`/levee-work?session=${record.levee_work_session}`);
return;
}
if (record.delivery_plan_id) {
router.push(`/distribution/${record.delivery_plan_id}/edit`);
}
@@ -112,12 +116,14 @@ export default function WorkRecordsPage() {
<td className="px-4 py-3 text-gray-600">
{record.spreading_session
? `散布実績 #${record.spreading_session}`
: record.levee_work_session
? `畔塗記録 #${record.levee_work_session}`
: record.delivery_plan_name
? `${record.delivery_plan_name}`
: '-'}
</td>
<td className="px-4 py-3 text-right">
{(record.spreading_session || record.delivery_plan_id) && (
{(record.spreading_session || record.levee_work_session || record.delivery_plan_id) && (
<button
onClick={() => moveToSource(record)}
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"

View File

@@ -1,7 +1,7 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine } from 'lucide-react';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction } from 'lucide-react';
import { logout } from '@/lib/api';
export default function Navbar() {
@@ -144,6 +144,17 @@ export default function Navbar() {
<FlaskConical className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/levee-work')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/levee-work')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Construction className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/materials')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${

View File

@@ -268,10 +268,45 @@ export interface SpreadingSession {
updated_at: string;
}
export interface LeveeWorkCandidate {
field_id: number;
field_name: string;
field_area_tan: string;
group_name: string | null;
plan_id: number;
crop_name: string;
variety_name: string;
selected: boolean;
}
export interface LeveeWorkSessionItem {
id: number;
field: number;
field_name: string;
field_area_tan: string;
group_name: string | null;
plan: number | null;
crop_name_snapshot: string;
variety_name_snapshot: string;
}
export interface LeveeWorkSession {
id: number;
year: number;
date: string;
title: string;
notes: string;
work_record_id: number | null;
item_count: number;
items: LeveeWorkSessionItem[];
created_at: string;
updated_at: string;
}
export interface WorkRecord {
id: number;
work_date: string;
work_type: 'fertilizer_delivery' | 'fertilizer_spreading';
work_type: 'fertilizer_delivery' | 'fertilizer_spreading' | 'levee_work';
work_type_display: string;
title: string;
year: number;
@@ -280,6 +315,7 @@ export interface WorkRecord {
delivery_plan_id: number | null;
delivery_plan_name: string | null;
spreading_session: number | null;
levee_work_session: number | null;
created_at: string;
updated_at: string;
}