From 1344f1d90b757bea02bae0c4a61b26ca8a200c74 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 22 Feb 2026 23:50:41 +0900 Subject: [PATCH] baseline: quarantine generated files and stabilize project --- .gitignore | 15 + README.md | 27 +- main.py | 2551 +++++++++++++++++++---------- scripts/auto_recover_and_build.sh | 118 ++ services/repositories.py | 27 +- 5 files changed, 1874 insertions(+), 864 deletions(-) create mode 100644 scripts/auto_recover_and_build.sh diff --git a/.gitignore b/.gitignore index 4223994..33dde93 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,18 @@ __pycache__/ sales.db __init__.py +# .gitignore +.env +.env.* + +# build/cache +build/ +dist/ +*.pyc +# generated +generated_pdfs/ +audit_export/ +trash/ +# experiments +apk_test/ +helloworld/ diff --git a/README.md b/README.md index 08e9f2f..4e2b5cc 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,35 @@ python main.py Fletを使用してAndroidアプリをビルド: ```bash -python build.py +flet build apk . ``` -または直接実行: +リリースAABを作る場合: ```bash -flet pack main.py --android +flet build aab . ``` +## リポジトリ整理の自動化 + +SWE実行で生成された試作ファイル/生成物を安全に整理するため、 +削除ではなく `trash/` へ隔離するスクリプトを用意しています。 + +```bash +bash scripts/auto_recover_and_build.sh /home/user/dev/h-1.flet.3 +``` + +このスクリプトで実行される内容: + +- プロジェクト全体のバックアップ作成 +- 生成物/試作ファイルの `trash//` への移動 +- `.gitignore` の整備 +- Gitベースラインコミット作成(必要時) + +注意: + +- 実行確認 (`python main.py`) と APK ビルド (`flet build apk`) は自動実行しません +- 必要に応じて最後に表示されるコマンドを手動実行してください + ## データベース アプリケーションはSQLiteデータベース(`sales.db`)を使用してデータを保存します。 diff --git a/main.py b/main.py index 2fd8945..6ebad84 100644 --- a/main.py +++ b/main.py @@ -1,877 +1,1726 @@ -import flet as ft -import sqlite3 -import datetime -from typing import List, Dict, Optional -from data_export import DataExporter -from compliance import ComplianceManager +""" +Flutter風ダッシュボード +下部ナビゲーションと洗練されたUIコンポーネントを実装 +""" -class SalesAssistant: +import flet as ft +import signal +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, InvoiceItem +from components.customer_picker import CustomerPickerModal +from services.app_service import AppService + +# ロギング設定 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +class ZoomableContainer(ft.Container): + """ピンチイン/アウト対応コンテナ""" + + def __init__(self, content=None, min_scale=0.5, max_scale=3.0, **kwargs): + super().__init__(**kwargs) + self.content = content + self.min_scale = min_scale + self.max_scale = max_scale + self.scale = 1.0 + self.initial_distance = 0 + + # ズーム機能を無効化してシンプルなコンテナとして使用 + # 将来的な実装のためにクラスは残す + +class AppBar(ft.Container): + """標準化されたアプリケーションヘッダー""" + + def __init__(self, title: str, show_back: bool = False, show_edit: bool = False, + on_back=None, on_edit=None, page=None): + super().__init__() + self.title = title + self.show_back = show_back + self.show_edit = show_edit + self.on_back = on_back + self.on_edit = on_edit + self.page_ref = page # page_refとして保存 + + self.bgcolor = ft.Colors.BLUE_GREY_50 + self.padding = ft.Padding.symmetric(horizontal=16, vertical=8) + + self.content = self._build_content() + + def _build_content(self) -> ft.Row: + """AppBarのコンテンツを構築""" + controls = [] + + # 左側:戻るボタン + if self.show_back: + controls.append( + ft.IconButton( + icon=ft.Icons.ARROW_BACK, + icon_color=ft.Colors.BLUE_GREY_700, + tooltip="戻る", + on_click=self.on_back if self.on_back else None + ) + ) + else: + controls.append(ft.Container(width=48)) # スペーーサー + + # 中央:タイトル + controls.append( + ft.Container( + content=ft.Text( + self.title, + size=18, + weight=ft.FontWeight.W_500, + color=ft.Colors.BLUE_GREY_800 + ), + expand=True, + alignment=ft.alignment.Alignment(0, 0) # 中央揃え + ) + ) + + # 右側:編集ボタン + if self.show_edit: + controls.append( + ft.IconButton( + icon=ft.Icons.EDIT, + icon_color=ft.Colors.BLUE_GREY_700, + tooltip="編集", + on_click=self.on_edit if self.on_edit else None + ) + ) + else: + controls.append(ft.Container(width=48)) # スペーサー + + return ft.Row( + controls, + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER + ) + +class FlutterStyleDashboard: + """Flutter風の統合ダッシュボード""" + def __init__(self, page: ft.Page): self.page = page + 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 + self.chain_verify_result = None + self.is_new_customer_form_open = False + + # ビジネスロジックサービス + self.app_service = AppService() + self.invoices = [] + self.customers = [] + + self.setup_page() + self.setup_database() + self.setup_ui() + + def setup_page(self): + """ページ設定""" self.page.title = "販売アシスト1号" + self.page.window.width = 420 + self.page.window.height = 900 self.page.theme_mode = ft.ThemeMode.LIGHT - self.page.vertical_alignment = ft.MainAxisAlignment.CENTER - self.page.horizontal_alignment = ft.CrossAxisAlignment.CENTER - # Initialize database - self.init_database() + # Fletのライフサイクルに任せる(SystemExitがasyncioに伝播して警告になりやすい) - # Initialize data exporter and compliance manager - self.exporter = DataExporter() - self.compliance = ComplianceManager() - - # Navigation - self.navigation_rail = ft.NavigationRail( - selected_index=0, - label_type=ft.NavigationRailLabelType.ALL, - min_width=100, - min_extended_width=200, - destinations=[ - ft.NavigationRailDestination( - label="ダッシュボード" - ), - ft.NavigationRailDestination( - label="顧客管理" - ), - ft.NavigationRailDestination( - label="商品管理" - ), - ft.NavigationRailDestination( - label="売上管理" - ), - ft.NavigationRailDestination( - label="データ出力" - ), - ft.NavigationRailDestination( - label="コンプライアンス" - ), - ], - on_change=self.navigation_changed - ) - - # Content area - self.content_area = ft.Container(expand=True) - - # Main layout - self.main_layout = ft.Row( - [ - self.navigation_rail, - ft.VerticalDivider(width=1), - self.content_area - ], - expand=True - ) - - # Show dashboard initially - self.show_dashboard() - - # Add main layout to page - self.page.add(self.main_layout) - - def init_database(self): - """Initialize SQLite database""" - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - - # Create customers table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS customers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - company TEXT, - phone TEXT, - email TEXT, - address TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Create products table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - code TEXT UNIQUE, - price REAL NOT NULL, - stock INTEGER DEFAULT 0, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Create sales table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS sales ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - customer_id INTEGER, - product_id INTEGER, - quantity INTEGER NOT NULL, - unit_price REAL NOT NULL, - total_price REAL NOT NULL, - sale_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (customer_id) REFERENCES customers (id), - FOREIGN KEY (product_id) REFERENCES products (id) - ) - ''') - - conn.commit() - conn.close() - - def navigation_changed(self, e): - """Handle navigation rail changes""" - if e.control.selected_index == 0: - self.show_dashboard() - elif e.control.selected_index == 1: - self.show_customers() - elif e.control.selected_index == 2: - self.show_products() - elif e.control.selected_index == 3: - self.show_sales() - elif e.control.selected_index == 4: - self.show_data_export() - elif e.control.selected_index == 5: - self.show_compliance() - - def show_dashboard(self): - """Show dashboard view""" - # Get statistics - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - - # Total customers - cursor.execute("SELECT COUNT(*) FROM customers") - total_customers = cursor.fetchone()[0] - - # Total products - cursor.execute("SELECT COUNT(*) FROM products") - total_products = cursor.fetchone()[0] - - # Total sales - cursor.execute("SELECT COUNT(*) FROM sales") - total_sales = cursor.fetchone()[0] - - # Total revenue - cursor.execute("SELECT SUM(total_price) FROM sales") - total_revenue = cursor.fetchone()[0] or 0 - - conn.close() - - dashboard_content = ft.Column([ - ft.Text("ダッシュボード", size=24, weight=ft.FontWeight.BOLD), - ft.Divider(), - ft.Row([ - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("顧客数", size=16, color=ft.Colors.BLUE), - ft.Text(str(total_customers), size=32, weight=ft.FontWeight.BOLD) - ]), - padding=20, - width=150 - ) - ), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("商品数", size=16, color=ft.Colors.GREEN), - ft.Text(str(total_products), size=32, weight=ft.FontWeight.BOLD) - ]), - padding=20, - width=150 - ) - ), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("売上件数", size=16, color=ft.Colors.ORANGE), - ft.Text(str(total_sales), size=32, weight=ft.FontWeight.BOLD) - ]), - padding=20, - width=150 - ) - ), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("総売上", size=16, color=ft.Colors.PURPLE), - ft.Text(f"¥{total_revenue:,.0f}", size=32, weight=ft.FontWeight.BOLD) - ]), - padding=20, - width=150 - ) - ) - ], wrap=True) - ], scroll=ft.ScrollMode.AUTO) - - self.content_area.content = ft.Container(dashboard_content, padding=20) - self.page.update() - - def show_customers(self): - """Show customers management view""" - customers = self.get_customers() - - # Customer list - customer_list = ft.ListView( - expand=True, - spacing=10, - padding=10 - ) - - for customer in customers: - customer_list.controls.append( - ft.Card( - content=ft.Container( - content=ft.ListTile( - title=ft.Text(customer['name']), - subtitle=ft.Text(customer['company'] or ''), - trailing=ft.Row([ - ft.IconButton( - icon=ft.icons.EDIT, - on_click=lambda e, c=customer: self.edit_customer(c) - ), - ft.IconButton( - icon=ft.icons.DELETE, - on_click=lambda e, c=customer: self.delete_customer(c) - ) - ]) - ), - padding=10 - ) - ) - ) - - # Add customer button - add_button = ft.ElevatedButton( - "顧客を追加", - icon=ft.icons.ADD, - on_click=self.add_customer_dialog - ) - - customers_content = ft.Column([ - ft.Row([ - ft.Text("顧客管理", size=24, weight=ft.FontWeight.BOLD), - add_button - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), - ft.Divider(), - customer_list - ], scroll=ft.ScrollMode.AUTO) - - self.content_area.content = ft.Container(customers_content, padding=20) - self.page.update() - - def show_products(self): - """Show products management view""" - products = self.get_products() - - # Product list - product_list = ft.ListView( - expand=True, - spacing=10, - padding=10 - ) - - for product in products: - product_list.controls.append( - ft.Card( - content=ft.Container( - content=ft.ListTile( - title=ft.Text(product['name']), - subtitle=ft.Text(f"¥{product['price']:,.0f} | 在庫: {product['stock']}"), - trailing=ft.Row([ - ft.IconButton( - icon=ft.icons.EDIT, - on_click=lambda e, p=product: self.edit_product(p) - ), - ft.IconButton( - icon=ft.icons.DELETE, - on_click=lambda e, p=product: self.delete_product(p) - ) - ]) - ), - padding=10 - ) - ) - ) - - # Add product button - add_button = ft.ElevatedButton( - "商品を追加", - icon=ft.icons.ADD, - on_click=self.add_product_dialog - ) - - products_content = ft.Column([ - ft.Row([ - ft.Text("商品管理", size=24, weight=ft.FontWeight.BOLD), - add_button - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), - ft.Divider(), - product_list - ], scroll=ft.ScrollMode.AUTO) - - self.content_area.content = ft.Container(products_content, padding=20) - self.page.update() - - def show_sales(self): - """Show sales management view""" - sales = self.get_sales() - - # Sales list - sales_list = ft.ListView( - expand=True, - spacing=10, - padding=10 - ) - - for sale in sales: - sales_list.controls.append( - ft.Card( - content=ft.Container( - content=ft.ListTile( - title=ft.Text(f"{sale['customer_name']} - {sale['product_name']}"), - subtitle=ft.Text(f"{sale['quantity']}個 × ¥{sale['unit_price']:,.0f} = ¥{sale['total_price']:,.0f}"), - trailing=ft.Text(sale['sale_date'].split()[0]) - ), - padding=10 - ) - ) - ) - - # Add sale button - add_button = ft.ElevatedButton( - "売上を追加", - icon=ft.icons.ADD, - on_click=self.add_sale_dialog - ) - - sales_content = ft.Column([ - ft.Row([ - ft.Text("売上管理", size=24, weight=ft.FontWeight.BOLD), - add_button - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), - ft.Divider(), - sales_list - ], scroll=ft.ScrollMode.AUTO) - - self.content_area.content = ft.Container(sales_content, padding=20) - self.page.update() - - def get_customers(self) -> List[Dict]: - """Get all customers from database""" - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute("SELECT * FROM customers ORDER BY created_at DESC") - customers = [] - for row in cursor.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'company': row[2], - 'phone': row[3], - 'email': row[4], - 'address': row[5], - 'created_at': row[6] - }) - conn.close() - return customers - - def get_products(self) -> List[Dict]: - """Get all products from database""" - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute("SELECT * FROM products ORDER BY created_at DESC") - products = [] - for row in cursor.fetchall(): - products.append({ - 'id': row[0], - 'name': row[1], - 'code': row[2], - 'price': row[3], - 'stock': row[4], - 'description': row[5], - 'created_at': row[6] - }) - conn.close() - return products - - def get_sales(self) -> List[Dict]: - """Get all sales with customer and product names""" - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute(''' - SELECT s.*, c.name as customer_name, p.name as product_name - FROM sales s - LEFT JOIN customers c ON s.customer_id = c.id - LEFT JOIN products p ON s.product_id = p.id - ORDER BY s.sale_date DESC - ''') - sales = [] - for row in cursor.fetchall(): - sales.append({ - 'id': row[0], - 'customer_id': row[1], - 'product_id': row[2], - 'quantity': row[3], - 'unit_price': row[4], - 'total_price': row[5], - 'sale_date': row[6], - 'customer_name': row[7] or '不明', - 'product_name': row[8] or '不明' - }) - conn.close() - return sales - - def add_customer_dialog(self, e): - """Show add customer dialog""" - name_field = ft.TextField(label="氏名", autofocus=True) - company_field = ft.TextField(label="会社名") - phone_field = ft.TextField(label="電話番号") - email_field = ft.TextField(label="メールアドレス") - address_field = ft.TextField(label="住所") - - def save_customer(e): - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO customers (name, company, phone, email, address) - VALUES (?, ?, ?, ?, ?) - ''', (name_field.value, company_field.value, phone_field.value, - email_field.value, address_field.value)) - conn.commit() - conn.close() - dialog.open = False - self.show_customers() - self.page.update() - - dialog = ft.AlertDialog( - title=ft.Text("顧客を追加"), - content=ft.Column([ - name_field, - company_field, - phone_field, - email_field, - address_field - ], tight=True), - actions=[ - ft.TextButton("キャンセル", on_click=lambda e: self.close_dialog(dialog)), - ft.TextButton("保存", on_click=save_customer) - ], - actions_alignment=ft.MainAxisAlignment.END - ) - - self.page.dialog = dialog - dialog.open = True - self.page.update() - - def add_product_dialog(self, e): - """Show add product dialog""" - name_field = ft.TextField(label="商品名", autofocus=True) - code_field = ft.TextField(label="商品コード") - price_field = ft.TextField(label="価格", keyboard_type=ft.KeyboardType.NUMBER) - stock_field = ft.TextField(label="在庫数", keyboard_type=ft.KeyboardType.NUMBER) - description_field = ft.TextField(label="説明", multiline=True) - - def save_product(e): - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO products (name, code, price, stock, description) - VALUES (?, ?, ?, ?, ?) - ''', (name_field.value, code_field.value, float(price_field.value or 0), - int(stock_field.value or 0), description_field.value)) - conn.commit() - conn.close() - dialog.open = False - self.show_products() - self.page.update() - - dialog = ft.AlertDialog( - title=ft.Text("商品を追加"), - content=ft.Column([ - name_field, - code_field, - price_field, - stock_field, - description_field - ], tight=True), - actions=[ - ft.TextButton("キャンセル", on_click=lambda e: self.close_dialog(dialog)), - ft.TextButton("保存", on_click=save_product) - ], - actions_alignment=ft.MainAxisAlignment.END - ) - - self.page.dialog = dialog - dialog.open = True - self.page.update() - - def add_sale_dialog(self, e): - """Show add sale dialog""" - customers = self.get_customers() - products = self.get_products() - - customer_dropdown = ft.Dropdown( - label="顧客", - options=[ft.dropdown.Option(c['name'], key=str(c['id'])) for c in customers] - ) - product_dropdown = ft.Dropdown( - label="商品", - options=[ft.dropdown.Option(p['name'], key=str(p['id'])) for p in products], - on_change=lambda e: self.update_price_display(e, product_dropdown, price_field, quantity_field, total_field) - ) - quantity_field = ft.TextField( - label="数量", - value="1", - keyboard_type=ft.KeyboardType.NUMBER, - on_change=lambda e: self.calculate_total(price_field, quantity_field, total_field) - ) - price_field = ft.TextField( - label="単価", - keyboard_type=ft.KeyboardType.NUMBER, - on_change=lambda e: self.calculate_total(price_field, quantity_field, total_field) - ) - total_field = ft.TextField(label="合計", read_only=True) - - def save_sale(e): - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO sales (customer_id, product_id, quantity, unit_price, total_price) - VALUES (?, ?, ?, ?, ?) - ''', (int(customer_dropdown.value), int(product_dropdown.value), - int(quantity_field.value), float(price_field.value), - float(total_field.value))) - conn.commit() - conn.close() - dialog.open = False - self.show_sales() - self.page.update() - - dialog = ft.AlertDialog( - title=ft.Text("売上を追加"), - content=ft.Column([ - customer_dropdown, - product_dropdown, - quantity_field, - price_field, - total_field - ], tight=True), - actions=[ - ft.TextButton("キャンセル", on_click=lambda e: self.close_dialog(dialog)), - ft.TextButton("保存", on_click=save_sale) - ], - actions_alignment=ft.MainAxisAlignment.END - ) - - self.page.dialog = dialog - dialog.open = True - self.page.update() - - def update_price_display(self, e, product_dropdown, price_field, quantity_field, total_field): - """Update price when product is selected""" - if product_dropdown.value: - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute("SELECT price FROM products WHERE id = ?", (int(product_dropdown.value),)) - result = cursor.fetchone() - conn.close() - if result: - price_field.value = str(result[0]) - self.calculate_total(price_field, quantity_field, total_field) - self.page.update() - - def calculate_total(self, price_field, quantity_field, total_field): - """Calculate total price""" + def setup_database(self): + """データ初期化(サービス層経由)""" try: - price = float(price_field.value or 0) - quantity = int(quantity_field.value or 1) - total_field.value = str(price * quantity) - except ValueError: - total_field.value = "0" - self.page.update() - - def close_dialog(self, dialog): - """Close dialog""" - dialog.open = False - self.page.update() - - def edit_customer(self, customer): - """Edit customer (placeholder)""" - self.page.snack_bar = ft.SnackBar(content=ft.Text("顧客編集機能は準備中です")) - self.page.snack_bar.open = True - self.page.update() - - def delete_customer(self, customer): - """Delete customer""" - def confirm_delete(e): - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute("DELETE FROM customers WHERE id = ?", (customer['id'],)) - conn.commit() - conn.close() - dialog.open = False - self.show_customers() - self.page.update() - - dialog = ft.AlertDialog( - title=ft.Text("確認"), - content=ft.Text(f"顧客「{customer['name']}」を削除してもよろしいですか?"), - actions=[ - ft.TextButton("キャンセル", on_click=lambda e: self.close_dialog(dialog)), - ft.TextButton("削除", on_click=confirm_delete) - ], - actions_alignment=ft.MainAxisAlignment.END - ) - - self.page.dialog = dialog - dialog.open = True - self.page.update() - - def edit_product(self, product): - """Edit product (placeholder)""" - self.page.snack_bar = ft.SnackBar(content=ft.Text("商品編集機能は準備中です")) - self.page.snack_bar.open = True - self.page.update() - - def delete_product(self, product): - """Delete product""" - def confirm_delete(e): - conn = sqlite3.connect('sales.db') - cursor = conn.cursor() - cursor.execute("DELETE FROM products WHERE id = ?", (product['id'],)) - conn.commit() - conn.close() - dialog.open = False - self.show_products() - self.page.update() - - dialog = ft.AlertDialog( - title=ft.Text("確認"), - content=ft.Text(f"商品「{product['name']}」を削除してもよろしいですか?"), - actions=[ - ft.TextButton("キャンセル", on_click=lambda e: self.close_dialog(dialog)), - ft.TextButton("削除", on_click=confirm_delete) - ], - actions_alignment=ft.MainAxisAlignment.END - ) - - self.page.dialog = dialog - dialog.open = True - self.page.update() - - def show_data_export(self): - """データ出力画面を表示""" - export_content = ft.Column([ - ft.Text("データ出力", size=24, weight=ft.FontWeight.BOLD), - ft.Divider(), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("JSON形式で出力", size=16, weight=ft.FontWeight.BOLD), - ft.Text("全データをJSON形式でエクスポートします"), - ft.ElevatedButton( - "JSON出力", - icon=ft.icons.DOWNLOAD, - on_click=self.export_json - ) - ]), - padding=20 - ) - ), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("CSV形式で出力", size=16, weight=ft.FontWeight.BOLD), - ft.Text("各テーブルをCSV形式でエクスポートします"), - ft.ElevatedButton( - "CSV出力", - icon=ft.icons.DOWNLOAD, - on_click=self.export_csv - ) - ]), - padding=20 - ) - ) - ], scroll=ft.ScrollMode.AUTO) - - self.content_area.content = ft.Container(export_content, padding=20) - self.page.update() - - def show_compliance(self): - """コンプライアンス画面を表示""" - compliance_content = ft.Column([ - ft.Text("電子帳簿保存法対応", size=24, weight=ft.FontWeight.BOLD), - ft.Divider(), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("データ整合性チェック", size=16, weight=ft.FontWeight.BOLD), - ft.Text("データの整合性を検証し、チェックサムを生成します"), - ft.ElevatedButton( - "整合性チェック", - icon=ft.icons.CHECK_CIRCLE, - on_click=self.check_integrity - ) - ]), - padding=20 - ) - ), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("古いデータのアーカイブ", size=16, weight=ft.FontWeight.BOLD), - ft.Text("7年以上前のデータをアーカイブします(10年保存)"), - ft.ElevatedButton( - "データアーカイブ", - icon=ft.icons.ARCHIVE, - on_click=self.archive_data - ) - ]), - padding=20 - ) - ), - ft.Card( - content=ft.Container( - content=ft.Column([ - ft.Text("コンプライアンスレポート", size=16, weight=ft.FontWeight.BOLD), - ft.Text("電子帳簿保存法対応レポートを生成します"), - ft.ElevatedButton( - "レポート生成", - icon=ft.icons.ASSIGNMENT, - on_click=self.generate_compliance_report - ) - ]), - padding=20 - ) - ) - ], scroll=ft.ScrollMode.AUTO) - - self.content_area.content = ft.Container(compliance_content, padding=20) - self.page.update() - - def export_json(self, e): - """JSON形式でデータをエクスポート""" - try: - file_path = self.exporter.export_to_json() - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"JSON出力完了: {file_path}"), - bgcolor=ft.Colors.GREEN - ) - except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"JSON出力エラー: {str(ex)}"), - bgcolor=ft.Colors.RED - ) - - self.page.snack_bar.open = True - self.page.update() - - def export_csv(self, e): - """CSV形式でデータをエクスポート""" - try: - files = self.exporter.export_to_csv() - file_list = "\n".join([f"{k}: {v}" for k, v in files.items()]) - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"CSV出力完了:\n{file_list}"), - bgcolor=ft.Colors.GREEN - ) - except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"CSV出力エラー: {str(ex)}"), - bgcolor=ft.Colors.RED - ) - - self.page.snack_bar.open = True - self.page.update() - - def check_integrity(self, e): - """データ整合性チェック""" - try: - results = [] - for table in ["customers", "products", "sales"]: - result = self.compliance.verify_data_integrity(table) - results.append(f"{table}: {result['status']} ({result['record_count']}件)") + # 顧客データ読み込み + self.customers = self.app_service.customer.get_all_customers() - message = "整合性チェック完了:\n" + "\n".join(results) - self.page.snack_bar = ft.SnackBar( - content=ft.Text(message), - bgcolor=ft.Colors.GREEN - ) - except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"整合性チェックエラー: {str(ex)}"), - bgcolor=ft.Colors.RED - ) + # 伝票データ読み込み + self.invoices = self.app_service.invoice.get_recent_invoices(20) + + logging.info(f"データ初期化: 顧客{len(self.customers)}件, 伝票{len(self.invoices)}件") + + # 伝票データがない場合はサンプルデータを作成 + if len(self.invoices) == 0: + logging.info("サンプルデータを作成します") + self.create_sample_data_via_service() + # 再度データ読み込み + self.invoices = self.app_service.invoice.get_recent_invoices(20) + logging.info(f"サンプルデータ作成後: 伝票{len(self.invoices)}件") + + except Exception as e: + logging.error(f"データ初期化エラー: {e}") + + def create_sample_data_via_service(self): + """サービス層経由でサンプルデータ作成""" + try: + sample_invoices = create_sample_invoices() + + for invoice in sample_invoices: + # 顧客を先に作成(存在しない場合) + customer = invoice.customer + if customer.id == 0 or not any(c.id == customer.id for c in self.customers): + customer_id = self.app_service.customer.create_customer( + name=customer.name, + formal_name=customer.formal_name, + address=customer.address, + phone=customer.phone + ) + customer.id = customer_id + + # 伝票を作成 + self.app_service.invoice.create_invoice( + customer=customer, + document_type=invoice.document_type, + amount=invoice.total_amount, + notes=invoice.notes, + items=invoice.items + ) + + logging.info(f"サンプルデータ作成完了: {len(sample_invoices)}件") + + except Exception as e: + logging.error(f"サンプルデータ作成エラー: {e}") + + def create_sample_data(self): + """サンプル伝票データ作成""" + try: + # サンプルデータ + sample_invoices = create_sample_invoices() + + for invoice in sample_invoices: + self.cursor.execute(''' + INSERT OR REPLACE INTO slips + (document_type, customer_name, amount, date, status, description) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + invoice.document_type.value, + invoice.customer.formal_name, + invoice.total_amount, + invoice.date.strftime('%Y-%m-%d %H:%M'), + '完了', + invoice.notes + )) + + self.conn.commit() + except Exception as e: + logging.error(f"サンプルデータ作成エラー: {e}") + + def setup_ui(self): + """UIセットアップ""" + # メインコンテンツ + self.main_content = ft.Column([], expand=True) - self.page.snack_bar.open = True + # ページ構成 + self.page.add( + ft.Column([ + self.main_content, + ], expand=True) + ) + + # 初期表示 + self.update_main_content() + + def on_tab_change(self, index): + """タブ切り替え""" + self.current_tab = index + self.update_main_content() self.page.update() - def archive_data(self, e): - """古いデータをアーカイブ""" - try: - result = self.compliance.archive_old_data() - if result["status"] == "SUCCESS": - message = f"アーカイブ完了: {result['archived_count']}件" - bgcolor = ft.Colors.GREEN + def update_main_content(self): + """メインコンテンツ更新""" + self.main_content.controls.clear() + + logging.info(f"update_main_content: current_tab={self.current_tab}, is_customer_picker_open={self.is_customer_picker_open}") + + if self.is_customer_picker_open: + # 顧客選択画面 + logging.info("顧客選択画面を表示") + self.main_content.controls.append(self.create_customer_picker_screen()) + elif self.current_tab == 0: + # 伝票一覧画面 + logging.info("伝票一覧画面を表示") + self.main_content.controls.append(self._build_invoice_list_screen()) + elif self.current_tab == 1: + # 伝票詳細/編集画面 + logging.info("伝票詳細/編集画面を表示") + self.main_content.controls.append(self._build_invoice_detail_screen()) + else: + # 不明なタブは一覧に戻す + self.current_tab = 0 + logging.info("不明なタブ、一覧画面を表示") + self.main_content.controls.append(self._build_invoice_list_screen()) + + logging.info(f"コントロール数: {len(self.main_content.controls)}") + self.page.update() + + def _build_invoice_list_screen(self) -> ft.Column: + """伝票一覧画面を構築""" + logging.info("_build_invoice_list_screen: 開始") + + # AppBar(戻るボタンなし、編集ボタンなし) + app_bar = AppBar( + title="伝票一覧", + show_back=False, + show_edit=False + ) + logging.info("_build_invoice_list_screen: AppBar作成完了") + + # 伝票リスト(ズーム対応) + invoice_list = self._build_invoice_list() + logging.info(f"_build_invoice_list_screen: 伝票リスト作成完了") + + zoomable_list = ZoomableContainer( + content=invoice_list, + min_scale=0.8, + max_scale=2.5 + ) + logging.info("_build_invoice_list_screen: ZoomableContainer作成完了") + + result = ft.Column([ + app_bar, + ft.Container( + content=zoomable_list, + expand=True, + padding=ft.Padding.all(16) + ), + ], expand=True) + + logging.info("_build_invoice_list_screen: Column作成完了") + return result + + def _build_invoice_detail_screen(self) -> ft.Column: + """伝票詳細画面を構築""" + if not self.editing_invoice: + return ft.Column([ft.Text("伝票が選択されていません")]) + + # AppBar(戻るボタンあり、編集ボタン条件付き) + is_locked = getattr(self.editing_invoice, 'final_locked', False) + app_bar = AppBar( + title=f"伝票詳細: {self.editing_invoice.invoice_number}", + show_back=True, + show_edit=not is_locked, + on_back=lambda _: self.back_to_list(), + on_edit=lambda _: self.toggle_edit_mode() + ) + + # 伝票詳細コンテンツ(ズーム対応) + detail_content = self._create_edit_existing_screen() + zoomable_content = ZoomableContainer( + content=detail_content, + min_scale=0.8, + max_scale=2.5 + ) + + return ft.Column([ + app_bar, + ft.Container( + content=zoomable_content, + expand=True, + padding=ft.Padding.all(16) + ), + ], expand=True) + + def back_to_list(self): + """一覧画面に戻る""" + self.current_tab = 0 + self.editing_invoice = None + self.update_main_content() + + def toggle_edit_mode(self): + """編集モードを切り替え""" + if hasattr(self, 'is_detail_edit_mode'): + self.is_detail_edit_mode = not self.is_detail_edit_mode + else: + self.is_detail_edit_mode = True + self.update_main_content() + + def _build_invoice_list(self) -> ft.Column: + """伝票リストを構築""" + logging.info("_build_invoice_list: 開始") + + # 履歴データ読み込み + slips = self.load_slips() + logging.info(f"伝票データ取得: {len(slips)}件") + + if not slips: + logging.info("_build_invoice_list: 伝票データなし、空のリストを返す") + return ft.Column([ + ft.Text("伝票データがありません", size=16, color=ft.Colors.GREY_600), + ft.Container(height=20), + ft.Text("データをインポートしてください", size=14, color=ft.Colors.GREY_500) + ], horizontal_alignment=ft.CrossAxisAlignment.CENTER) + + if not self.show_offsets: + slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))] + logging.info(f"赤伝除外後: {len(slips)}件") + + def on_toggle_offsets(e): + self.show_offsets = bool(e.control.value) + self.update_main_content() + + def on_verify_chain(_): + """チェーン検証""" + try: + result = self.app_service.invoice.verify_chain() + self.chain_verify_result = result + self.update_main_content() + except Exception as e: + logging.error(f"チェーン検証エラー: {e}") + + # 履歴カードリスト + slip_cards = [] + for i, slip in enumerate(slips): + logging.info(f"伝票{i}: {type(slip)}") + card = self.create_slip_card(slip) + slip_cards.append(card) + + logging.info(f"カード作成数: {len(slip_cards)}") + + return ft.Column([ + # 検証結果表示(あれば) + 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, + ), + ]) + def create_customer_picker_screen(self) -> ft.Container: + """顧客選択画面(画面内遷移・ダイアログ不使用)""" + self.customers = self.app_service.customer.get_all_customers() + + def back(_=None): + self.is_customer_picker_open = False + self.customer_search_query = "" + self.update_main_content() + + # AppBar(戻るボタンあり) + app_bar = AppBar( + title="顧客選択", + show_back=True, + show_edit=False, + on_back=back + ) + + list_container = ft.Column([], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True) + + def render_list(customers: List[Customer]): + list_container.controls.clear() + for customer in customers: + list_container.controls.append( + ft.ListTile( + title=ft.Text(customer.formal_name, weight=ft.FontWeight.BOLD), + subtitle=ft.Text(f"{customer.address}\n{customer.phone}"), + on_click=lambda _, c=customer: select_customer(c), + ) + ) + self.page.update() + + def select_customer(customer: Customer): + self.selected_customer = customer + # 新規伝票作成中の場合は顧客を設定 + if hasattr(self, 'editing_invoice') and self.editing_invoice: + self.editing_invoice.customer = customer + logging.info(f"伝票に顧客を設定: {customer.formal_name}") else: - message = f"アーカイブエラー: {result['error']}" - bgcolor = ft.colors.RED - - self.page.snack_bar = ft.SnackBar( - content=ft.Text(message), - bgcolor=bgcolor - ) - except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"アーカイブエラー: {str(ex)}"), - bgcolor=ft.Colors.RED - ) - - self.page.snack_bar.open = True - self.page.update() + logging.info(f"顧客を選択: {customer.formal_name}") + back() + + def on_search_change(e): + q = (e.control.value or "").strip().lower() + self.customer_search_query = q + if not q: + render_list(self.customers) + return + filtered = [ + c + for c in self.customers + if q in (c.name or "").lower() + or q in (c.formal_name or "").lower() + or q in (c.address or "").lower() + or q in (c.phone or "").lower() + ] + render_list(filtered) + + search_field = ft.TextField( + label="顧客検索", + prefix_icon=ft.Icons.SEARCH, + value=self.customer_search_query, + on_change=on_search_change, + autofocus=True, + ) + + header = ft.Container( + content=ft.Row( + [ + ft.IconButton(ft.Icons.ARROW_BACK, on_click=back), + ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD), + ft.Container(expand=True), + ft.IconButton( + ft.Icons.PERSON_ADD, + tooltip="新規顧客追加", + icon_color=ft.Colors.WHITE, + on_click=lambda _: self.open_new_customer_form(), + ), + ] + ), + padding=ft.padding.all(15), + bgcolor=ft.Colors.BLUE_GREY, + ) + + content = ft.Column( + [ + app_bar, # AppBarを追加 + ft.Container(content=search_field, padding=ft.padding.all(15)), + ft.Container(content=list_container, padding=ft.padding.symmetric(horizontal=15), expand=True), + ], + expand=True, + ) + + # 初期表示 + render_list(self.customers) + + return ft.Container(content=content, expand=True) - def generate_compliance_report(self, e): - """コンプライアンスレポートを生成""" + def create_slip_history_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() + + # 履歴カードリスト + 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=16, weight=ft.FontWeight.BOLD), # 文字を小さく + ft.Container(expand=True), + ft.Row([ + ft.IconButton( + ft.Icons.VERIFIED, + tooltip="チェーン検証", + icon_color=ft.Colors.BLUE_300, + icon_size=16, # アイコンを小さく + on_click=on_verify_chain, + ), + ft.Row( + [ + ft.Text("赤伝", size=10, color=ft.Colors.WHITE), # 文字を小さく + ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets), + ], + spacing=3, # 間隔を狭める + ), + ft.IconButton( + ft.Icons.CLEAR_ALL, + icon_size=16, # アイコンを小さく + ), + ], spacing=3), # 間隔を狭める + ]), + padding=ft.padding.all(8), # パディングを狭める + bgcolor=ft.Colors.BLUE_GREY, + ), + + # 検証結果表示(あれば) + ft.Container( + content=self._build_chain_verify_result(), + margin=ft.Margin.only(bottom=5), # 間隔を狭める + ) if self.chain_verify_result else ft.Container(height=0), + + # 履歴リスト(極限密度表示) + ft.Column( + controls=slip_cards, + spacing=0, # カード間隔を0pxに + scroll=ft.ScrollMode.AUTO, + expand=True, + ), + ]), + expand=True, + ) + + def can_create_offset_invoice(self, invoice: Invoice) -> bool: + """赤伝発行可能かチェック""" + # LOCK済み伝票であること + if not getattr(invoice, 'final_locked', False): + return False + + # すでに赤伝が存在しないこと try: - report = self.compliance.generate_compliance_report() - - # レポート内容を表示 - dialog = ft.AlertDialog( - title=ft.Text("コンプライアンスレポート"), + import sqlite3 + with sqlite3.connect('sales.db') as conn: + cursor = conn.cursor() + cursor.execute( + 'SELECT COUNT(*) FROM invoices WHERE offset_target_uuid = ?', + (invoice.uuid,) + ) + offset_count = cursor.fetchone()[0] + return offset_count == 0 + except Exception as e: + logging.error(f"赤伝存在チェックエラー: {e}") + return False + + def create_slip_card(self, slip) -> ft.Card: + """伝票カード作成""" + # サービス層からは Invoice オブジェクトが返る + if isinstance(slip, Invoice): + slip_type = slip.document_type.value + customer_name = slip.customer.formal_name + amount = slip.total_amount + date = slip.date.strftime("%Y-%m-%d %H:%M") + status = "赤伝" if getattr(slip, "is_offset", False) else "完了" + # 最初の商品名を取得(複数ある場合は「他」を付与) + if slip.items and len(slip.items) > 0: + first_item_name = slip.items[0].description + if len(slip.items) > 1: + first_item_name += "(他" + str(len(slip.items) - 1) + ")" + else: + first_item_name = "" + else: + slip_id, slip_type, customer_name, amount, date, status, description, created_at = slip + date = date.strftime("%Y-%m-%d %H:%M") + first_item_name = description or "" + + # タイプに応じたアイコンと色 + type_config = { + "売上伝票": {"icon": "💰", "color": ft.Colors.GREEN}, + "見積書": {"icon": "📄", "color": ft.Colors.BLUE}, + "納品書": {"icon": "📦", "color": ft.Colors.PURPLE}, + "請求書": {"icon": "📋", "color": ft.Colors.ORANGE}, + "領収書": {"icon": "🧾", "color": ft.Colors.RED} + } + + config = type_config.get(slip_type, {"icon": "📝", "color": ft.Colors.GREY}) + + def on_single_tap(_): + """シングルタップ:詳細表示""" + if isinstance(slip, Invoice): + self.open_invoice_detail(slip) + + def on_double_tap(_): + """ダブルタップ:編集モード切替""" + if isinstance(slip, Invoice): + self.open_invoice_edit(slip) + + def on_long_press(_): + """長押し:コンテキストメニュー""" + self.show_context_menu(slip) + + # 赤伝ボタンの表示条件チェック + show_offset_button = False + if isinstance(slip, Invoice): + show_offset_button = self.can_create_offset_invoice(slip) + + # 長押しメニューで操作するため、ボタンは不要 + + display_amount = amount + if isinstance(slip, Invoice) and getattr(slip, "is_offset", False): + display_amount = -abs(amount) + + return ft.GestureDetector( + content=ft.Card( content=ft.Container( content=ft.Column([ - ft.Text(f"生成日時: {report['generated_date']}"), - ft.Text(f"データベース: {report['database_path']}"), - ft.Divider(), - ft.Text("テーブル状況:", weight=ft.FontWeight.BOLD), - *[ft.Text(f" {table}: {info['record_count']}件") - for table, info in report['tables'].items()], - ft.Divider(), - ft.Text("監査ログ:", weight=ft.FontWeight.BOLD), - *[ft.Text(f" {op}: {count}件") - for op, count in report['audit_summary'].items()] - ], scroll=ft.ScrollMode.AUTO), - height=400 + ft.Row([ + ft.Container( + content=ft.Text(config["icon"], size=16), # アイコンを少し大きく + width=28, + height=28, + bgcolor=config["color"], + border_radius=14, + alignment=ft.alignment.Alignment(0, 0), + ), + ft.Container( + content=ft.Column([ + ft.Text(slip_type, size=7, weight=ft.FontWeight.BOLD), # タイプ文字をさらに小さく + ft.Text(customer_name, size=15, weight=ft.FontWeight.W_500), # 顧客名を1.5倍に + ft.Row([ + ft.Text(first_item_name, size=12, color=ft.Colors.GREY_600), # 商品名をさらに大きく + ft.Container(expand=True), # スペースを取る + ft.Text(f"¥{display_amount:,.0f}", size=11, weight=ft.FontWeight.BOLD), # 金額を右寄せ + ]), + ], + spacing=0, # 行間を最小化 + tight=True, # 余白を最小化 + ), + expand=True, + ), + ]), + ft.Container(height=0), # 間隔を完全に削除 + ft.Row([ + ft.Text(f"{date} | {status}", size=9, color=ft.Colors.GREY_600), # 日付を大きく + ft.Container(expand=True), # スペースを取る + # 赤伝ボタン(条件付きで表示) + ft.Container( + content=ft.IconButton( + icon=ft.icons.REMOVE_CIRCLE_OUTLINE, + icon_color=ft.Colors.RED_500, + icon_size=16, + tooltip="赤伝発行", + on_click=lambda _: self.create_offset_invoice_dialog(slip), + disabled=not show_offset_button + ) if show_offset_button else ft.Container(width=20), + ), + ]), + ], + spacing=0, # カラムの行間を最小化 + tight=True, # 余白を最小化 + ), + padding=ft.padding.all(2), # パディングを最小化 ), - actions=[ - ft.TextButton("閉じる", on_click=lambda e: self.close_dialog(dialog)) - ] + elevation=0, + ), + on_tap=on_single_tap, + on_double_tap=on_double_tap, + on_long_press=on_long_press, + ) + + def create_offset_invoice_dialog(self, invoice: Invoice): + """赤伝発行確認ダイアログ""" + def close_dialog(_): + self.dialog.open = False + self.update_main_content() + + def confirm_create_offset(_): + # 赤伝を発行 + offset_invoice = self.app_service.invoice.create_offset_invoice( + invoice.uuid, + f"相殺伝票: {invoice.invoice_number}" + ) + if offset_invoice: + logging.info(f"赤伝発行成功: {offset_invoice.invoice_number}") + # 一覧を更新 + self.invoices = self.app_service.invoice.get_recent_invoices(20) + self.update_main_content() + else: + logging.error(f"赤伝発行失敗: {invoice.invoice_number}") + close_dialog(_) + + # 確認ダイアログ + self.dialog = ft.AlertDialog( + modal=True, + title=ft.Text("赤伝発行確認"), + content=ft.Column([ + ft.Text(f"以下の伝票の赤伝を発行します。"), + ft.Container(height=10), + ft.Text(f"伝票番号: {invoice.invoice_number}"), + ft.Text(f"顧客: {invoice.customer.formal_name}"), + ft.Text(f"金額: ¥{invoice.total_amount:,.0f}"), + ft.Container(height=10), + ft.Text("赤伝発行後は取り消せません。よろしいですか?", + color=ft.Colors.RED, weight=ft.FontWeight.BOLD), + ], tight=True), + actions=[ + ft.TextButton("キャンセル", on_click=close_dialog), + ft.ElevatedButton( + "赤伝発行", + on_click=confirm_create_offset, + style=ft.ButtonStyle( + color=ft.Colors.WHITE, + bgcolor=ft.Colors.RED_500 + ) + ), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.dialog.open = True + self.update_main_content() + + def show_context_menu(self, slip): + """コンテキストメニューを表示""" + if not isinstance(slip, Invoice): + return + + def close_dialog(_): + self.dialog.open = False + self.update_main_content() + + def edit_invoice(_): + self.open_invoice_edit(slip) + close_dialog(_) + + def delete_invoice(_): + self.delete_invoice(slip.uuid) + close_dialog(_) + + def create_offset(_): + self.create_offset_invoice_dialog(slip) + close_dialog(_) + + # メニューアイテムの構築 + menu_items = [] + + # 編集メニュー + if not getattr(slip, 'final_locked', False): + menu_items.append( + ft.PopupMenuItem( + text=ft.Row([ + ft.Icon(ft.Icons.EDIT, size=16), + ft.Text("編集", size=14), + ], spacing=8), + on_click=edit_invoice + ) + ) + + # 赤伝発行メニュー + if self.can_create_offset_invoice(slip): + menu_items.append( + ft.PopupMenuItem( + text=ft.Row([ + ft.Icon(ft.Icons.REMOVE_CIRCLE, size=16), + ft.Text("赤伝発行", size=14), + ], spacing=8), + on_click=create_offset + ) + ) + + # 削除メニュー + menu_items.append( + ft.PopupMenuItem( + text=ft.Row([ + ft.Icon(ft.Icons.DELETE, size=16), + ft.Text("削除", size=14), + ], spacing=8), + on_click=delete_invoice + ) + ) + + # コンテキストメニューダイアログ + self.dialog = ft.AlertDialog( + modal=True, + title=ft.Text(f"操作選択: {slip.invoice_number}"), + content=ft.Column(menu_items, tight=True, spacing=2), + actions=[ + ft.TextButton("キャンセル", on_click=close_dialog), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.dialog.open = True + self.update_main_content() + + def open_invoice_detail(self, invoice: Invoice): + """伝票詳細を開く""" + self.editing_invoice = invoice + self.current_tab = 1 + self.is_detail_edit_mode = False # 表示モードで開く + self.update_main_content() + + def open_invoice_edit(self, invoice: Invoice): + """伝票編集を開く""" + self.editing_invoice = invoice + self.current_tab = 1 + self.is_detail_edit_mode = True # 編集モードで開く + self.update_main_content() + + def delete_invoice(self, invoice_uuid: str): + """伝票を削除""" + try: + success = self.app_service.invoice.delete_invoice_by_uuid(invoice_uuid) + if success: + logging.info(f"伝票削除成功: {invoice_uuid}") + # リストを更新 + self.invoices = self.app_service.invoice.get_recent_invoices(20) + self.update_main_content() + else: + logging.error(f"伝票削除失敗: {invoice_uuid}") + except Exception as e: + logging.error(f"伝票削除エラー: {e}") + + def _build_chain_verify_result(self) -> ft.Control: + if not self.chain_verify_result: + return ft.Container(height=0) + r = self.chain_verify_result + ok = r.get("ok", False) + checked = r.get("checked", 0) + errors = r.get("errors", []) + if ok: + return ft.Container( + content=ft.Row([ + ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=20), + ft.Text(f"チェーン検証 OK ({checked}件)", size=14, color=ft.Colors.GREEN), + ]), + bgcolor=ft.Colors.GREEN_50, + padding=ft.Padding.all(10), + border_radius=8, + ) + else: + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED, size=20), + ft.Text(f"チェーン検証 NG (checked={checked})", size=14, color=ft.Colors.RED), + ]), + ft.Text(f"エラー: {errors}", size=12, color=ft.Colors.RED_700), + ]), + bgcolor=ft.Colors.RED_50, + padding=ft.Padding.all(10), + 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 not self.editing_invoice: + # 新規伝票の場合は空のInvoiceを作成 + from models.invoice_models import Invoice, Customer, DocumentType + default_customer = Customer( + id=0, + name="選択してください", + formal_name="選択してください", + address="", + phone="" + ) + self.editing_invoice = Invoice( + customer=default_customer, + date=datetime.now(), + items=[], + document_type=DocumentType.SALES, + invoice_number="NEW-" + str(int(datetime.now().timestamp())) # 新規伝票番号 + ) + self.is_detail_edit_mode = True # 新規作成モード + + # 既存・新規共通で詳細編集画面を返す + return self._create_edit_existing_screen() + + def _create_edit_existing_screen(self) -> ft.Container: + """既存伝票の編集画面(新規・編集共通)""" + # 編集不可チェック(新規作成時はFalse) + is_new_invoice = self.editing_invoice.invoice_number.startswith("NEW-") + + # LOCK条件:明示的に確定された場合のみLOCK + # PDF生成だけではLOCKしない(お試しPDFを許可) + pdf_generated = getattr(self.editing_invoice, 'pdf_generated_at', None) is not None + chain_hash = getattr(self.editing_invoice, 'chain_hash', None) is not None + final_locked = getattr(self.editing_invoice, 'final_locked', False) # 明示的確定フラグ + + is_locked = final_locked if not is_new_invoice else False + is_view_mode = not getattr(self, 'is_detail_edit_mode', False) + + # デバッグ用にLOCK状態をログ出力 + logging.info(f"伝票LOCK状態: {self.editing_invoice.invoice_number}") + logging.info(f" PDF生成: {pdf_generated}") + logging.info(f" チェーン収容: {chain_hash}") + logging.info(f" 明示的確定: {final_locked}") + logging.info(f" LOCK状態: {is_locked}") + logging.info(f" 新規伝票: {is_new_invoice}") + logging.info(f" 編集モード: {getattr(self, 'is_detail_edit_mode', False)}") + logging.info(f" 表示モード: {is_view_mode}") + + # 伝票種類選択(ヘッダーに移動) + document_types = list(DocumentType) + + # 明細テーブル + if is_view_mode: + items_table = self._create_view_mode_table(self.editing_invoice.items) + else: + items_table = self._create_edit_mode_table(self.editing_invoice.items, is_locked) + + # 顧客表示・選択 + def select_customer(): + """顧客選択画面を開く""" + self.current_tab = 2 # 顧客選択タブ + self.update_main_content() + + # 編集モード時は顧客名入力フィールドを表示 + if not is_view_mode and not is_locked: + # 編集モード:顧客名入力フィールド + customer_field = ft.TextField( + label="顧客名", + value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", + disabled=is_locked, + width=300, ) - self.page.dialog = dialog - dialog.open = True - self.page.update() + def update_customer_name(e): + """顧客名を更新""" + if self.editing_invoice: + # 既存顧客を検索、なければ新規作成 + customer_name = e.control.value or "" + found_customer = None + for customer in self.app_service.customer.get_all_customers(): + if customer.name == customer_name or customer.formal_name == customer_name: + found_customer = customer + break + + if found_customer: + self.editing_invoice.customer = found_customer + else: + # 新規顧客を作成 + from models.invoice_models import Customer + self.editing_invoice.customer = Customer( + id=0, + name=customer_name, + formal_name=customer_name, + address="", + phone="" + ) - except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"レポート生成エラー: {str(ex)}"), - bgcolor=ft.Colors.RED + customer_field.on_change = update_customer_name + + customer_display = ft.Container( + content=ft.Row([ + customer_field, # 顧客ラベルを削除 + ft.ElevatedButton( + "選択", + icon=ft.Icons.PERSON_SEARCH, + on_click=lambda _: select_customer(), + style=ft.ButtonStyle( + bgcolor=ft.Colors.BLUE_600, + color=ft.Colors.WHITE + ) + ), + ]), + padding=ft.padding.symmetric(horizontal=10, vertical=5), + bgcolor=ft.Colors.BLUE_50, + border_radius=5, ) - self.page.snack_bar.open = True - self.page.update() + elif is_new_invoice: + # 新規作成時も同じ表示 + customer_field = ft.TextField( + label="顧客名", + value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", + disabled=is_locked, + width=300, + ) + + def update_customer_name(e): + """顧客名を更新""" + if self.editing_invoice: + # 既存顧客を検索、なければ新規作成 + customer_name = e.control.value or "" + found_customer = None + for customer in self.app_service.customer.get_all_customers(): + if customer.name == customer_name or customer.formal_name == customer_name: + found_customer = customer + break + + if found_customer: + self.editing_invoice.customer = found_customer + else: + # 新規顧客を作成 + from models.invoice_models import Customer + self.editing_invoice.customer = Customer( + id=0, + name=customer_name, + formal_name=customer_name, + address="", + phone="" + ) + + customer_field.on_change = update_customer_name + + customer_display = ft.Container( + content=ft.Row([ + customer_field, # 顧客ラベルを削除 + ft.ElevatedButton( + "選択", + icon=ft.Icons.PERSON_SEARCH, + on_click=lambda _: select_customer(), + style=ft.ButtonStyle( + bgcolor=ft.Colors.BLUE_600, + color=ft.Colors.WHITE + ) + ), + ]), + padding=ft.padding.symmetric(horizontal=10, vertical=5), + bgcolor=ft.Colors.BLUE_50, + border_radius=5, + ) + else: + # 既存伝票は表示のみ + customer_display = ft.Container( + content=ft.Row([ + 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 + + # 伝票を更新(現在の明細を保持) + # テーブルから実際の値を取得して更新 + self.editing_invoice.notes = notes_field.value + self.editing_invoice.document_type = self.selected_document_type + + # TODO: テーブルの明細データを取得して更新 + # 現在は編集された明細データが反映されていない + logging.info(f"更新前明細件数: {len(self.editing_invoice.items)}") + for i, item in enumerate(self.editing_invoice.items): + logging.info(f" 明細{i+1}: {item.description} x{item.quantity} @¥{item.unit_price}") + + # DBに保存(新規・更新共通) + try: + if is_new_invoice: + # 新規作成 + logging.info(f"=== 新規伝票作成開 ===") + logging.info(f"顧客情報: {self.editing_invoice.customer.name} (ID: {self.editing_invoice.customer.id})") + logging.info(f"伝票種類: {self.editing_invoice.document_type.value}") + logging.info(f"明細件数: {len(self.editing_invoice.items)}") + + # 顧客を先にDBに保存(新規顧客の場合) + if self.editing_invoice.customer.id == 0: + logging.info(f"新規顧客をDBに保存します: {self.editing_invoice.customer.name}") + # 新規顧客をDBに保存 + customer_id = self.app_service.customer.create_customer( + name=self.editing_invoice.customer.name, + formal_name=self.editing_invoice.customer.formal_name, + address=self.editing_invoice.customer.address, + phone=self.editing_invoice.customer.phone + ) + logging.info(f"create_customer戻り値: {customer_id}") + if customer_id > 0: # IDが正しく取得できたかチェック + self.editing_invoice.customer.id = customer_id + logging.info(f"新規顧客をDBに保存: {self.editing_invoice.customer.name} (ID: {customer_id})") + else: + logging.error(f"顧客保存失敗: {self.editing_invoice.customer.name}") + return + + # 合計金額は表示時に計算するため、DBには保存しない + amount = 0 # ダミー値(実際は表示時に計算) + + logging.info(f"伝票作成パラメータ: customer.id={self.editing_invoice.customer.id}, document_type={self.editing_invoice.document_type}, amount={amount}") + + success = self.app_service.invoice.create_invoice( + customer=self.editing_invoice.customer, + document_type=self.editing_invoice.document_type, + amount=amount, + notes=getattr(self.editing_invoice, 'notes', ''), + items=self.editing_invoice.items # UIの明細を渡す + ) + logging.info(f"create_invoice戻り値: {success}") + if success: + logging.info(f"伝票作成成功: {self.editing_invoice.invoice_number}") + # 一覧を更新して新規作成画面を閉じる + self.invoices = self.app_service.invoice.get_recent_invoices(20) + logging.info(f"更新後伝票件数: {len(self.invoices)}") + self.editing_invoice = None + self.current_tab = 0 # 一覧タブに戻る + self.update_main_content() + else: + logging.error(f"伝票作成失敗: {self.editing_invoice.invoice_number}") + else: + # 更新 + logging.info(f"=== 伝票更新開 ===") + success = self.app_service.invoice.update_invoice(self.editing_invoice) + if success: + logging.info(f"伝票更新成功: {self.editing_invoice.invoice_number}") + # 一覧を更新して編集画面を閉じる + self.invoices = self.app_service.invoice.get_recent_invoices(20) + self.editing_invoice = None + self.current_tab = 0 # 一覧タブに戻る + self.update_main_content() + else: + logging.error(f"伝票更新失敗: {self.editing_invoice.invoice_number}") + except Exception as e: + logging.error(f"伝票保存エラー: {e}") + import traceback + logging.error(f"詳細エラー: {traceback.format_exc()}") + + # 編集モード終了(ビューモードに戻る) + 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 %H:%M')} | {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, + ), + + # 顧客名入力(編集モードまたは新規作成時) + customer_display if (not is_view_mode and not is_locked) or is_new_invoice else ft.Container(height=0), + + # 明細テーブル(フレキシブルに) + ft.Container( + content=ft.Column([ + ft.Container( + content=items_table, + height=400, # 高さを拡大して見やすく + 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.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 + ), + 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(), + ]), + padding=ft.padding.symmetric(horizontal=5, 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=0, + unit_price=0, + ) + + # 元のinvoice.itemsに直接追加 + self.editing_invoice.items.append(new_item) + + # UIを更新 + 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}") + + # 合計金額を更新するために画面を更新 + # 空行削除ロジック:商品名が無く数量も単価も0なら削除 + self._remove_empty_items() + self.update_main_content() + + def _remove_empty_items(self): + """商品名が無く数量も単価も0の明細を削除""" + if not self.editing_invoice: + return + + # 空行を特定(ただし最低1行は残す) + non_empty_items = [] + empty_count = 0 + + for item in self.editing_invoice.items: + if (not item.description or item.description.strip() == "") and \ + item.quantity == 0 and \ + item.unit_price == 0: + empty_count += 1 + # 最低1行は残すため、空行が複数ある場合のみ削除 + if empty_count > 1: + continue # 削除 + non_empty_items.append(item) + + self.editing_invoice.items = non_empty_items + + 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: + """編集モード:フレキシブルな表形式""" + # 自動空行追加を無効化(ユーザーが明示的に追加する場合のみ) + # TODO: 必要に応じて空行追加ボタンを提供 + + # 空行の自動追加を無効化 + pass + + # ヘッダー行 + 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, idx=i: self._update_item_field(idx, '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, idx=i: self._update_item_field(idx, '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, idx=i: self._update_item_field(idx, '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="顧客名(略称)") + formal_name_field = ft.TextField(label="正式名称") + address_field = ft.TextField(label="住所") + phone_field = ft.TextField(label="電話番号") + + def save_customer(_): + name = (name_field.value or "").strip() + formal_name = (formal_name_field.value or "").strip() + address = (address_field.value or "").strip() + phone = (phone_field.value or "").strip() + if not name or not formal_name: + # TODO: エラー表示 + return + new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone) + if new_customer: + self.customers = self.app_service.customer.get_all_customers() + self.selected_customer = new_customer + logging.info(f"新規顧客登録: {new_customer.formal_name}") + self.is_customer_picker_open = False + self.is_new_customer_form_open = False + self.update_main_content() + else: + logging.error("新規顧客登録失敗") + + def cancel(_): + self.is_new_customer_form_open = False + self.update_main_content() + + return ft.Container( + content=ft.Column([ + ft.Container( + content=ft.Row([ + ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel), + ft.Text("新規顧客登録", size=18, weight=ft.FontWeight.BOLD), + ]), + padding=ft.padding.all(15), + bgcolor=ft.Colors.BLUE_GREY, + ), + ft.Container( + content=ft.Column([ + ft.Text("顧客情報を入力", size=16, weight=ft.FontWeight.BOLD), + ft.Container(height=10), + name_field, + ft.Container(height=10), + formal_name_field, + ft.Container(height=10), + address_field, + ft.Container(height=10), + phone_field, + ft.Container(height=20), + ft.Row([ + ft.Button("保存", on_click=save_customer, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE), + ft.Button("キャンセル", on_click=cancel), + ], spacing=10), + ]), + padding=ft.padding.all(20), + expand=True, + ), + ]), + expand=True, + ) + + def open_customer_picker(self, e=None): + """顧客選択を開く(画面内遷移)""" + logging.info("顧客選択画面へ遷移") + self.is_customer_picker_open = True + self.update_main_content() + + def on_customer_selected(self, customer: Customer): + """顧客選択時の処理""" + self.selected_customer = customer + logging.info(f"顧客を選択: {customer.formal_name}") + + # 編集中の伝票があれば顧客を設定 + if self.editing_invoice: + self.editing_invoice.customer = customer + logging.info(f"編集中伝票に顧客を設定: {customer.formal_name}") + + # 顧客選択画面を閉じて元の画面に戻る + self.is_customer_picker_open = False + self.customer_search_query = "" + 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) + self.customers = self.app_service.customer.get_all_customers() + logging.info(f"顧客を削除: {customer.formal_name}") + # モーダルを再表示してリストを更新 + if self.customer_picker and self.customer_picker.is_open: + self.customer_picker.update_customer_list(self.customers) + + def on_amount_change(self, e): + """金額変更時の処理""" + self.amount_value = e.control.value + logging.info(f"金額を変更: {self.amount_value}") + + def on_document_type_change(self, index): + """帳票種類変更""" + document_types = list(DocumentType) + selected_type = document_types[index] + logging.info(f"帳票種類を変更: {selected_type.value}") + # TODO: 選択された種類を保存 + + def select_document_type(self, doc_type: str): + """帳票種類選択""" + # DocumentTypeから対応するenumを見つける + for dt in DocumentType: + if dt.value == doc_type: + self.selected_document_type = dt + logging.info(f"帳票種類を選択: {doc_type}") + self.update_main_content() + break + + def create_slip(self, e=None): + """伝票作成 - サービス層を使用""" + if not self.selected_customer: + logging.warning("顧客が選択されていません") + return + + try: + amount = int(self.amount_value) if self.amount_value else 250000 + except ValueError: + amount = 250000 + + logging.info(f"伝票を作成: {self.selected_document_type.value}, {self.selected_customer.formal_name}, ¥{amount:,}") + + # サービス層経由で伝票作成 + invoice = self.app_service.invoice.create_invoice( + customer=self.selected_customer, + document_type=self.selected_document_type, + amount=amount, + notes="" + ) + + if invoice: + if invoice.file_path: + self.app_service.invoice.delete_pdf_file(invoice.file_path) + invoice.file_path = None + logging.info(f"伝票作成成功: {invoice.invoice_number}") + # リストを更新 + self.invoices = self.app_service.invoice.get_recent_invoices(20) + # 発行履歴タブに切り替え + self.on_tab_change(1) + else: + logging.error("伝票作成失敗") + + def load_slips(self) -> List[Invoice]: + """伝票データ読み込み - サービス層経由""" + return self.app_service.invoice.get_recent_invoices(20) def main(page: ft.Page): - app = SalesAssistant(page) + """メイン関数""" + app = FlutterStyleDashboard(page) + page.update() if __name__ == "__main__": - ft.app(target=main) + import flet as ft + ft.run(main, port=8550) diff --git a/scripts/auto_recover_and_build.sh b/scripts/auto_recover_and_build.sh new file mode 100644 index 0000000..997e549 --- /dev/null +++ b/scripts/auto_recover_and_build.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# bash scripts/auto_recover_and_build.sh [PROJECT_DIR] +# Default PROJECT_DIR is current working directory. + +PROJECT_DIR="${1:-$(pwd)}" +PROJECT_DIR="$(realpath "$PROJECT_DIR")" +TS="$(date +%Y%m%d_%H%M%S)" + +if [[ ! -f "$PROJECT_DIR/main.py" ]]; then + echo "ERROR: main.py not found under PROJECT_DIR: $PROJECT_DIR" >&2 + exit 1 +fi + +BACKUP_DIR="${PROJECT_DIR}.backup_${TS}" +TRASH_DIR="$PROJECT_DIR/trash/${TS}" + +printf "[1/7] Backup project -> %s\n" "$BACKUP_DIR" +cp -a "$PROJECT_DIR" "$BACKUP_DIR" + +printf "[2/7] Quarantine noisy/generated files -> %s\n" "$TRASH_DIR" +mkdir -p "$TRASH_DIR" + +# Candidate directories to quarantine (safe move, no delete) +for d in \ + apk_test \ + helloworld \ + build \ + dist \ + flutter.参考 \ + __pycache__ \ + .pytest_cache \ + .mypy_cache + do + if [[ -e "$PROJECT_DIR/$d" ]]; then + mv "$PROJECT_DIR/$d" "$TRASH_DIR/" + fi +done + +# Candidate files to quarantine (safe move, no delete) +shopt -s nullglob +for f in \ + "$PROJECT_DIR"/app_*.py \ + "$PROJECT_DIR"/test_*.py \ + "$PROJECT_DIR"/debug_test.py \ + "$PROJECT_DIR"/run_*.py \ + "$PROJECT_DIR"/main_simple.py \ + "$PROJECT_DIR"/minimal.py \ + "$PROJECT_DIR"/*.spec + do + if [[ -e "$f" ]]; then + mv "$f" "$TRASH_DIR/" + fi +done +shopt -u nullglob + +printf "[3/7] Ensure .gitignore has generated-file rules\n" +GITIGNORE="$PROJECT_DIR/.gitignore" +touch "$GITIGNORE" +append_if_missing() { + local line="$1" + grep -Fqx "$line" "$GITIGNORE" || echo "$line" >> "$GITIGNORE" +} + +append_if_missing "" +append_if_missing "# build/cache" +append_if_missing "build/" +append_if_missing "dist/" +append_if_missing "__pycache__/" +append_if_missing "*.pyc" +append_if_missing "" +append_if_missing "# generated" +append_if_missing "generated_pdfs/" +append_if_missing "audit_export/" +append_if_missing "trash/" +append_if_missing "" +append_if_missing "# experiments" +append_if_missing "apk_test/" +append_if_missing "helloworld/" + +printf "[4/7] Initialize git baseline if needed\n" +if [[ ! -d "$PROJECT_DIR/.git" ]]; then + git -C "$PROJECT_DIR" init +fi + +printf "[5/7] Stage core project files\n" +# Stage only the likely core set; ignore errors for missing paths. +git -C "$PROJECT_DIR" add \ + .gitignore \ + README.md \ + requirements.txt \ + main.py \ + models \ + services \ + components \ + sales.db \ + sales_assist.db \ + scripts || true + +if ! git -C "$PROJECT_DIR" diff --cached --quiet; then + git -C "$PROJECT_DIR" commit -m "baseline: quarantine generated files and stabilize project" +else + printf "No staged changes to commit.\n" +fi + +printf "[6/7] Optional runtime check (python main.py)\n" +printf "Skipped by default. Run manually if needed:\n" +printf " source %s/.venv/bin/activate && python %s/main.py\n" "$PROJECT_DIR" "$PROJECT_DIR" + +printf "[7/7] Optional APK build check\n" +printf "Run manually once your Flet build config is ready:\n" +printf " source %s/.venv/bin/activate && flet build apk %s\n" "$PROJECT_DIR" "$PROJECT_DIR" + +printf "Done.\n" +printf "Backup: %s\n" "$BACKUP_DIR" +printf "Quarantine: %s\n" "$TRASH_DIR" diff --git a/services/repositories.py b/services/repositories.py index 809630c..511aab9 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -464,6 +464,7 @@ class InvoiceRepository: """DB行をInvoiceオブジェクトに変換""" try: invoice_id = row[0] + logging.info(f"変換開始: invoice_id={invoice_id}, row_length={len(row)}") # 明細取得 cursor.execute(''' @@ -484,11 +485,11 @@ class InvoiceRepository: # 顧客情報 customer = Customer( - id=row[15] or 0, # customer_idフィールド - name=row[3], # customer_nameフィールド - formal_name=row[3], # customer_nameフィールド - address=row[4] or "", # customer_addressフィールド - phone=row[5] or "" # customer_phoneフィールド + id=row[3] or 0, # customer_idフィールド + name=row[4], # customer_nameフィールド + formal_name=row[4], # customer_nameフィールド + address=row[5] or "", # customer_addressフィールド + phone=row[6] or "" # customer_phoneフィールド ) # 伝票タイプ @@ -498,13 +499,18 @@ class InvoiceRepository: doc_type = dt break + # 日付変換 + date_str = row[10] # 正しいdateフィールドのインデックス + logging.info(f"日付変換: {date_str}") + date_obj = datetime.fromisoformat(date_str) + inv = Invoice( customer=customer, - date=datetime.fromisoformat(row[9]), # 正しいdateフィールドのインデックス + date=date_obj, items=items, - file_path=row[12], - invoice_number=row[10] or "", # 正しいinvoice_numberフィールドのインデックス - notes=row[11], # 正しいnotesフィールドのインデックス + file_path=row[13], # 正しいfile_pathフィールドのインデックス + invoice_number=row[11] or "", # 正しいinvoice_numberフィールドのインデックス + notes=row[12], # 正しいnotesフィールドのインデックス document_type=doc_type, uuid=row[1], ) @@ -522,10 +528,11 @@ class InvoiceRepository: inv.offset_target_uuid = row[22] inv.pdf_generated_at = row[23] inv.pdf_sha256 = row[24] - inv.submitted_to_tax_authority = bool(row[25]) + inv.submitted_to_tax_authority = bool(row[27]) except Exception: pass + logging.info(f"変換成功: {inv.invoice_number}") return inv except Exception as e: