CSV取込機能を実装し、Expenseモデルと関連するStore・ExpenseCategoryモデルを追加。CSVパーサーを作成し、出光CSVに対応。明細編集画面のAJAX保存機能を実装し、取込結果を表示する機能を追加。作業ログをdiary.mdに追記。
This commit is contained in:
14
Doc/diary.md
14
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へ保存
|
||||
|
||||
7
expenses/csv_parsers/__init__.py
Normal file
7
expenses/csv_parsers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .factory import get_parser
|
||||
from .idemitsu import IdemitsuParser
|
||||
|
||||
__all__ = [
|
||||
'get_parser',
|
||||
'IdemitsuParser',
|
||||
]
|
||||
25
expenses/csv_parsers/base.py
Normal file
25
expenses/csv_parsers/base.py
Normal 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
|
||||
26
expenses/csv_parsers/factory.py
Normal file
26
expenses/csv_parsers/factory.py
Normal 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
|
||||
67
expenses/csv_parsers/idemitsu.py
Normal file
67
expenses/csv_parsers/idemitsu.py
Normal 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 '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
|
||||
54
expenses/migrations/0001_initial.py
Normal file
54
expenses/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
36
expenses/services.py
Normal file
36
expenses/services.py
Normal 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)
|
||||
@@ -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/<int:expense_id>/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'),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>明細編集</h2>
|
||||
{% if imported %}
|
||||
<p>取込件数: {{ imported }} / 重複件数: {{ duplicated }} / 文字コード: {{ encoding }}</p>
|
||||
{% endif %}
|
||||
<form>
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -18,10 +24,127 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="7">データはここに表示されます。</td>
|
||||
</tr>
|
||||
{% for expense in expenses %}
|
||||
<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>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7">データがありません。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user