月次レポート機能を実装し、経費の取り消し・復帰機能を追加。CSV取込画面にドラッグ&ドロップエリアを実装し、エラーメッセージ表示を追加。金額のカンマ区切り表示を全体に適用。faviconとapple-touch-iconを追加し、404エラーを回避。作業ログをdiary.mdに追記。

This commit is contained in:
president
2025-12-21 16:36:39 +09:00
parent d301ddcbfb
commit 7ae367cd66
17 changed files with 682 additions and 21 deletions

View File

@@ -5,7 +5,7 @@ from datetime import date
from typing import Iterable
@dataclass(frozen=True)
@dataclass
class ExpenseRow:
use_date: date
description: str

View File

@@ -1,7 +1,12 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from typing import Iterable
from django.db.models import Count, Sum
from .csv_parsers import get_parser
from .models import Expense
@@ -34,3 +39,74 @@ def import_csv_lines(lines: Iterable[str]) -> CSVImportResult:
else:
duplicated += 1
return CSVImportResult(imported=imported, duplicated=duplicated)
@dataclass
class MonthlyReportResult:
target_month: date
total_amount: int
store_totals: list[dict]
category_totals: list[dict]
owner_totals: list[dict]
unclassified_counts: dict
def build_monthly_report(start_date: date, end_date: date) -> MonthlyReportResult:
queryset = Expense.objects.filter(
is_canceled=False,
use_date__gte=start_date,
use_date__lt=end_date,
)
detail_queryset = (
queryset.select_related('store', 'expense_category')
.order_by('use_date', 'id')
)
store_details = defaultdict(list)
category_details = defaultdict(list)
owner_details = defaultdict(list)
for expense in detail_queryset:
detail = {
'use_date': expense.use_date,
'description': expense.description,
'amount': expense.amount,
'note': expense.note,
}
store_details[expense.store_id].append(detail)
category_details[expense.expense_category_id].append(detail)
owner_details[expense.owner_type].append(detail)
total_amount = queryset.aggregate(total=Sum('amount'))['total'] or 0
store_totals = list(
queryset.values('store_id', 'store__name')
.annotate(total=Sum('amount'), count=Count('id'))
.order_by('store__name', 'store_id')
)
for row in store_totals:
row['details'] = store_details.get(row['store_id'], [])
category_totals = list(
queryset.values('expense_category_id', 'expense_category__name')
.annotate(total=Sum('amount'), count=Count('id'))
.order_by('expense_category__name', 'expense_category_id')
)
for row in category_totals:
row['details'] = category_details.get(row['expense_category_id'], [])
owner_totals = list(
queryset.values('owner_type')
.annotate(total=Sum('amount'), count=Count('id'))
.order_by('owner_type')
)
for row in owner_totals:
row['details'] = owner_details.get(row['owner_type'], [])
unclassified_counts = {
'store_missing': queryset.filter(store__isnull=True).count(),
'category_missing': queryset.filter(expense_category__isnull=True).count(),
'owner_pending': queryset.filter(owner_type='pending').count(),
'total': queryset.count(),
}
return MonthlyReportResult(
target_month=start_date,
total_amount=total_amount,
store_totals=store_totals,
category_totals=category_totals,
owner_totals=owner_totals,
unclassified_counts=unclassified_counts,
)

View File

@@ -7,6 +7,7 @@ urlpatterns = [
path('', views.csv_upload, name='csv_upload'),
path('expenses/', views.expense_list, name='expense_list'),
path('expenses/<int:expense_id>/update/', views.expense_update, name='expense_update'),
path('expenses/<int:expense_id>/cancel/', views.expense_cancel, name='expense_cancel'),
path('reports/monthly/', views.monthly_report, name='monthly_report'),
path('reports/monthly/pdf/', views.monthly_report_pdf, name='monthly_report_pdf'),
]

View File

@@ -1,5 +1,6 @@
import io
import json
from datetime import date, datetime
from urllib.parse import urlencode
import chardet
@@ -8,7 +9,7 @@ from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_POST
from .services import import_csv_lines
from .services import build_monthly_report, import_csv_lines
from .models import Expense, ExpenseCategory, Store
@@ -21,7 +22,11 @@ def csv_upload(request):
encoding = detected.get('encoding') or 'utf-8'
text = raw.decode(encoding, errors='ignore')
lines = io.StringIO(text)
result = import_csv_lines(lines)
try:
result = import_csv_lines(lines)
except ValueError as exc:
context['error_message'] = str(exc)
return render(request, 'expenses/csv_upload.html', context)
query = urlencode(
{'imported': result.imported, 'duplicated': result.duplicated, 'encoding': encoding}
)
@@ -30,11 +35,11 @@ def csv_upload(request):
def expense_list(request):
expenses = (
Expense.objects.filter(is_canceled=False)
.select_related('store', 'expense_category')
.order_by('-use_date', '-id')[:200]
)
include_canceled = request.GET.get('include_canceled') == '1'
queryset = Expense.objects.select_related('store', 'expense_category')
if not include_canceled:
queryset = queryset.filter(is_canceled=False)
expenses = queryset.order_by('-use_date', '-id')[:200]
stores = Store.objects.filter(is_active=True).order_by('name')
categories = ExpenseCategory.objects.filter(is_active=True).order_by('name')
return render(
@@ -47,6 +52,7 @@ def expense_list(request):
'imported': request.GET.get('imported'),
'duplicated': request.GET.get('duplicated'),
'encoding': request.GET.get('encoding'),
'include_canceled': include_canceled,
},
)
@@ -104,8 +110,70 @@ def expense_update(request, expense_id: int):
def monthly_report(request):
return render(request, 'expenses/monthly_report.html')
error_message = None
target_month = request.GET.get('target_month')
if target_month:
try:
start_date = datetime.strptime(target_month, '%Y-%m').date().replace(day=1)
except ValueError:
error_message = '年月の形式が不正です。'
start_date = date.today().replace(day=1)
else:
start_date = date.today().replace(day=1)
if start_date.month == 12:
end_date = start_date.replace(year=start_date.year + 1, month=1)
else:
end_date = start_date.replace(month=start_date.month + 1)
report = build_monthly_report(start_date, end_date)
return render(
request,
'expenses/monthly_report.html',
{
'report': report,
'target_month': start_date.strftime('%Y-%m'),
'error_message': error_message,
},
)
def monthly_report_pdf(request):
return render(request, 'expenses/monthly_report_pdf.html')
target_month = request.GET.get('target_month')
if target_month:
try:
start_date = datetime.strptime(target_month, '%Y-%m').date().replace(day=1)
except ValueError:
start_date = date.today().replace(day=1)
else:
start_date = date.today().replace(day=1)
if start_date.month == 12:
end_date = start_date.replace(year=start_date.year + 1, month=1)
else:
end_date = start_date.replace(month=start_date.month + 1)
report = build_monthly_report(start_date, end_date)
return render(
request,
'expenses/monthly_report_pdf.html',
{
'report': report,
'target_month': start_date.strftime('%Y-%m'),
},
)
@require_POST
def expense_cancel(request, expense_id: int):
try:
payload = json.loads(request.body.decode('utf-8'))
except json.JSONDecodeError:
return JsonResponse({'status': 'error', 'message': 'JSON形式が不正です。'}, status=400)
if not isinstance(payload, dict):
return JsonResponse({'status': 'error', 'message': 'JSONはオブジェクトで送信してください。'}, status=400)
if 'is_canceled' not in payload:
return JsonResponse({'status': 'error', 'message': 'is_canceledが必要です。'}, status=400)
if not isinstance(payload['is_canceled'], bool):
return JsonResponse({'status': 'error', 'message': 'is_canceledの型が不正です。'}, status=400)
expense = get_object_or_404(Expense, pk=expense_id)
expense.is_canceled = payload['is_canceled']
expense.human_confirmed = True
expense.save(update_fields=['is_canceled', 'human_confirmed', 'updated_at'])
return JsonResponse({'status': 'ok'})