月次レポート機能を実装し、経費の取り消し・復帰機能を追加。CSV取込画面にドラッグ&ドロップエリアを実装し、エラーメッセージ表示を追加。金額のカンマ区切り表示を全体に適用。faviconとapple-touch-iconを追加し、404エラーを回避。作業ログをdiary.mdに追記。
This commit is contained in:
@@ -5,7 +5,7 @@ from datetime import date
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass
|
||||
class ExpenseRow:
|
||||
use_date: date
|
||||
description: str
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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'})
|
||||
|
||||
Reference in New Issue
Block a user