diff --git a/app_flutter_style_dashboard.py b/app_flutter_style_dashboard.py index 21bf72e..74eb2a4 100644 --- a/app_flutter_style_dashboard.py +++ b/app_flutter_style_dashboard.py @@ -9,7 +9,7 @@ import sys import logging from datetime import datetime from typing import List, Dict, Optional -from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer +from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem from components.customer_picker import CustomerPickerModal from services.app_service import AppService @@ -24,11 +24,13 @@ class FlutterStyleDashboard: def __init__(self, page: ft.Page): self.page = page - self.current_tab = 0 # 0: 新規作成, 1: 発行履歴 + self.current_tab = 0 # 0: 伝票一覧, 1: 詳細編集 self.selected_customer = None self.selected_document_type = DocumentType.INVOICE self.amount_value = "250000" self.customer_picker = None + self.editing_invoice = None # 編集中の伝票 + self.is_edit_mode = False # 編集モードフラグ self.is_customer_picker_open = False self.customer_search_query = "" self.show_offsets = False @@ -147,14 +149,103 @@ class FlutterStyleDashboard: elif self.is_customer_picker_open: self.main_content.controls.append(self.create_customer_picker_screen()) else: - # 新規作成画面 - self.main_content.controls.append(self.create_slip_input_screen()) + # 伝票一覧画面 + self.main_content.controls.append(self.create_slip_list_screen()) else: - # 発行履歴画面 - self.main_content.controls.append(self.create_slip_history_screen()) + # 詳細編集画面 + self.main_content.controls.append(self.create_invoice_edit_screen()) self.page.update() + def create_slip_list_screen(self) -> ft.Container: + """伝票一覧画面""" + # 履歴データ読み込み + slips = self.load_slips() + if not self.show_offsets: + slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))] + + def on_toggle_offsets(e): + self.show_offsets = bool(e.control.value) + self.update_main_content() + + def on_verify_chain(e=None): + res = self.app_service.invoice.invoice_repo.verify_chain() + self.chain_verify_result = res + self.update_main_content() + + def create_new_slip(_): + """新規伝票作成""" + self.editing_invoice = None + self.current_tab = 1 + self.update_main_content() + + # 履歴カードリスト + slip_cards = [] + for slip in slips: + card = self.create_slip_card(slip) + slip_cards.append(card) + + return ft.Container( + content=ft.Column([ + # ヘッダー + ft.Container( + content=ft.Row([ + ft.Text("📄 伝票一覧", size=20, weight=ft.FontWeight.BOLD), + ft.Container(expand=True), + ft.IconButton( + ft.Icons.VERIFIED, + tooltip="チェーン検証", + icon_color=ft.Colors.BLUE_300, + on_click=on_verify_chain, + ), + ft.Row( + [ + ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE), + ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets), + ], + spacing=5, + ), + ft.IconButton(ft.Icons.CLEAR_ALL, icon_size=20), + ]), + padding=ft.padding.all(15), + bgcolor=ft.Colors.BLUE_GREY, + ), + + # 検証結果表示(あれば) + ft.Container( + content=self._build_chain_verify_result(), + margin=ft.Margin.only(bottom=10), + ) if self.chain_verify_result else ft.Container(height=0), + + # 伝票リスト + ft.Column( + controls=slip_cards, + spacing=10, + scroll=ft.ScrollMode.AUTO, + expand=True, + ), + + # 新規作成ボタン + ft.Container( + content=ft.Button( + content=ft.Row([ + ft.Icon(ft.Icons.ADD, color=ft.Colors.WHITE), + ft.Text("新規伝票を作成", color=ft.Colors.WHITE), + ], alignment=ft.MainAxisAlignment.CENTER), + style=ft.ButtonStyle( + bgcolor=ft.Colors.BLUE_GREY_800, + padding=ft.padding.all(20), + shape=ft.RoundedRectangleBorder(radius=15), + ), + width=400, + height=60, + on_click=create_new_slip, + ), + padding=ft.padding.all(20), + ), + ]), + expand=True, + ) def create_customer_picker_screen(self) -> ft.Container: """顧客選択画面(画面内遷移・ダイアログ不使用)""" self.customers = self.app_service.customer.get_all_customers() @@ -450,28 +541,52 @@ class FlutterStyleDashboard: logging.info(f"PDF削除完了: {pdf_path}") else: logging.error("PDF再生成失敗") + + def edit_invoice(_=None): + if not isinstance(slip, Invoice): + return + self.open_invoice_edit(slip) actions_row = None if isinstance(slip, Invoice): buttons = [] if not getattr(slip, "submitted_to_tax_authority", False): buttons.append( - ft.IconButton( - ft.Icons.REPLAY_CIRCLE_FILLED, - tooltip="赤伝(相殺)を発行", - icon_color=ft.Colors.RED_400, - on_click=issue_offset, + ft.ElevatedButton( + content=ft.Text("編集", color=ft.Colors.WHITE), + style=ft.ButtonStyle( + bgcolor=ft.Colors.GREEN_600, + padding=ft.padding.all(10), + ), + on_click=edit_invoice, + width=80, + height=40, ) ) - if not getattr(slip, "submitted_to_tax_authority", False): buttons.append( - ft.IconButton( - ft.Icons.CHECK_CIRCLE, - tooltip="税務署提出済みに設定", - icon_color=ft.Colors.ORANGE_400, - on_click=lambda _: self.submit_invoice_for_tax(slip.uuid), + ft.ElevatedButton( + content=ft.Text("赤伝", color=ft.Colors.WHITE), + style=ft.ButtonStyle( + bgcolor=ft.Colors.RED_600, + padding=ft.padding.all(10), + ), + on_click=issue_offset, + width=80, + height=40, ) - ) + ) + buttons.append( + ft.ElevatedButton( + content=ft.Text("提出", color=ft.Colors.WHITE), + style=ft.ButtonStyle( + bgcolor=ft.Colors.ORANGE_600, + padding=ft.padding.all(10), + ), + on_click=lambda _: self.submit_invoice_for_tax(slip.uuid), + width=80, + height=40, + ) + ) buttons.append( ft.IconButton( ft.Icons.DOWNLOAD, @@ -480,7 +595,7 @@ class FlutterStyleDashboard: on_click=regenerate_and_share, ) ) - actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.END) + actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.CENTER, spacing=10) display_amount = amount if isinstance(slip, Invoice) and getattr(slip, "is_offset", False): @@ -547,11 +662,441 @@ class FlutterStyleDashboard: border_radius=8, ) + def open_invoice_edit(self, invoice: Invoice): + """伝票編集画面を開く""" + self.editing_invoice = invoice + self.is_edit_mode = True + self.selected_customer = invoice.customer + self.selected_document_type = invoice.document_type + self.amount_value = str(invoice.items[0].unit_price if invoice.items else "0") + self.is_detail_edit_mode = False # 初期はビューモード + self.current_tab = 1 # 詳細編集タブに切り替え + self.update_main_content() + def open_new_customer_form(self): """新規顧客フォームを開く(画面内遷移)""" self.is_new_customer_form_open = True self.update_main_content() + def create_invoice_edit_screen(self) -> ft.Container: + """伝票編集画面""" + if self.editing_invoice: + # 既存伝票の編集 + return self._create_edit_existing_screen() + else: + # 新規伝票作成 + return self.create_slip_input_screen() + + def _create_edit_existing_screen(self) -> ft.Container: + """既存伝票の編集画面""" + # 編集不可チェック + is_locked = getattr(self.editing_invoice, 'submitted_to_tax_authority', False) + is_view_mode = not getattr(self, 'is_detail_edit_mode', False) + + # 伝票種類選択(ヘッダーに移動) + document_types = list(DocumentType) + + # 明細テーブル + items_table = self._create_items_table(self.editing_invoice.items, is_locked or is_view_mode) + + # 顧客表示 + customer_display = ft.Container( + content=ft.Row([ + ft.Text("顧客: ", weight=ft.FontWeight.BOLD), + ft.Text(self.editing_invoice.customer.name), # 顧客名を表示 + ]), + padding=ft.padding.symmetric(horizontal=10, vertical=5), + bgcolor=ft.Colors.GREY_100, + border_radius=5, + ) + + # 備考フィールド + notes_field = ft.TextField( + label="備考", + value=getattr(self.editing_invoice, 'notes', ''), + disabled=is_locked or is_view_mode, + multiline=True, + min_lines=2, + max_lines=3, + ) + + def toggle_edit_mode(_): + """編集モード切替""" + old_mode = getattr(self, 'is_detail_edit_mode', False) + self.is_detail_edit_mode = not old_mode + logging.debug(f"Toggle edit mode: {old_mode} -> {self.is_detail_edit_mode}") + self.update_main_content() + + def save_changes(_): + if is_locked: + return + + # 伝票を更新(現在の明細を保持) + # TODO: テーブルから実際の値を取得して更新 + # 現在は既存の明細を維持し、備考のみ更新 + self.editing_invoice.notes = notes_field.value + self.editing_invoice.document_type = self.selected_document_type + + # TODO: ハッシュ再計算と保存 + logging.info(f"伝票更新: {self.editing_invoice.invoice_number}") + + # 編集モード終了(ビューモードに戻る) + self.is_detail_edit_mode = False # ビューモードに戻る + self.update_main_content() + + def cancel_edit(_): + self.is_detail_edit_mode = False + self.is_edit_mode = False + self.editing_invoice = None + self.current_tab = 0 # 一覧タブに戻る + self.update_main_content() + + return ft.Container( + content=ft.Column([ + # ヘッダー + ft.Container( + content=ft.Row([ + ft.Container(expand=True), + # コンパクトな伝票種類選択(セグメント化) + ft.Container( + content=ft.Row([ + ft.GestureDetector( + content=ft.Container( + content=ft.Text( + doc_type.value, + size=10, + color=ft.Colors.WHITE if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_600, + weight=ft.FontWeight.BOLD if doc_type == self.editing_invoice.document_type else ft.FontWeight.NORMAL, + ), + padding=ft.padding.symmetric(horizontal=8, vertical=4), + bgcolor=ft.Colors.BLUE_600 if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_300, + border_radius=ft.border_radius.all(4), + margin=ft.margin.only(right=1), + ), + on_tap=lambda _, dt=doc_type: self.select_document_type(dt.value) if not is_locked and not is_view_mode else None, + ) for doc_type in document_types + ]), + padding=ft.padding.all(2), + bgcolor=ft.Colors.GREY_200, + border_radius=ft.border_radius.all(6), + margin=ft.margin.only(right=10), + ), + ft.ElevatedButton( + content=ft.Text("編集" if is_view_mode else "保存"), + style=ft.ButtonStyle( + bgcolor=ft.Colors.BLUE_600 if is_view_mode else ft.Colors.GREEN_600, + ), + on_click=toggle_edit_mode if is_view_mode else save_changes, + disabled=is_locked, + width=70, + height=30, + ) if not is_locked else ft.Container(), + ft.Container(width=5), + ft.IconButton(ft.Icons.CLOSE, on_click=cancel_edit), + ]), + padding=ft.padding.symmetric(horizontal=15, vertical=8), + bgcolor=ft.Colors.BLUE_GREY, + ), + + # 基本情報行(コンパクトに) + ft.Container( + content=ft.Row([ + ft.Text(f"{self.editing_invoice.invoice_number} | {self.editing_invoice.date.strftime('%Y/%m/%d')} | {self.editing_invoice.customer.name}", size=12, weight=ft.FontWeight.BOLD), + ft.Container(expand=True), + ]), + padding=ft.padding.symmetric(horizontal=15, vertical=3), + bgcolor=ft.Colors.GREY_50, + ), + + # 明細テーブル(フレキシブルに) + ft.Container( + content=ft.Column([ + ft.Row([ + ft.Container(expand=True), + ft.IconButton( + ft.Icons.ADD_CIRCLE_OUTLINE, + tooltip="行を追加", + icon_color=ft.Colors.GREEN_600, + disabled=is_locked or is_view_mode, + on_click=lambda _: self._add_item_row(), + ) if not is_locked and not is_view_mode else ft.Container(), + ]), + ft.Container(height=3), + ft.Container( + content=items_table, + height=300, # 高さを縮小 + border=ft.border.all(1, ft.Colors.GREY_300), + border_radius=5, + padding=ft.padding.all(1), # パディングを最小化 + width=None, # 幅を可変に + expand=True, # 利用可能な幅を全て使用 + ), + ft.Container(height=10), + # 合計金額表示 + ft.Container( + content=ft.Row([ + ft.Container(expand=True), + ft.Text("合計: ", size=14, weight=ft.FontWeight.BOLD), + ft.Text( + f"¥{sum(item.subtotal for item in self.editing_invoice.items):,}", + size=16, + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_600 + ), + ]), + padding=ft.padding.symmetric(horizontal=10, vertical=8), + bgcolor=ft.Colors.GREY_100, + border_radius=5, + ), + ]), + padding=ft.padding.all(15), + expand=True, # 明細部分が最大限のスペースを占有 + ), + + # 備考(コンパクト) + ft.Container( + content=ft.Column([ + ft.Text("備考", size=12, weight=ft.FontWeight.BOLD), + ft.Container(height=3), + notes_field, + ft.Container(height=5), + ft.Text("🔒 税務署提出済みは編集できません" if is_locked else "✅ " + ("編集モード" if not is_view_mode else "ビューモード"), + size=11, color=ft.Colors.RED_600 if is_locked else (ft.Colors.GREEN_600 if not is_view_mode else ft.Colors.BLUE_600)), + ft.Container(height=10), + # PDF生成ボタンを追加 + ft.ElevatedButton( + content=ft.Row([ + ft.Icon(ft.Icons.DOWNLOAD, size=16), + ft.Container(width=5), + ft.Text("PDF生成", size=12, color=ft.Colors.WHITE), + ]), + style=ft.ButtonStyle( + bgcolor=ft.Colors.BLUE_600, + padding=ft.padding.symmetric(horizontal=15, vertical=10), + ), + on_click=lambda _: self.generate_pdf_from_edit(), + width=120, + height=40, + ) if not is_locked else ft.Container(), + ]), + padding=ft.padding.symmetric(horizontal=15, vertical=10), + bgcolor=ft.Colors.GREY_50, + ), + ]), + expand=True, + ) + def generate_pdf_from_edit(self): + """編集画面からPDFを生成""" + if not self.editing_invoice: + return + + try: + pdf_path = self.app_service.invoice.regenerate_pdf(self.editing_invoice.uuid) + if pdf_path: + self.editing_invoice.file_path = pdf_path + self.editing_invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat() + logging.info(f"PDF生成完了: {pdf_path}") + # TODO: 成功メッセージ表示 + else: + logging.error("PDF生成失敗") + # TODO: エラーメッセージ表示 + except Exception as e: + logging.error(f"PDF生成エラー: {e}") + # TODO: エラーメッセージ表示 + + def _add_item_row(self): + """明細行を追加""" + if not self.editing_invoice: + return + + # 新しい明細行を追加 + new_item = InvoiceItem( + description="新しい商品", + quantity=1, + unit_price=0, + is_discount=False + ) + self.editing_invoice.items.append(new_item) + self.update_main_content() + + def _delete_item_row(self, index: int): + """明細行を削除""" + if not self.editing_invoice or index >= len(self.editing_invoice.items): + return + + # 行を削除(最低1行は残す) + if len(self.editing_invoice.items) > 1: + del self.editing_invoice.items[index] + self.update_main_content() + + def _update_item_field(self, item_index: int, field_name: str, value: str): + """明細フィールドを更新""" + if not self.editing_invoice or item_index >= len(self.editing_invoice.items): + return + + item = self.editing_invoice.items[item_index] + + # デバッグ用:更新前の値をログ出力 + old_value = getattr(item, field_name) + logging.debug(f"Updating item {item_index} {field_name}: '{old_value}' -> '{value}'") + + if field_name == 'description': + item.description = value + elif field_name == 'quantity': + try: + # 空文字の場合は1を設定 + if not value or value.strip() == '': + item.quantity = 1 + else: + item.quantity = int(value) + logging.debug(f"Quantity updated to: {item.quantity}") + except ValueError as e: + item.quantity = 1 + logging.error(f"Quantity update error: {e}") + elif field_name == 'unit_price': + try: + # 空文字の場合は0を設定 + if not value or value.strip() == '': + item.unit_price = 0 + else: + item.unit_price = int(value) + logging.debug(f"Unit price updated to: {item.unit_price}") + except ValueError as e: + item.unit_price = 0 + logging.error(f"Unit price update error: {e}") + + # 合計金額を更新するために画面を更新 + self.update_main_content() + + def _create_items_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column: + """明細テーブルを作成(編集モードと表示モードで別の見せ方)""" + # ビューモード状態を取得 + is_view_mode = not getattr(self, 'is_detail_edit_mode', False) + + # デバッグ用:メソッド開始時の状態をログ出力 + logging.debug(f"Creating items table: locked={is_locked}, view_mode={is_view_mode}, edit_mode={getattr(self, 'is_detail_edit_mode', False)}") + logging.debug(f"Items count: {len(items)}") + + if is_view_mode or is_locked: + # 表示モード:表形式で整然と表示 + return self._create_view_mode_table(items) + else: + # 編集モード:入力フォーム風に表示 + return self._create_edit_mode_table(items, is_locked) + + def _create_view_mode_table(self, items: List[InvoiceItem]) -> ft.Column: + """表示モード:フレキシブルな表形式で整然と表示""" + # ヘッダー行 + header_row = ft.Row([ + ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅 + ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35), # 固定幅 + ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 + ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 + ft.Container(width=35), # 削除ボタン用スペースを確保 + ]) + + # データ行 + data_rows = [] + for i, item in enumerate(items): + row = ft.Row([ + ft.Text(item.description, size=12, expand=True), # 可変幅 + ft.Text(str(item.quantity), size=12, width=35, text_align=ft.TextAlign.RIGHT), # 固定幅 + ft.Text(f"¥{item.unit_price:,}", size=12, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅 + ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅 + ft.Container(width=35), # 削除ボタン用スペース + ]) + data_rows.append(row) + + return ft.Column([ + header_row, + ft.Divider(height=1, color=ft.Colors.GREY_400), + ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限 + ]) + + def _create_edit_mode_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column: + """編集モード:フレキシブルな表形式""" + # 編集モードに入ったら自動で空行を追加 + if not any(item.description == "" and item.quantity == 0 and item.unit_price == 0 for item in items): + items.append(InvoiceItem(description="", quantity=0, unit_price=0)) + + # ヘッダー行 + header_row = ft.Row([ + ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅 + ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35), # 固定幅 + ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 + ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 + ft.Container(width=35), # 削除ボタン用スペースを確保 + ]) + + # データ行 + data_rows = [] + for i, item in enumerate(items): + # 商品名フィールド + product_field = ft.TextField( + value=item.description, + text_size=12, + height=28, + width=None, # 幅を可変に + expand=True, # 可変幅 + border=ft.border.all(1, ft.Colors.BLUE_200), + bgcolor=ft.Colors.WHITE, + content_padding=ft.padding.all(5), # 内部余白を最小化 + on_change=lambda e: self._update_item_field(i, 'description', e.control.value), + ) + + # 数量フィールド + quantity_field = ft.TextField( + value=str(item.quantity), + text_size=12, + height=28, + width=35, # 固定幅 + text_align=ft.TextAlign.RIGHT, + border=ft.border.all(1, ft.Colors.BLUE_200), + bgcolor=ft.Colors.WHITE, + content_padding=ft.padding.all(5), # 内部余白を最小化 + on_change=lambda e: self._update_item_field(i, 'quantity', e.control.value), + keyboard_type=ft.KeyboardType.NUMBER, + ) + + # 単価フィールド + unit_price_field = ft.TextField( + value=f"{item.unit_price:,}", + text_size=12, + height=28, + width=70, # 固定幅 + text_align=ft.TextAlign.RIGHT, + border=ft.border.all(1, ft.Colors.BLUE_200), + bgcolor=ft.Colors.WHITE, + content_padding=ft.padding.all(5), # 内部余白を最小化 + on_change=lambda e: self._update_item_field(i, 'unit_price', e.control.value.replace(',', '')), + keyboard_type=ft.KeyboardType.NUMBER, + ) + + # 削除ボタン + delete_button = ft.IconButton( + ft.Icons.DELETE_OUTLINE, + tooltip="行を削除", + icon_color=ft.Colors.RED_600, + disabled=is_locked, + icon_size=16, + on_click=lambda _, idx=i: self._delete_item_row(idx), + ) + + # データ行 + row = ft.Row([ + product_field, + quantity_field, + unit_price_field, + ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅 + delete_button, + ]) + data_rows.append(row) + + return ft.Column([ + header_row, + ft.Divider(height=1, color=ft.Colors.GREY_400), + ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限 + ]) def create_new_customer_screen(self) -> ft.Container: """新規顧客登録画面""" name_field = ft.TextField(label="顧客名(略称)") @@ -629,6 +1174,16 @@ class FlutterStyleDashboard: # UIを更新して選択された顧客を表示 self.update_main_content() + def submit_invoice_for_tax(self, invoice_uuid: str) -> bool: + """税務署提出済みフラグを設定""" + success = self.app_service.invoice.submit_to_tax_authority(invoice_uuid) + if success: + self.invoices = self.app_service.invoice.get_recent_invoices(20) + self.update_main_content() + logging.info(f"税務署提出済み: {invoice_uuid}") + else: + logging.error(f"税務署提出失敗: {invoice_uuid}") + return success def on_customer_deleted(self, customer: Customer): """顧客削除時の処理""" self.app_service.customer.delete_customer(customer.id) diff --git a/components/customer_picker.py b/components/customer_picker.py index f40e5ad..aa7f3f3 100644 --- a/components/customer_picker.py +++ b/components/customer_picker.py @@ -3,6 +3,10 @@ Flutter風のModalBottomSheetをFletで実装 """ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + import flet as ft from typing import List, Callable, Optional from models.invoice_models import Customer diff --git a/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_generated_pdfsSELF001_20260220_214109.pdf_250,000円_350cfecc.PDF b/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_generated_pdfsSELF001_20260220_214109.pdf_250,000円_350cfecc.PDF new file mode 100644 index 0000000..42c628c --- /dev/null +++ b/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_generated_pdfsSELF001_20260220_214109.pdf_250,000円_350cfecc.PDF @@ -0,0 +1,80 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (SELF001) /CreationDate (D:20260221233306+09'00') /Creator (anonymous) /Keywords (node_id=2026-02-20 12:41:09) /ModDate (D:20260221233306+09'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (3772e7ed-19be-4049-ac62-025c725d0912) /Title (\376\377\212\313lBf\370\000 \0002\0000\0002\0006\0000\0002\0002\0000\000-\0002\0001\0004\0001) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 556 +>> +stream +Gatm94\rsL&Dd4649e37qlnCi7jOH5;arYj"sh@khmg11=sm?a72%,-m!JKpj7u@YmA9CPpic))GE4E1?4\B;fQt]12\ROd2;NX?G0*&X$GL/!+n=)3ME98`Bms,$-EN.`D`*Qd*S].F"0q%5[/T%gpn9\WnKg85_NALLp@a;F;n/u7d^2@F:\S^9\(N7lcj!ALlp\6]Eo/q\l!.(a5_7TDa%<;.B!2k]=?<\&;B4:;IR./?IdW45;Cj]5cm.l1]:#_Zda$&"'QSWZO\&F!>4'?:C9W:(W+b$Tr/;?Xj/*n)C/QDEGZP!&8_OV!s'al_+@6eN5""Cm'uTK5h*$]>dMlW\igb%dE8`endstream +endobj +xref +0 10 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000414 00000 n +0000000617 00000 n +0000000685 00000 n +0000001081 00000 n +0000001140 00000 n +trailer +<< +/ID +[<14435c1529362f9feebdcdad3f6da730><14435c1529362f9feebdcdad3f6da730>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1786 +%%EOF diff --git a/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_東京都品川区東品川1-1-1_250,000円_0260ba6d.PDF b/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_東京都品川区東品川1-1-1_250,000円_0260ba6d.PDF new file mode 100644 index 0000000..9df9cab --- /dev/null +++ b/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_東京都品川区東品川1-1-1_250,000円_0260ba6d.PDF @@ -0,0 +1,80 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (SELF) /CreationDate (D:20260221162914+09'00') /Creator (anonymous) /Keywords () /ModDate (D:20260221162914+09'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (aaa8cbcc-06c2-4aff-9955-0d7eda07d4e7) /Title (\376\377\212\313lBf\370\000 \000T\000E\000S\000T\0000\0000\0001) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 571 +>> +stream +Gatm9]8kZ#%.*F5=?8abj$TQ:/H]7X+AH+<$]lRJD>q9XmIT$^>O/A1$Nl,lE]AH*-jK6ta7lc=J1j&JIq<#=r\nf3!<"j$l(>uq!YU7fOteWiGmY,mX[lkkrfn_=l`5Y["mXCro*=LZ1ue0MB.?].`Y;sEa:hOLFj*UZZD[>^6TC^OA;1eG7l#`29a25gs-3TMf7`-ne7>G"S2?=3I*01,^0UF5%UbZ2!FWJf#'/1:]12Rf^gF][U1=\`a!07ZP'`B>6/&>kt`q,F"Y$.YP\#QF)9d`O0<#-c7WN#(,1)6m^_-FkC*@4ER'Q?i]658R0'aW#7fY=ncee0/:g&uWc<;eF8OT[^>BkOB_C6MAoLamY.2[fhLAWTZVSFO.9Whi0V>E!rn?S#q!7p)QD!e_Ao!p)T/>q>R@`InGM0kn=Z:@=\lEsendstream +endobj +xref +0 10 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000414 00000 n +0000000617 00000 n +0000000685 00000 n +0000001021 00000 n +0000001080 00000 n +trailer +<< +/ID +[<647dfafbf6f40c211061b89af760d29a><647dfafbf6f40c211061b89af760d29a>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1741 +%%EOF diff --git a/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_請求書分_250,000円_1d3b6ecc.PDF b/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_請求書分_250,000円_1d3b6ecc.PDF new file mode 100644 index 0000000..bf670b4 --- /dev/null +++ b/generated_pdfs/20260220(請求書)東京都品川区東品川1-1-1_請求書分_250,000円_1d3b6ecc.PDF @@ -0,0 +1,80 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (SELF001) /CreationDate (D:20260221233933+09'00') /Creator (anonymous) /Keywords (node_id=2026-02-20 12:39:44) /ModDate (D:20260221233933+09'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (7face002-113c-45ad-a89c-a73082390cf6) /Title (\376\377\212\313lBf\370\000 \0002\0000\0002\0006\0000\0002\0002\0000\000-\0002\0001\0003\0009) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 556 +>> +stream +Gatm94\rsL&Dd4649e37qlnCi7jOH5;arYj"sh@khmg11=sm?a72%,-m!JKpj7u@YmA9CPpic))GE4E1?4ZlEcH8'12\ROd2;NX?G0*&X$GL/!+n=)3ME98`Bms,$-EN.`D`*Qd*S].F"0q%5[/T%gpn9\WnKg85_NALLp@a;F;n/u7d^2@F:\S^9\(N7lcj!ALlp\6]Eo/q\l!.(a5_7TDa%<;.B!2k]=?<\&;B4:;IR./?IdW45;Cj]5cm.l1]:#_Zda$&"'QSWZO\&F!>4'?:C9W:(W+b$Tr/;?Xj/*n)C/QDEGZP!&8_OV!s'al_+@6eN5""Cm'uTK5h*$]>dMlW\igb%dE8`endstream +endobj +xref +0 10 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000414 00000 n +0000000617 00000 n +0000000685 00000 n +0000001081 00000 n +0000001140 00000 n +trailer +<< +/ID +[<473678096162e2d017466f3e776ba6ca><473678096162e2d017466f3e776ba6ca>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1786 +%%EOF diff --git a/generated_pdfs/20260221(請求書)佐藤商事_核弾頭_5,000,000,000円_96ab1e47.PDF b/generated_pdfs/20260221(請求書)佐藤商事_核弾頭_5,000,000,000円_96ab1e47.PDF new file mode 100644 index 0000000..784cbce --- /dev/null +++ b/generated_pdfs/20260221(請求書)佐藤商事_核弾頭_5,000,000,000円_96ab1e47.PDF @@ -0,0 +1,80 @@ +%PDF-1.3 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (SELF) /CreationDate (D:20260221162429+09'00') /Creator (anonymous) /Keywords () /ModDate (D:20260221162429+09'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (4458bd92-1629-45aa-b3d9-00376ff9d14f) /Title (\376\377\212\313lBf\370\000 \000T\000E\000S\000T\0000\0000\0001) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 541 +>> +stream +Gatm9\PC%-&FK'(;]:=D3VH.''IlM'90+=*,f9S*N#g[Cg5_@^'KK@am$frSgK=`d+1M7lhZ-G04?bR*5lD#Ucp@U3]6_ro$]&Tl-"/k&F6CCF$s>>QZrQ[-8&#M/'2Z*on*cqK>4>VN%oa;G[3NE\e5'3C*4D3g1t=F$A"!)a,m;@E.r!6_^dHo/0YRT_<)R#-G0i~>endstream +endobj +xref +0 10 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000414 00000 n +0000000617 00000 n +0000000685 00000 n +0000001021 00000 n +0000001080 00000 n +trailer +<< +/ID +[<9f94a7ecbd060db21eed7c31734d1ad5><9f94a7ecbd060db21eed7c31734d1ad5>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1711 +%%EOF diff --git a/models/invoice_models.py b/models/invoice_models.py index 36f425a..76e0eb1 100644 --- a/models/invoice_models.py +++ b/models/invoice_models.py @@ -16,14 +16,43 @@ class DocumentType(Enum): RECEIPT = "領収書" SALES = "売上伝票" +class Product: + """商品マスタモデル""" + + def __init__(self, id: Optional[int] = None, name: str = "", unit_price: int = 0, description: str = ""): + self.id = id + self.name = name + self.unit_price = unit_price + self.description = description + + def to_dict(self) -> Dict[str, Any]: + """JSON変換""" + return { + 'id': self.id, + 'name': self.name, + 'unit_price': self.unit_price, + 'description': self.description + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Product': + """JSONから復元""" + return cls( + id=data.get('id'), + name=data.get('name', ''), + unit_price=data.get('unit_price', 0), + description=data.get('description', '') + ) + class InvoiceItem: """伝票の各明細行を表すモデル""" - def __init__(self, description: str, quantity: int, unit_price: int, is_discount: bool = False): + def __init__(self, description: str, quantity: int, unit_price: int, is_discount: bool = False, product_id: Optional[int] = None): self.description = description self.quantity = quantity self.unit_price = unit_price self.is_discount = is_discount # 値引き項目かどうかを示すフラグ + self.product_id = product_id # 商品マスタID @property def subtotal(self) -> int: @@ -36,7 +65,8 @@ class InvoiceItem: description=kwargs.get('description', self.description), quantity=kwargs.get('quantity', self.quantity), unit_price=kwargs.get('unit_price', self.unit_price), - is_discount=kwargs.get('is_discount', self.is_discount) + is_discount=kwargs.get('is_discount', self.is_discount), + product_id=kwargs.get('product_id', self.product_id) ) def to_dict(self) -> Dict[str, Any]: @@ -45,7 +75,8 @@ class InvoiceItem: 'description': self.description, 'quantity': self.quantity, 'unit_price': self.unit_price, - 'is_discount': self.is_discount + 'is_discount': self.is_discount, + 'product_id': self.product_id } @classmethod @@ -55,18 +86,20 @@ class InvoiceItem: description=data['description'], quantity=data['quantity'], unit_price=data['unit_price'], - is_discount=data.get('is_discount', False) + is_discount=data.get('is_discount', False), + product_id=data.get('product_id') ) class Customer: """顧客情報モデル""" - def __init__(self, id: int, name: str, formal_name: str, address: str = "", phone: str = ""): + def __init__(self, id: int, name: str, formal_name: str, address: str = "", phone: str = "", email: str = ""): self.id = id self.name = name self.formal_name = formal_name self.address = address self.phone = phone + self.email = email def to_dict(self) -> Dict[str, Any]: """JSON変換""" @@ -86,7 +119,8 @@ class Customer: name=data['name'], formal_name=data['formal_name'], address=data.get('address', ''), - phone=data.get('phone', '') + phone=data.get('phone', ''), + email=data.get('email', '') ) class Invoice: diff --git a/services/app_service.py b/services/app_service.py index b4a1ba9..63ad98f 100644 --- a/services/app_service.py +++ b/services/app_service.py @@ -9,7 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from typing import Optional, List, Dict, Any from datetime import datetime -from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType +from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType, Product from services.repositories import InvoiceRepository, CustomerRepository from services.pdf_generator import PdfGenerator import logging @@ -226,11 +226,20 @@ class InvoiceService: logging.error(f"PDF再生成失敗: uuidが見つかりません: {invoice_uuid}") return None + # 既存のPDFパスとnotesをクリアして新規生成 + invoice.file_path = None # 既存パスをクリア + invoice.notes = "" # notesもクリア(古いパスが含まれている可能性) + # 会社情報は現状v1固定。将来はcompany_info_versionで分岐。 pdf_path = self.pdf_generator.generate_invoice_pdf(invoice, self.company_info) if not pdf_path: return None - + + # 新しいPDFパスをDBに保存 + invoice.file_path = pdf_path + invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat() + self.invoice_repo.update_invoice_file_path(invoice.uuid, pdf_path, invoice.pdf_generated_at) + return pdf_path def delete_pdf_file(self, pdf_path: str) -> bool: @@ -335,12 +344,28 @@ class CustomerService: # サービスファクトリ +class ProductService: + """商品ビジネスロジック""" + + def __init__(self): + self.invoice_repo = InvoiceRepository() + + def get_all_products(self) -> List[Product]: + """全商品を取得""" + return self.invoice_repo.get_all_products() + + def save_product(self, product: Product) -> bool: + """商品を保存(新規・更新)""" + return self.invoice_repo.save_product(product) + + class AppService: """アプリケーションサービス統合""" def __init__(self): self.invoice = InvoiceService() self.customer = CustomerService() + self.product = ProductService() def get_dashboard_data(self) -> Dict[str, Any]: """ダッシュボード表示用データ""" diff --git a/services/pdf_generator.py b/services/pdf_generator.py index b6f97e3..00f3770 100644 --- a/services/pdf_generator.py +++ b/services/pdf_generator.py @@ -42,9 +42,41 @@ class PdfGenerator: 生成されたPDFファイルパス、失敗時はNone """ try: - # ファイル名生成: {会社ID}_{端末ID}_{連番}.pdf - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{company_info.get('id', 'COMP')}_{timestamp}.pdf" + # ファイル名生成ルール: {日付}({タイプ}){株式会社等を除く顧客名}_{件名or商品1行目}_{金額}円_{HASH下8桁}.PDF + date_str = invoice.date.strftime("%Y%m%d") + doc_type = invoice.document_type.value if invoice.document_type else "請求" + + # 顧客名から株式会社等を除去 + customer_name = invoice.customer.name + for suffix in ["株式会社", "有限会社", "合資会社", "合同会社"]: + customer_name = customer_name.replace(suffix, "") + + # 顧客名から不正な文字を除去(ファイル名に使えない文字) + import re + customer_name = re.sub(r'[\\/:*?"<>|]', '', customer_name) + + # 件名または商品1行目 + subject_or_product = invoice.notes or "" + if not subject_or_product and invoice.items: + subject_or_product = invoice.items[0].description + + # 件名から不正な文字を除去 + subject_or_product = re.sub(r'[\\/:*?"<>|]', '', subject_or_product) + + # 件名が長すぎる場合は短縮 + if len(subject_or_product) > 30: + subject_or_product = subject_or_product[:30] + "..." + + # 金額 + total_amount = sum(item.subtotal for item in invoice.items) + amount_str = f"{total_amount:,}円" + + # ハッシュ(仮実装) + import hashlib + hash_input = f"{date_str}{doc_type}{customer_name}{subject_or_product}{total_amount}" + hash_value = hashlib.md5(hash_input.encode()).hexdigest()[:8] + + filename = f"{date_str}({doc_type}){customer_name}_{subject_or_product}_{amount_str}_{hash_value}.PDF" filepath = os.path.join(self.output_dir, filename) # PDF生成 diff --git a/services/repositories.py b/services/repositories.py index 763abec..062e933 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -11,7 +11,7 @@ import sqlite3 import json from datetime import datetime from typing import List, Optional, Dict, Any -from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType +from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType, Product import logging import uuid as uuid_module import hashlib @@ -82,10 +82,23 @@ class InvoiceRepository: quantity INTEGER, unit_price INTEGER, is_discount BOOLEAN, + product_id INTEGER, FOREIGN KEY (invoice_id) REFERENCES invoices(id) ) ''') + # 商品マスタテーブル + cursor.execute(''' + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + unit_price INTEGER NOT NULL, + description TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() # 既存DB向けの軽量マイグレーション(列追加) @@ -325,6 +338,20 @@ class InvoiceRepository: logging.error(f"伝票保存エラー: {e}") return False + def update_invoice_file_path(self, invoice_uuid: str, file_path: str, pdf_generated_at: str): + """PDFファイルパスを更新""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE invoices + SET file_path = ?, pdf_generated_at = ? + WHERE uuid = ? + ''', (file_path, pdf_generated_at, invoice_uuid)) + conn.commit() + except Exception as e: + logging.error(f"PDFパス更新エラー: {e}") + def get_all_invoices(self, limit: int = 100) -> List[Invoice]: """全伝票を取得""" invoices = [] @@ -572,6 +599,66 @@ class CustomerRepository: logging.error(f"顧客更新エラー: {e}") return False + def get_all_products(self) -> List[Product]: + """全商品を取得""" + products = [] + + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM products ORDER BY name') + + rows = cursor.fetchall() + for row in rows: + products.append(Product( + id=row[0], + name=row[1], + unit_price=row[2], + description=row[3] + )) + + return products + + except Exception as e: + logging.error(f"商品取得エラー: {e}") + return [] + + def save_product(self, product: Product) -> bool: + """商品を保存(新規・更新)""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + if product.id: + # 更新 + cursor.execute(""" + UPDATE products + SET name = ?, unit_price = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, ( + product.name, + product.unit_price, + product.description, + product.id + )) + else: + # 新規 + cursor.execute(""" + INSERT INTO products (name, unit_price, description) + VALUES (?, ?, ?) + """, ( + product.name, + product.unit_price, + product.description + )) + + conn.commit() + return True + + except Exception as e: + logging.error(f"商品保存エラー: {e}") + return False + def get_all_customers(self) -> List[Customer]: """全顧客を取得""" customers = []