import io import json from datetime import date, datetime from urllib.parse import urlencode import chardet from django.http import JsonResponse 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 build_monthly_report, import_csv_lines from .models import Expense, ExpenseCategory, Store def csv_upload(request): context = {} if request.method == 'POST' and request.FILES.get('csv_file'): upload = request.FILES['csv_file'] raw = upload.read() detected = chardet.detect(raw) encoding = detected.get('encoding') or 'utf-8' text = raw.decode(encoding, errors='ignore') lines = io.StringIO(text) 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} ) return redirect(f"/expenses/?{query}") return render(request, 'expenses/csv_upload.html', context) def expense_list(request): 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( request, 'expenses/expense_list.html', { 'expenses': expenses, 'stores': stores, 'categories': categories, 'imported': request.GET.get('imported'), 'duplicated': request.GET.get('duplicated'), 'encoding': request.GET.get('encoding'), 'include_canceled': include_canceled, }, ) @require_POST def expense_update(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) expense = get_object_or_404(Expense, pk=expense_id) fields = {} for key in ('store_id', 'expense_category_id', 'owner_type', 'note'): if key in payload: value = payload[key] if value in ('', None): if key == 'owner_type': fields[key] = 'pending' else: fields[key] = None else: fields[key] = value if 'store_id' in fields and fields['store_id'] is not None: if not isinstance(fields['store_id'], int): return JsonResponse({'status': 'error', 'message': 'store_idの型が不正です。'}, status=400) if not Store.objects.filter(id=fields['store_id'], is_active=True).exists(): return JsonResponse({'status': 'error', 'message': 'store_idが不正です。'}, status=400) if 'expense_category_id' in fields and fields['expense_category_id'] is not None: if not isinstance(fields['expense_category_id'], int): return JsonResponse({'status': 'error', 'message': 'expense_category_idの型が不正です。'}, status=400) if not ExpenseCategory.objects.filter( id=fields['expense_category_id'], is_active=True ).exists(): return JsonResponse( {'status': 'error', 'message': 'expense_category_idが不正です。'}, status=400 ) if 'owner_type' in fields and fields['owner_type'] is not None: if not isinstance(fields['owner_type'], str): return JsonResponse({'status': 'error', 'message': 'owner_typeの型が不正です。'}, status=400) if fields['owner_type'] not in {'company', 'personal', 'pending'}: return JsonResponse({'status': 'error', 'message': 'owner_typeが不正です。'}, status=400) if 'note' in fields and fields['note'] is not None: if not isinstance(fields['note'], str): return JsonResponse({'status': 'error', 'message': 'noteの型が不正です。'}, status=400) if len(fields['note']) > 2000: return JsonResponse({'status': 'error', 'message': 'noteが長すぎます。'}, status=400) if fields: for key, value in fields.items(): setattr(expense, key, value) expense.human_confirmed = True expense.save() return JsonResponse({'status': 'ok'}) def monthly_report(request): 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): 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'})