From 89caf3438a5157570ec3405e1e9f7c81f6539171 Mon Sep 17 00:00:00 2001 From: president Date: Fri, 19 Dec 2025 15:51:14 +0900 Subject: [PATCH] =?UTF-8?q?CSV=E5=8F=96=E8=BE=BC=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85=E3=81=97=E3=80=81Expense=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AB=E3=81=A8=E9=96=A2=E9=80=A3=E3=81=99=E3=82=8B?= =?UTF-8?q?Store=E3=83=BBExpenseCategory=E3=83=A2=E3=83=87=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=82CSV=E3=83=91=E3=83=BC?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=82=92=E4=BD=9C=E6=88=90=E3=81=97=E3=80=81?= =?UTF-8?q?=E5=87=BA=E5=85=89CSV=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=80=82?= =?UTF-8?q?=E6=98=8E=E7=B4=B0=E7=B7=A8=E9=9B=86=E7=94=BB=E9=9D=A2=E3=81=AE?= =?UTF-8?q?AJAX=E4=BF=9D=E5=AD=98=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=81=97=E3=80=81=E5=8F=96=E8=BE=BC=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=82=E4=BD=9C=E6=A5=AD=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=82=92diary.md=E3=81=AB=E8=BF=BD=E8=A8=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Doc/diary.md | 14 +++ expenses/csv_parsers/__init__.py | 7 ++ expenses/csv_parsers/base.py | 25 ++++++ expenses/csv_parsers/factory.py | 26 ++++++ expenses/csv_parsers/idemitsu.py | 67 ++++++++++++++ expenses/migrations/0001_initial.py | 54 +++++++++++ expenses/models.py | 46 +++++++++- expenses/services.py | 36 ++++++++ expenses/urls.py | 1 + expenses/views.py | 91 ++++++++++++++++++- templates/expenses/expense_list.html | 129 ++++++++++++++++++++++++++- 11 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 expenses/csv_parsers/__init__.py create mode 100644 expenses/csv_parsers/base.py create mode 100644 expenses/csv_parsers/factory.py create mode 100644 expenses/csv_parsers/idemitsu.py create mode 100644 expenses/migrations/0001_initial.py create mode 100644 expenses/services.py diff --git a/Doc/diary.md b/Doc/diary.md index 5363cb5..25eaac2 100644 --- a/Doc/diary.md +++ b/Doc/diary.md @@ -3,3 +3,17 @@ - settings.pyに環境変数ベース設定・PostgreSQL設定・日本語/東京タイムゾーン追加 - ルーティングとテンプレート骨子を追加(CSV取込/明細編集/レポート/PDF) - .env.exampleをDB設定に合わせて更新 +- expenses/models.py にStore・ExpenseCategory・Expenseモデルを実装 +- makemigrationsで初期マイグレーションを作成(DB接続警告ありだが生成自体は完了) +- CSVパーサ骨格(base/factory/idemitsu)とサービス層 import_csv_lines を追加 +- CSV取込画面からサービス層を呼び出すアップロード処理を追加 +- 文字コード自動判定(chardet)と取込/重複件数の表示を追加 +- 出光CSVのヘッダ/明細混在に対応するパーサへ更新 +- CSV取り込み時に行バッファリングしてブランド判定とパースを両立 +- 明細編集のAJAX保存用エンドポイントとフロント側fetchの下地を追加 +- 明細一覧画面でExpenseデータを取得してテンプレートに表示 +- 明細編集画面の編集UI(select/ラジオ/テキスト)とAJAX保存のイベントを追加 +- 明細保存APIにJSONバリデーションとID/型チェックを追加 +- CSV取込後に明細編集画面へリダイレクトし、取込結果を一覧画面に表示 +- 出光CSVパーサに備考/続行行の結合と外貨備考の取り込みを追加 +- source_hashに備考を含め、noteをExpenseへ保存 diff --git a/expenses/csv_parsers/__init__.py b/expenses/csv_parsers/__init__.py new file mode 100644 index 0000000..846c890 --- /dev/null +++ b/expenses/csv_parsers/__init__.py @@ -0,0 +1,7 @@ +from .factory import get_parser +from .idemitsu import IdemitsuParser + +__all__ = [ + 'get_parser', + 'IdemitsuParser', +] diff --git a/expenses/csv_parsers/base.py b/expenses/csv_parsers/base.py new file mode 100644 index 0000000..e9a83f7 --- /dev/null +++ b/expenses/csv_parsers/base.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Iterable + + +@dataclass(frozen=True) +class ExpenseRow: + use_date: date + description: str + amount: int + note: str + source: str + source_hash: str + + +class CSVParserBase: + source: str = '' + + def detect(self, header_line: str) -> bool: + raise NotImplementedError + + def parse(self, lines: Iterable[str]) -> list[ExpenseRow]: + raise NotImplementedError diff --git a/expenses/csv_parsers/factory.py b/expenses/csv_parsers/factory.py new file mode 100644 index 0000000..e78c2dd --- /dev/null +++ b/expenses/csv_parsers/factory.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Iterable + +from .base import CSVParserBase +from .idemitsu import IdemitsuParser + + +PARSERS: list[CSVParserBase] = [ + IdemitsuParser(), +] + + +def detect_parser(lines: Iterable[str]) -> CSVParserBase | None: + for line in lines: + for parser in PARSERS: + if parser.detect(line): + return parser + return None + + +def get_parser(lines: Iterable[str]) -> CSVParserBase: + parser = detect_parser(lines) + if parser is None: + raise ValueError('CSVブランドを判定できませんでした。') + return parser diff --git a/expenses/csv_parsers/idemitsu.py b/expenses/csv_parsers/idemitsu.py new file mode 100644 index 0000000..27e3cd2 --- /dev/null +++ b/expenses/csv_parsers/idemitsu.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import csv +import hashlib +from datetime import datetime +from typing import Iterable + +from .base import CSVParserBase, ExpenseRow + + +class IdemitsuParser(CSVParserBase): + source = 'idemitsu' + + def detect(self, header_line: str) -> bool: + return '出光' in header_line or 'apollostation' in header_line + + def parse(self, lines: Iterable[str]) -> list[ExpenseRow]: + rows: list[ExpenseRow] = [] + reader = csv.reader(lines) + last_row_index: int | None = None + for columns in reader: + if not columns: + continue + head = columns[0].strip() + if head in ('カード名称', 'お支払日', '今回ご請求額'): + continue + if head == '利用日': + continue + if not head: + if last_row_index is not None: + extra_desc = columns[1].strip() if len(columns) > 1 else '' + extra_note = columns[6].strip() if len(columns) > 6 else '' + if extra_desc: + rows[last_row_index].description += f' {extra_desc}' + if extra_note: + if rows[last_row_index].note: + rows[last_row_index].note += f' {extra_note}' + else: + rows[last_row_index].note = extra_note + continue + if len(columns) < 6: + continue + try: + use_date = datetime.strptime(head, '%Y/%m/%d').date() + except ValueError: + continue + description = columns[1].strip() + amount_text = columns[5].replace(',', '').strip() + note = columns[6].strip() if len(columns) > 6 else '' + try: + amount = int(amount_text) + except ValueError: + continue + raw = f"{use_date.isoformat()}|{description}|{amount}|{note}|{self.source}" + source_hash = hashlib.sha256(raw.encode('utf-8')).hexdigest() + rows.append( + ExpenseRow( + use_date=use_date, + description=description, + amount=amount, + note=note, + source=self.source, + source_hash=source_hash, + ) + ) + last_row_index = len(rows) - 1 + return rows diff --git a/expenses/migrations/0001_initial.py b/expenses/migrations/0001_initial.py new file mode 100644 index 0000000..df83b75 --- /dev/null +++ b/expenses/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.27 on 2025-12-19 06:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ExpenseCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='Store', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='Expense', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('use_date', models.DateField()), + ('description', models.CharField(max_length=255)), + ('amount', models.IntegerField()), + ('is_business', models.BooleanField(blank=True, null=True)), + ('note', models.TextField(blank=True)), + ('source', models.CharField(choices=[('idemitsu', 'Idemitsu')], max_length=50)), + ('source_hash', models.CharField(max_length=64)), + ('ai_score', models.FloatField(blank=True, null=True)), + ('human_confirmed', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('expense_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='expenses.expensecategory')), + ('store', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='expenses.store')), + ], + ), + migrations.AddConstraint( + model_name='expense', + constraint=models.UniqueConstraint(fields=('source', 'source_hash'), name='uniq_source_hash'), + ), + ] diff --git a/expenses/models.py b/expenses/models.py index 71a8362..d5d4aee 100644 --- a/expenses/models.py +++ b/expenses/models.py @@ -1,3 +1,47 @@ from django.db import models -# Create your models here. + +class Store(models.Model): + name = models.CharField(max_length=200, unique=True) + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class ExpenseCategory(models.Model): + name = models.CharField(max_length=200, unique=True) + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class Expense(models.Model): + SOURCE_CHOICES = [ + ('idemitsu', 'Idemitsu'), + ] + + use_date = models.DateField() + description = models.CharField(max_length=255) + amount = models.IntegerField() + store = models.ForeignKey(Store, null=True, blank=True, on_delete=models.SET_NULL) + expense_category = models.ForeignKey( + ExpenseCategory, null=True, blank=True, on_delete=models.SET_NULL + ) + is_business = models.BooleanField(null=True, blank=True) + note = models.TextField(blank=True) + source = models.CharField(max_length=50, choices=SOURCE_CHOICES) + source_hash = models.CharField(max_length=64) + ai_score = models.FloatField(null=True, blank=True) + human_confirmed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['source', 'source_hash'], name='uniq_source_hash'), + ] + + def __str__(self) -> str: + return f'{self.use_date} {self.description} {self.amount}' diff --git a/expenses/services.py b/expenses/services.py new file mode 100644 index 0000000..2724b28 --- /dev/null +++ b/expenses/services.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Iterable + +from .csv_parsers import get_parser +from .models import Expense + + +class CSVImportResult: + def __init__(self, imported: int, duplicated: int) -> None: + self.imported = imported + self.duplicated = duplicated + + +def import_csv_lines(lines: Iterable[str]) -> CSVImportResult: + buffered_lines = list(lines) + parser = get_parser(buffered_lines) + rows = parser.parse(buffered_lines) + imported = 0 + duplicated = 0 + for row in rows: + expense, created = Expense.objects.get_or_create( + source=row.source, + source_hash=row.source_hash, + defaults={ + 'use_date': row.use_date, + 'description': row.description, + 'amount': row.amount, + 'note': row.note, + }, + ) + if created: + imported += 1 + else: + duplicated += 1 + return CSVImportResult(imported=imported, duplicated=duplicated) diff --git a/expenses/urls.py b/expenses/urls.py index cca2f2d..bff496f 100644 --- a/expenses/urls.py +++ b/expenses/urls.py @@ -6,6 +6,7 @@ from . import views urlpatterns = [ path('', views.csv_upload, name='csv_upload'), path('expenses/', views.expense_list, name='expense_list'), + path('expenses//update/', views.expense_update, name='expense_update'), path('reports/monthly/', views.monthly_report, name='monthly_report'), path('reports/monthly/pdf/', views.monthly_report_pdf, name='monthly_report_pdf'), ] diff --git a/expenses/views.py b/expenses/views.py index fda43eb..28cfa6f 100644 --- a/expenses/views.py +++ b/expenses/views.py @@ -1,12 +1,97 @@ -from django.shortcuts import render +import io +import json +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 import_csv_lines +from .models import Expense, ExpenseCategory, Store def csv_upload(request): - return render(request, 'expenses/csv_upload.html') + 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) + result = import_csv_lines(lines) + 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): - return render(request, 'expenses/expense_list.html') + expenses = Expense.objects.select_related('store', 'expense_category').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'), + }, + ) + + +@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', 'is_business', 'note'): + if key in payload: + value = payload[key] + if value in ('', None): + 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 'is_business' in fields and fields['is_business'] is not None: + if not isinstance(fields['is_business'], bool): + return JsonResponse({'status': 'error', 'message': 'is_businessの型が不正です。'}, 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): diff --git a/templates/expenses/expense_list.html b/templates/expenses/expense_list.html index 7861343..c03e188 100644 --- a/templates/expenses/expense_list.html +++ b/templates/expenses/expense_list.html @@ -5,6 +5,12 @@ {% block content %}

明細編集

+ {% if imported %} +

取込件数: {{ imported }} / 重複件数: {{ duplicated }} / 文字コード: {{ encoding }}

+ {% endif %} +
+ {% csrf_token %} +
@@ -18,10 +24,127 @@ - - - + {% for expense in expenses %} + + + + + + + + + + {% empty %} + + + + {% endfor %}
データはここに表示されます。
{{ expense.use_date }}{{ expense.description }}{{ expense.amount }} + + + + + + + + + +
データがありません。
+
{% endblock %}