1831 lines
77 KiB
Python
1831 lines
77 KiB
Python
"""
|
||
Flutter風ダッシュボード
|
||
下部ナビゲーションと洗練されたUIコンポーネントを実装
|
||
"""
|
||
|
||
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
|
||
|
||
# Fletのライフサイクルに任せる(SystemExitがasyncioに伝播して警告になりやすい)
|
||
|
||
def setup_database(self):
|
||
"""データ初期化(サービス層経由)"""
|
||
try:
|
||
# 顧客データ読み込み
|
||
self.customers = self.app_service.customer.get_all_customers()
|
||
|
||
# 伝票データ読み込み
|
||
self.invoices = self.app_service.invoice.get_recent_invoices(20)
|
||
|
||
logging.info(f"データ初期化: 顧客{len(self.customers)}件, 伝票{len(self.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.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 update_main_content(self):
|
||
"""メインコンテンツ更新"""
|
||
self.main_content.controls.clear()
|
||
|
||
if self.is_customer_picker_open:
|
||
# 顧客選択画面
|
||
self.main_content.controls.append(self.create_customer_picker_screen())
|
||
elif self.current_tab == 0:
|
||
# 伝票一覧画面
|
||
self.main_content.controls.append(self._build_invoice_list_screen())
|
||
elif self.current_tab == 1:
|
||
# 伝票詳細/編集画面
|
||
self.main_content.controls.append(self._build_invoice_detail_screen())
|
||
elif self.current_tab == 2:
|
||
# 新規作成画面
|
||
self.main_content.controls.append(self._build_new_invoice_screen())
|
||
|
||
self.page.update()
|
||
|
||
def _build_invoice_list_screen(self) -> ft.Column:
|
||
"""伝票一覧画面を構築"""
|
||
# AppBar(戻るボタンなし、編集ボタンなし)
|
||
app_bar = AppBar(
|
||
title="伝票一覧",
|
||
show_back=False,
|
||
show_edit=False
|
||
)
|
||
|
||
# 伝票リスト(ズーム対応)
|
||
invoice_list = self._build_invoice_list()
|
||
zoomable_list = ZoomableContainer(
|
||
content=invoice_list,
|
||
min_scale=0.8,
|
||
max_scale=2.5
|
||
)
|
||
|
||
# 浮動アクションボタン
|
||
fab = ft.FloatingActionButton(
|
||
icon=ft.Icons.ADD,
|
||
on_click=lambda _: self.open_new_invoice(),
|
||
tooltip="新規伝票作成"
|
||
)
|
||
|
||
return ft.Column([
|
||
app_bar,
|
||
ft.Container(
|
||
content=zoomable_list,
|
||
expand=True,
|
||
padding=ft.Padding.all(16)
|
||
),
|
||
], expand=True)
|
||
|
||
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 _build_new_invoice_screen(self) -> ft.Column:
|
||
"""新規作成画面を構築"""
|
||
# AppBar(戻るボタンあり、編集ボタンなし)
|
||
app_bar = AppBar(
|
||
title="新規伝票作成",
|
||
show_back=True,
|
||
show_edit=False,
|
||
on_back=lambda _: self.back_to_list()
|
||
)
|
||
|
||
# 新規作成コンテンツ(ズーム対応)
|
||
new_content = self.create_slip_input_screen()
|
||
zoomable_content = ZoomableContainer(
|
||
content=new_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:
|
||
"""伝票リストを構築"""
|
||
# 履歴データ読み込み
|
||
slips = self.load_slips()
|
||
logging.info(f"伝票データ取得: {len(slips)}件")
|
||
|
||
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:
|
||
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 save_as_draft(self, _):
|
||
"""下書きとして保存"""
|
||
try:
|
||
# 下書き伝票を作成
|
||
draft_invoice = self.app_service.invoice.create_draft_invoice(
|
||
customer=self.selected_customer,
|
||
document_type=DocumentType.DRAFT,
|
||
amount=int(self.amount_value or "0"),
|
||
notes="下書き"
|
||
)
|
||
|
||
if draft_invoice:
|
||
logging.info(f"下書き保存成功: {draft_invoice.invoice_number}")
|
||
# 一覧を更新
|
||
self.invoices = self.app_service.invoice.get_recent_invoices(20)
|
||
self.back_to_list()
|
||
else:
|
||
logging.error("下書き保存失敗")
|
||
except Exception as e:
|
||
logging.error(f"下書き保存エラー: {e}")
|
||
|
||
def create_slip_input_screen(self) -> ft.Container:
|
||
"""伝票入力画面"""
|
||
# 帳票種類選択(タブ風ボタン)
|
||
document_types = list(DocumentType)
|
||
|
||
type_buttons = ft.Row([
|
||
ft.Container(
|
||
content=ft.Button(
|
||
content=ft.Text(doc_type.value, size=12),
|
||
bgcolor=ft.Colors.BLUE_GREY_800 if doc_type == self.selected_document_type else ft.Colors.GREY_300,
|
||
color=ft.Colors.WHITE if doc_type == self.selected_document_type else ft.Colors.BLACK,
|
||
on_click=lambda _, idx=i, dt=doc_type: self.select_document_type(dt.value),
|
||
width=100,
|
||
height=45,
|
||
),
|
||
margin=ft.margin.only(right=5),
|
||
) for i, doc_type in enumerate(document_types)
|
||
], wrap=True)
|
||
|
||
# 下書きボタン(新規作成時のみ表示)
|
||
draft_button = ft.Container(
|
||
content=ft.ElevatedButton(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.DRAFTS, size=16),
|
||
ft.Text("下書きとして保存", size=12),
|
||
], spacing=5),
|
||
style=ft.ButtonStyle(
|
||
bgcolor=ft.Colors.ORANGE_500,
|
||
color=ft.Colors.WHITE,
|
||
),
|
||
on_click=self.save_as_draft,
|
||
),
|
||
margin=ft.margin.only(top=10),
|
||
)
|
||
|
||
return ft.Container(
|
||
content=ft.Column([
|
||
# ヘッダー
|
||
ft.Container(
|
||
content=ft.Row([
|
||
ft.Text("📋 販売アシスト1号", size=20, weight=ft.FontWeight.BOLD),
|
||
ft.Container(expand=True),
|
||
ft.IconButton(ft.Icons.SETTINGS, icon_size=20),
|
||
]),
|
||
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),
|
||
type_buttons,
|
||
draft_button, # 下書きボタンを追加
|
||
]),
|
||
padding=ft.padding.all(20),
|
||
),
|
||
|
||
# 顧客選択
|
||
ft.Container(
|
||
content=ft.Column([
|
||
ft.Text("宛先と基本金額の設定", size=16, weight=ft.FontWeight.BOLD),
|
||
ft.Container(height=10),
|
||
ft.Row(
|
||
[
|
||
ft.TextField(
|
||
label="取引先名",
|
||
value=self.selected_customer.formal_name if self.selected_customer else "未選択",
|
||
read_only=True,
|
||
border_color=ft.Colors.BLUE_GREY,
|
||
expand=True,
|
||
),
|
||
ft.Container(width=8), # スペースを追加
|
||
ft.IconButton(
|
||
ft.Icons.PERSON_SEARCH,
|
||
tooltip="顧客を選択",
|
||
on_click=self.open_customer_picker,
|
||
),
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN, # 検索ボタンを右端に配置
|
||
),
|
||
ft.Container(height=10),
|
||
ft.TextField(
|
||
label="基本金額 (税抜)",
|
||
value=self.amount_value,
|
||
keyboard_type=ft.KeyboardType.NUMBER,
|
||
on_change=self.on_amount_change,
|
||
border_color=ft.Colors.BLUE_GREY,
|
||
),
|
||
]),
|
||
padding=ft.padding.all(20),
|
||
),
|
||
|
||
# 作成ボタン
|
||
ft.Container(
|
||
content=ft.Button(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.DESCRIPTION, 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=self.create_slip,
|
||
),
|
||
padding=ft.padding.all(20),
|
||
),
|
||
|
||
]),
|
||
expand=True,
|
||
)
|
||
|
||
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:
|
||
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.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), # パディングを最小化
|
||
),
|
||
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,
|
||
)
|
||
|
||
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,
|
||
)
|
||
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 = FlutterStyleDashboard(page)
|
||
page.update()
|
||
|
||
if __name__ == "__main__":
|
||
ft.app(target=main)
|