CSV取込機能を実装し、Expenseモデルと関連するStore・ExpenseCategoryモデルを追加。CSVパーサーを作成し、出光CSVに対応。明細編集画面のAJAX保存機能を実装し、取込結果を表示する機能を追加。作業ログをdiary.mdに追記。

This commit is contained in:
president
2025-12-19 15:51:14 +09:00
parent 5fc2a31f50
commit 89caf3438a
11 changed files with 489 additions and 7 deletions

View File

@@ -3,3 +3,17 @@
- settings.pyに環境変数ベース設定・PostgreSQL設定・日本語/東京タイムゾーン追加 - settings.pyに環境変数ベース設定・PostgreSQL設定・日本語/東京タイムゾーン追加
- ルーティングとテンプレート骨子を追加CSV取込/明細編集/レポート/PDF - ルーティングとテンプレート骨子を追加CSV取込/明細編集/レポート/PDF
- .env.exampleをDB設定に合わせて更新 - .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データを取得してテンプレートに表示
- 明細編集画面の編集UIselect/ラジオ/テキストとAJAX保存のイベントを追加
- 明細保存APIにJSONバリデーションとID/型チェックを追加
- CSV取込後に明細編集画面へリダイレクトし、取込結果を一覧画面に表示
- 出光CSVパーサに備考/続行行の結合と外貨備考の取り込みを追加
- source_hashに備考を含め、noteをExpenseへ保存

View File

@@ -0,0 +1,7 @@
from .factory import get_parser
from .idemitsu import IdemitsuParser
__all__ = [
'get_parser',
'IdemitsuParser',
]

View File

@@ -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

View File

@@ -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

View File

@@ -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 '' 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

View File

@@ -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'),
),
]

View File

@@ -1,3 +1,47 @@
from django.db import models 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}'

36
expenses/services.py Normal file
View File

@@ -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)

View File

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

View File

@@ -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): 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): 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): def monthly_report(request):

View File

@@ -5,6 +5,12 @@
{% block content %} {% block content %}
<section> <section>
<h2>明細編集</h2> <h2>明細編集</h2>
{% if imported %}
<p>取込件数: {{ imported }} / 重複件数: {{ duplicated }} / 文字コード: {{ encoding }}</p>
{% endif %}
<form>
{% csrf_token %}
</form>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -18,10 +24,127 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> {% for expense in expenses %}
<td colspan="7">データはここに表示されます。</td> <tr data-expense-id="{{ expense.id }}">
<td>{{ expense.use_date }}</td>
<td>{{ expense.description }}</td>
<td>{{ expense.amount }}</td>
<td>
<select class="js-expense-field" data-field="store_id">
<option value="">未設定</option>
{% for store in stores %}
<option value="{{ store.id }}" {% if expense.store_id == store.id %}selected{% endif %}>
{{ store.name }}
</option>
{% endfor %}
</select>
</td>
<td>
<select class="js-expense-field" data-field="expense_category_id">
<option value="">未設定</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if expense.expense_category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</td>
<td>
<label>
<input
class="js-expense-field"
data-field="is_business"
type="radio"
name="is_business_{{ expense.id }}"
value="true"
{% if expense.is_business is True %}checked{% endif %}
>
会社
</label>
<label>
<input
class="js-expense-field"
data-field="is_business"
type="radio"
name="is_business_{{ expense.id }}"
value="false"
{% if expense.is_business is False %}checked{% endif %}
>
家計
</label>
<label>
<input
class="js-expense-field"
data-field="is_business"
type="radio"
name="is_business_{{ expense.id }}"
value=""
{% if expense.is_business is None %}checked{% endif %}
>
未設定
</label>
</td>
<td>
<input
class="js-expense-field"
data-field="note"
type="text"
value="{{ expense.note }}"
>
</td>
</tr> </tr>
{% empty %}
<tr>
<td colspan="7">データがありません。</td>
</tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
<script>
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
async function saveExpense(expenseId, payload) {
const response = await fetch(`/expenses/${expenseId}/update/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error('保存に失敗しました');
}
return response.json();
}
function coerceValue(field, value) {
if (field === 'is_business') {
if (value === '') {
return null;
}
return value === 'true';
}
return value;
}
document.querySelectorAll('.js-expense-field').forEach((element) => {
const eventName = element.tagName === 'INPUT' ? 'change' : 'change';
element.addEventListener(eventName, async (event) => {
const row = event.target.closest('tr');
if (!row) {
return;
}
const expenseId = row.dataset.expenseId;
const field = event.target.dataset.field;
const value = coerceValue(field, event.target.value);
try {
await saveExpense(expenseId, { [field]: value });
} catch (error) {
alert(error.message);
}
});
});
</script>
</section> </section>
{% endblock %} {% endblock %}