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

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