h-1.flet.3/main.py
2026-02-23 17:44:15 +09:00

1958 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Flutter風ダッシュボード
下部ナビゲーションと洗練されたUIコンポーネントを実装
"""
import flet as ft
import signal
import sys
import logging
import sqlite3
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 components.explorer_framework import (
ExplorerQueryState,
EXPLORER_PERIODS,
EXPLORER_SORTS,
to_date_range,
)
from components.editor_framework import normalize_invoice_items, validate_invoice_items
from components.editor_framework import (
build_invoice_items_view_table,
build_invoice_items_edit_table,
)
from components.universal_master_editor import UniversalMasterEditor
from services.app_service import AppService
# ロギング設定
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
def log_wrap(name: str):
"""関数の開始/終了/例外を簡易ログするデコレータ"""
def deco(fn):
def _wrapped(*args, **kwargs):
import time
import traceback
arg_types = [type(a).__name__ for a in args]
t0 = time.time()
try:
logging.info(f"{name} start args={arg_types} kwargs_keys={list(kwargs.keys())}")
res = fn(*args, **kwargs)
dt = time.time() - t0
logging.info(f"{name} ok dt={dt:.3f}s res_type={type(res).__name__}")
return res
except Exception:
logging.error(f"{name} error:\n{traceback.format_exc()}")
raise
return _wrapped
return deco
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, action_icon=None, action_tooltip: str = "編集",
bottom: Optional[ft.Control] = 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.action_icon = action_icon or ft.Icons.EDIT
self.action_tooltip = action_tooltip
self.bottom = bottom
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=self.action_icon,
icon_color=ft.Colors.BLUE_GREY_700,
tooltip=self.action_tooltip,
on_click=self.on_edit if self.on_edit else None
)
)
else:
controls.append(ft.Container(width=48)) # スペーサー
content_row = ft.Row(
controls,
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
vertical_alignment=ft.CrossAxisAlignment.CENTER
)
if self.bottom:
return ft.Column(
[content_row, self.bottom],
spacing=6,
alignment=ft.MainAxisAlignment.START,
)
return content_row
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.master_editor: Optional[UniversalMasterEditor] = None
self.explorer_state = ExplorerQueryState(
period_key="3m",
include_offsets=self.show_offsets,
limit=50,
offset=0,
)
# 保存後遷移設定True: 詳細に留まる, False: 一覧へ戻る)
self.stay_on_detail_after_save = True
self.invoice_card_theme = {
"page_bg": "#F3F2FB",
"card_bg": ft.Colors.WHITE,
"card_radius": 18,
"shadow": ft.BoxShadow(
blur_radius=16,
spread_radius=0,
color="#D5D8F0",
offset=ft.Offset(0, 6),
),
"icon_fg": ft.Colors.WHITE,
"icon_default_bg": "#5C6BC0",
"title_color": ft.Colors.BLUE_GREY_900,
"subtitle_color": ft.Colors.BLUE_GREY_500,
"amount_color": "#2F3C7E",
"tag_text_color": "#4B4F67",
"tag_bg": "#E7E9FB",
"badge_bg": "#35C46B",
}
self.doc_type_palette = {
DocumentType.INVOICE.value: "#5C6BC0",
DocumentType.ESTIMATE.value: "#7E57C2",
DocumentType.DELIVERY.value: "#26A69A",
DocumentType.RECEIPT.value: "#FF7043",
DocumentType.SALES.value: "#42A5F5",
DocumentType.DRAFT.value: "#90A4AE",
}
# ビジネスロジックサービス
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.on_disconnect = self.dispose
self.page.on_close = self.dispose
# 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)}")
# 伝票データがない場合はサンプルデータを作成
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):
"""サンプル伝票データ作成"""
logging.warning("create_sample_dataは使用されていません")
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 dispose(self, e=None):
"""リソース解放"""
try:
self.app_service.close()
except Exception as err:
logging.warning(f"クリーンアップ失敗: {err}")
def on_tab_change(self, index):
"""タブ切り替え"""
self.current_tab = index
self.update_main_content()
self.page.update()
@log_wrap("update_main_content")
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_new_customer_form_open:
logging.info("新規顧客登録画面を表示")
self.main_content.controls.append(self.create_new_customer_screen())
elif 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())
elif self.current_tab == 2:
# マスタ編集画面
logging.info("マスタ編集画面を表示")
self.main_content.controls.append(self._build_master_editor_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()
@log_wrap("_build_invoice_list_screen")
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: 伝票リスト作成完了")
result = ft.Column([
app_bar,
ft.Container(
content=invoice_list,
expand=True,
padding=ft.Padding.all(16)
),
], expand=True)
logging.info("_build_invoice_list_screen: Column作成完了")
return result
@log_wrap("_build_invoice_detail_screen")
def _build_invoice_detail_screen(self) -> ft.Column:
"""伝票詳細画面を構築"""
if not self.editing_invoice:
return ft.Column([ft.Text("伝票が選択されていません")])
is_locked = getattr(self.editing_invoice, 'final_locked', False)
is_view_mode = not getattr(self, 'is_detail_edit_mode', False)
app_bar = AppBar(
title="伝票詳細",
show_back=True,
show_edit=not is_locked,
on_back=lambda _: self.back_to_list(),
on_edit=lambda _: self.on_detail_appbar_action(),
action_icon=ft.Icons.SAVE if getattr(self, 'is_detail_edit_mode', False) else ft.Icons.EDIT,
action_tooltip="保存" if getattr(self, 'is_detail_edit_mode', False) else "編集",
bottom=None,
)
# 編集/閲覧モード共通の画面(元の編集用ビルダーを利用)
body = self._create_edit_existing_screen()
return ft.Column([
app_bar,
body,
], expand=True)
def _build_doc_type_bar(self, is_locked: bool, is_view_mode: bool) -> ft.Control:
"""AppBar下部の帳票種別チップ"""
document_types = list(DocumentType)
can_select = not (is_locked or is_view_mode)
active_type = getattr(self, "selected_document_type", DocumentType.INVOICE)
chips = [
ft.GestureDetector(
content=ft.Container(
content=ft.Text(
dt.value,
size=11,
weight=ft.FontWeight.BOLD if dt == active_type else ft.FontWeight.NORMAL,
color=ft.Colors.WHITE if dt == active_type else ft.Colors.BLUE_GREY_600,
text_align=ft.TextAlign.CENTER,
),
padding=ft.Padding.symmetric(horizontal=10, vertical=6),
bgcolor=ft.Colors.BLUE_600 if dt == active_type else ft.Colors.BLUE_GREY_100,
border_radius=18,
),
on_tap=(lambda e, x=dt: self.select_document_type(x)) if can_select else None,
)
for dt in document_types
]
return ft.Container(
content=ft.Row(
controls=[
ft.Row([
ft.Icon(ft.Icons.EDIT if not is_locked else ft.Icons.LOCK, size=16, color=ft.Colors.BLUE_GREY_500),
ft.Text("帳票タイプ", size=11, color=ft.Colors.BLUE_GREY_600),
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER),
ft.Row(controls=chips, spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
padding=ft.Padding.symmetric(horizontal=12, vertical=6),
bgcolor=ft.Colors.BLUE_GREY_50,
border_radius=12,
)
def on_detail_appbar_action(self):
"""詳細画面の右上アクション(編集/保存)"""
if getattr(self, 'is_detail_edit_mode', False):
save_handler = getattr(self, "_detail_save_handler", None)
if save_handler:
save_handler(None)
return
self.toggle_edit_mode()
def _build_master_editor_screen(self) -> ft.Column:
"""マスタ編集画面を構築"""
if self.master_editor is None:
self.master_editor = UniversalMasterEditor(self.page)
app_bar = AppBar(
title="マスタ編集",
show_back=True,
show_edit=False,
on_back=lambda _: self.back_to_list(),
)
return ft.Column([
app_bar,
ft.Container(
content=self.master_editor.build(),
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: 開始")
date_from, date_to = to_date_range(self.explorer_state.period_key)
slips = self.load_slips(
query=self.explorer_state.query,
date_from=date_from,
date_to=date_to,
sort_by=self.explorer_state.sort_key,
sort_desc=self.explorer_state.sort_desc,
limit=self.explorer_state.limit,
offset=self.explorer_state.offset,
include_offsets=self.explorer_state.include_offsets,
)
self.show_offsets = self.explorer_state.include_offsets
logging.info(f"伝票データ取得: {len(slips)}")
def on_query_change(e):
self.explorer_state.query = (e.control.value or "").strip()
self.explorer_state.offset = 0
self.update_main_content()
def on_period_change(e):
self.explorer_state.period_key = e.control.value or "3m"
self.explorer_state.offset = 0
self.update_main_content()
def on_sort_change(e):
self.explorer_state.sort_key = e.control.value or "date"
self.explorer_state.offset = 0
self.update_main_content()
def on_sort_direction_toggle(_):
self.explorer_state.sort_desc = not self.explorer_state.sort_desc
self.explorer_state.offset = 0
self.update_main_content()
def on_toggle_offsets(e):
self.explorer_state.include_offsets = bool(e.control.value)
self.explorer_state.offset = 0
self.show_offsets = self.explorer_state.include_offsets
self.update_main_content()
def on_prev_page(_):
if self.explorer_state.offset <= 0:
return
self.explorer_state.offset = max(0, self.explorer_state.offset - self.explorer_state.limit)
self.update_main_content()
def on_next_page(_):
if len(slips) < self.explorer_state.limit:
return
self.explorer_state.offset += self.explorer_state.limit
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}")
explorer_controls = ft.Container(
content=ft.Column(
[
ft.Row(
[
ft.TextField(
label="検索",
hint_text="伝票番号 / 顧客名 / 種別 / 備考",
value=self.explorer_state.query,
prefix_icon=ft.Icons.SEARCH,
on_change=on_query_change,
expand=True,
dense=True,
),
ft.Dropdown(
label="期間",
value=self.explorer_state.period_key,
options=[
ft.dropdown.Option(k, v)
for k, v in EXPLORER_PERIODS.items()
],
on_select=on_period_change,
width=140,
dense=True,
),
ft.Dropdown(
label="ソート",
value=self.explorer_state.sort_key,
options=[
ft.dropdown.Option(k, v)
for k, v in EXPLORER_SORTS.items()
],
on_select=on_sort_change,
width=150,
dense=True,
),
ft.IconButton(
icon=ft.Icons.ARROW_DOWNWARD if self.explorer_state.sort_desc else ft.Icons.ARROW_UPWARD,
tooltip="並び順切替",
on_click=on_sort_direction_toggle,
),
],
spacing=8,
),
ft.Row(
[
ft.Text("赤伝", size=10, color=ft.Colors.WHITE),
ft.Switch(
value=self.explorer_state.include_offsets,
on_change=on_toggle_offsets,
),
ft.Container(width=10),
ft.Text("保存後詳細に留まる", size=10, color=ft.Colors.WHITE),
ft.Switch(
value=self.stay_on_detail_after_save,
on_change=lambda e: setattr(self, 'stay_on_detail_after_save', bool(e.control.value)),
),
ft.Text(
f"表示中: {len(slips)}件 / offset={self.explorer_state.offset}",
size=12,
color=ft.Colors.BLUE_GREY_700,
),
ft.Container(expand=True),
ft.TextButton("◀ 前", on_click=on_prev_page),
ft.TextButton("次 ▶", on_click=on_next_page),
ft.OutlinedButton("マスタ編集", on_click=self.open_master_editor),
ft.OutlinedButton("チェーン検証", on_click=on_verify_chain),
],
alignment=ft.MainAxisAlignment.START,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
],
spacing=6,
),
padding=ft.Padding.all(10),
bgcolor=ft.Colors.BLUE_GREY_50,
border_radius=8,
)
if not slips:
logging.info("_build_invoice_list: 伝票データなし、空のリストを返す")
return ft.Column(
[
explorer_controls,
ft.Container(height=20),
ft.Text("伝票データがありません", size=16, color=ft.Colors.GREY_600),
ft.Text("検索条件を緩めるか、データを登録してください", size=14, color=ft.Colors.GREY_500),
],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
expand=True,
)
slip_cards = []
for i, slip in enumerate(slips):
logging.info(f"伝票{i}: {type(slip)}")
slip_cards.append(self.create_slip_card(slip))
logging.info(f"カード作成数: {len(slip_cards)}")
content_column = ft.Column(
[
explorer_controls,
ft.Container(height=8),
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=8,
scroll=ft.ScrollMode.AUTO,
expand=True,
),
],
expand=True,
)
return ft.Container(
bgcolor=self.invoice_card_theme.get("page_bg"),
padding=ft.Padding.symmetric(horizontal=12, vertical=10),
content=content_column,
expand=True,
)
def can_create_offset_invoice(self, invoice: Invoice) -> bool:
"""赤伝発行可能かチェック"""
if not getattr(invoice, "final_locked", False):
return False
try:
with sqlite3.connect("sales.db") as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM invoices WHERE offset_target_uuid = ?",
(invoice.uuid,),
)
return cursor.fetchone()[0] == 0
except Exception as e:
logging.error(f"赤伝存在チェックエラー: {e}")
return False
def create_slip_card(self, slip) -> ft.Container:
"""伝票カード作成(コンパクト表示)"""
theme = self.invoice_card_theme
palette = self.doc_type_palette
if isinstance(slip, Invoice):
slip_type = slip.document_type.value
customer_name = slip.customer.formal_name
amount = slip.total_amount
invoice_number = slip.invoice_number
dt = slip.date
final_locked = getattr(slip, "final_locked", False)
items = slip.items or []
first_item = items[0].description if items else "(明細なし)"
extra_count = max(len(items) - 1, 0)
else:
slip_id, slip_type, customer_name, amount, dt, status, description, created_at = slip
invoice_number = getattr(slip, "invoice_number", f"ID:{slip_id}")
final_locked = status == "完了"
first_item = description or "(明細なし)"
extra_count = 0
icon_bg = palette.get(slip_type, theme["icon_default_bg"])
display_amount = -abs(amount) if isinstance(slip, Invoice) and getattr(slip, "is_offset", False) else amount
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 = isinstance(slip, Invoice) and self.can_create_offset_invoice(slip)
first_item_label = first_item[:30] + ("" if len(first_item) > 30 else "")
if extra_count > 0:
first_item_label = f"{first_item_label}{extra_count}"
header_row = ft.Row(
[
ft.Row(
[
ft.Container(
content=ft.Text(
slip_type,
size=9,
weight=ft.FontWeight.BOLD,
color=theme["tag_text_color"],
),
padding=ft.Padding.symmetric(horizontal=8, vertical=2),
bgcolor=theme["tag_bg"],
border_radius=10,
),
ft.Text(f"No: {invoice_number}", size=10, color=theme["subtitle_color"]),
],
spacing=6,
),
ft.Text(
dt.strftime("%y/%m/%d %H:%M"),
size=9,
color=theme["subtitle_color"],
text_align=ft.TextAlign.END,
),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
)
customer_row = ft.Row(
[
ft.Text(
customer_name,
size=12,
weight=ft.FontWeight.BOLD,
color=theme["title_color"],
expand=True,
),
ft.Text(
f"¥{display_amount:,.0f}",
size=14,
weight=ft.FontWeight.BOLD,
color=theme["amount_color"],
),
],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
)
item_row = ft.Row(
[
ft.Text(
first_item_label,
size=10,
color=theme["subtitle_color"],
expand=True,
),
ft.IconButton(
icon=ft.icons.REMOVE_CIRCLE_OUTLINE,
icon_color=ft.Colors.RED_400,
icon_size=16,
tooltip="赤伝発行",
on_click=lambda _: self.create_offset_invoice_dialog(slip),
style=ft.ButtonStyle(padding=0, bgcolor={"": ft.Colors.TRANSPARENT}),
) if show_offset_button else ft.Container(width=0),
],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
)
left_column = ft.Column(
[
header_row,
customer_row,
item_row,
],
spacing=3,
expand=True,
)
status_chip = ft.Text("✓ LOCK", size=9, color=theme["tag_text_color"]) if final_locked else ft.Container()
card_body = ft.Container(
content=ft.Column(
[left_column, status_chip],
spacing=4,
expand=True,
),
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
bgcolor=theme["card_bg"],
border_radius=theme["card_radius"],
shadow=[theme["shadow"]],
)
return ft.GestureDetector(
content=card_body,
on_tap=on_single_tap,
on_double_tap=on_double_tap,
on_long_press=on_long_press,
)
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.selected_document_type = invoice.document_type
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.selected_document_type = invoice.document_type
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}")
# 明細テーブル
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.open_customer_picker()
customer_field = None
if (not is_view_mode and not is_locked) or 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=260,
)
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
# 日付・時間ピッカーテキスト入力NGなのでボタンポップアップ
date_button = None
time_button = None
if not is_view_mode and not is_locked:
# DatePickerをページに登録重複追加を防止
if not hasattr(self, "_date_picker"):
self._date_picker = ft.DatePicker(
first_date=datetime(2000, 1, 1),
last_date=datetime(2100, 12, 31),
)
self.page.overlay.append(self._date_picker)
if not hasattr(self, "_time_picker"):
self._time_picker = ft.TimePicker()
self.page.overlay.append(self._time_picker)
def on_date_change(e):
if not e.data:
return
try:
if isinstance(e.data, datetime):
picked_date = e.data
elif hasattr(e.data, "year") and hasattr(e.data, "month") and hasattr(e.data, "day"):
picked_date = datetime(e.data.year, e.data.month, e.data.day)
else:
raw = str(e.data)
picked_date = datetime.fromisoformat(raw)
current = self.editing_invoice.date
self.editing_invoice.date = datetime(
picked_date.year, picked_date.month, picked_date.day,
current.hour, current.minute
)
# ボタン表示を更新
try:
if date_button is not None:
date_button.content.controls[1].value = picked_date.strftime("%Y/%m/%d")
if time_button is not None:
time_button.content.controls[1].value = self.editing_invoice.date.strftime("%H:%M")
self.page.update()
except Exception:
pass
except Exception as exc:
logging.warning(f"日付パース失敗: {e.data} ({exc})")
def on_time_change(e):
if not e.data:
return
try:
if hasattr(e.data, "hour") and hasattr(e.data, "minute"):
h, m = e.data.hour, e.data.minute
else:
parts = str(e.data).split(":")
h, m = int(parts[0]), int(parts[1])
current = self.editing_invoice.date
self.editing_invoice.date = datetime(
current.year, current.month, current.day,
h, m
)
# ボタン表示を更新
try:
if time_button is not None:
time_button.content.controls[1].value = f"{h:02d}:{m:02d}"
self.page.update()
except Exception:
pass
except Exception as exc:
logging.warning(f"時間パース失敗: {e.data} ({exc})")
self._date_picker.on_change = on_date_change
self._time_picker.on_change = on_time_change
date_button = ft.Button(
content=ft.Row([
ft.Icon(ft.Icons.EVENT, size=16),
ft.Text(self.editing_invoice.date.strftime("%Y/%m/%d"), size=12),
], spacing=6),
on_click=lambda _: self._open_date_picker(),
)
time_button = ft.Button(
content=ft.Row([
ft.Icon(ft.Icons.ACCESS_TIME, size=16),
ft.Text(self.editing_invoice.date.strftime("%H:%M"), size=12),
], spacing=6),
on_click=lambda _: self._open_time_picker(),
)
# 備考フィールド
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
save_succeeded = False
self.editing_invoice.notes = notes_field.value
self.editing_invoice.document_type = self.selected_document_type
# UIで更新された明細を保存前に正規化して確定
normalized_items = normalize_invoice_items(self.editing_invoice.items)
validation = validate_invoice_items(normalized_items)
if not validation.ok:
logging.warning(f"伝票保存バリデーションエラー: {validation.errors}")
try:
self.page.snack_bar = ft.SnackBar(
content=ft.Text(validation.errors[0]),
bgcolor=ft.Colors.RED_600,
)
self.page.snack_bar.open = True
self.page.update()
except Exception:
pass
return
self.editing_invoice.items = normalized_items
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:
save_succeeded = True
logging.info(f"伝票作成成功: {self.editing_invoice.invoice_number}")
self.page.snack_bar = ft.SnackBar(
content=ft.Text("伝票を保存しました"),
bgcolor=ft.Colors.GREEN_600,
)
self.page.snack_bar.open = True
# 一覧を更新して新規作成画面を閉じる
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}")
self.page.snack_bar = ft.SnackBar(
content=ft.Text("保存に失敗しました。編集内容を確認してください。"),
bgcolor=ft.Colors.RED_600,
)
self.page.snack_bar.open = True
self.page.update()
else:
# 更新
logging.info(f"=== 伝票更新開 ===")
# 顧客ID未発行なら先に作成
if getattr(self.editing_invoice.customer, "id", 0) == 0:
try:
new_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,
)
if isinstance(new_id, int) and new_id > 0:
self.editing_invoice.customer.id = new_id
except Exception as e:
logging.error(f"顧客作成失敗(update側): {e}")
success = self.app_service.invoice.update_invoice(self.editing_invoice)
if success:
save_succeeded = True
logging.info(f"伝票更新成功: {self.editing_invoice.invoice_number}")
self.page.snack_bar = ft.SnackBar(
content=ft.Text("伝票を更新しました"),
bgcolor=ft.Colors.GREEN_600,
)
self.page.snack_bar.open = True
# 一覧データは更新
self.invoices = self.app_service.invoice.get_recent_invoices(20)
# 設定により遷移先を変更
if not self.stay_on_detail_after_save:
self.editing_invoice = None
self.current_tab = 0 # 一覧タブに戻る
else:
logging.error(f"伝票更新失敗: {self.editing_invoice.invoice_number}")
self.page.snack_bar = ft.SnackBar(
content=ft.Text("更新に失敗しました。編集内容を確認してください。"),
bgcolor=ft.Colors.RED_600,
)
self.page.snack_bar.open = True
self.page.update()
except Exception as e:
logging.error(f"伝票保存エラー: {e}")
import traceback
logging.error(f"詳細エラー: {traceback.format_exc()}")
self.page.snack_bar = ft.SnackBar(
content=ft.Text("保存中にエラーが発生しました"),
bgcolor=ft.Colors.RED_600,
)
self.page.snack_bar.open = True
self.page.update()
save_succeeded = False
if save_succeeded:
# 編集モード終了(ビューモードに戻る)
self.is_detail_edit_mode = False # ビューモードに戻る
self.update_main_content()
# AppBar右上の保存アイコンからも同じ保存処理を呼べるようにする
self._detail_save_handler = save_changes
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()
summary_tags = []
if pdf_generated:
summary_tags.append("PDF生成済み")
if chain_hash:
summary_tags.append("監査チェーン登録済み")
if final_locked:
summary_tags.append("LOCK")
summary_badges = ft.Row(
controls=[
ft.Container(
content=ft.Text(tag, size=10, color=ft.Colors.WHITE),
padding=ft.Padding.symmetric(horizontal=8, vertical=4),
bgcolor=ft.Colors.BLUE_GREY_400,
border_radius=12,
)
for tag in summary_tags
],
spacing=4,
wrap=True,
run_spacing=4,
) if summary_tags else ft.Container(height=0)
summary_card = ft.Container(
content=ft.Column(
[
ft.Row(
[
customer_field if customer_field else ft.Text(
self.editing_invoice.customer.formal_name,
size=13,
weight=ft.FontWeight.BOLD,
),
ft.Row([
ft.Button(
content=ft.Text("顧客選択", size=12),
on_click=lambda _: select_customer(),
disabled=is_locked or is_view_mode,
) if not is_view_mode and not is_locked else ft.Container(),
ft.Text(
f"¥{self.editing_invoice.total_amount:,} (税込)",
size=15,
weight=ft.FontWeight.BOLD,
color=ft.Colors.BLUE_700,
),
], spacing=8, alignment=ft.MainAxisAlignment.END),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
ft.Row(
[
ft.Text(
self.editing_invoice.invoice_number,
size=12,
color=ft.Colors.BLUE_GREY_500,
),
ft.Row([
date_button if date_button else ft.Text(
self.editing_invoice.date.strftime("%Y/%m/%d"),
size=12,
color=ft.Colors.BLUE_GREY_600,
),
ft.Text(" "),
time_button if time_button else ft.Text(
self.editing_invoice.date.strftime("%H:%M"),
size=12,
color=ft.Colors.BLUE_GREY_600,
),
], spacing=4, alignment=ft.MainAxisAlignment.END),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
summary_badges,
],
spacing=6,
),
padding=ft.Padding.all(14),
bgcolor=ft.Colors.BLUE_GREY_50,
border_radius=10,
)
items_section = ft.Container(
content=ft.Column(
[
ft.Row(
[
ft.Text("明細", size=13, weight=ft.FontWeight.BOLD),
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(),
],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
ft.Container(
content=items_table,
border=ft.Border.all(1, ft.Colors.GREY_300),
border_radius=6,
padding=ft.Padding.all(4),
),
],
spacing=6,
),
padding=ft.Padding.all(12),
bgcolor=ft.Colors.WHITE,
border_radius=10,
)
notes_section = ft.Container(
content=ft.Column(
[
ft.Text("備考", size=13, weight=ft.FontWeight.BOLD),
notes_field,
ft.Row(
[
ft.Button(
content=ft.Row([
ft.Icon(ft.Icons.DOWNLOAD, size=16),
ft.Text("PDF生成", size=12),
], spacing=6),
style=ft.ButtonStyle(
bgcolor=ft.Colors.BLUE_600,
color=ft.Colors.WHITE,
),
on_click=lambda _: self.generate_pdf_from_edit(),
disabled=is_locked,
) if not is_locked else ft.Container(),
],
alignment=ft.MainAxisAlignment.END,
),
],
spacing=10,
),
padding=ft.Padding.all(14),
bgcolor=ft.Colors.WHITE,
border_radius=10,
)
lower_scroll = ft.Container(
content=ft.Column(
[
summary_card,
notes_section,
],
spacing=12,
scroll=ft.ScrollMode.AUTO,
expand=True,
),
expand=True,
)
top_stack = ft.Column(
[
items_section,
],
spacing=12,
)
return ft.Container(
content=ft.Column(
[
top_stack,
lower_scroll,
],
spacing=12,
expand=True,
),
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}")
# 入力途中で画面全体を再描画すると編集値が飛びやすいため、
# ここではモデル更新のみに留める(再描画は保存/行追加/行削除時に実施)。
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:
"""表示モード:フレキシブルな表形式で整然と表示"""
return build_invoice_items_view_table(items)
def _create_edit_mode_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column:
"""編集モード:フレキシブルな表形式"""
return build_invoice_items_edit_table(
items=items,
is_locked=is_locked,
on_update_field=self._update_item_field,
on_delete_row=self._delete_item_row,
)
def _open_date_picker(self):
if hasattr(self, "_date_picker"):
try:
self._date_picker.open = True
self.page.update()
except Exception as e:
logging.warning(f"DatePicker open error: {e}")
def _open_time_picker(self):
if hasattr(self, "_time_picker"):
try:
self._time_picker.open = True
self.page.update()
except Exception as e:
logging.warning(f"TimePicker open error: {e}")
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:
try:
self.page.snack_bar = ft.SnackBar(content=ft.Text("顧客名と正式名称は必須です"), bgcolor=ft.Colors.RED_600)
self.page.snack_bar.open = True
self.page.update()
except Exception:
pass
return
try:
new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone)
except Exception as e:
logging.error(f"顧客登録エラー: {e}")
try:
self.page.snack_bar = ft.SnackBar(content=ft.Text("保存に失敗しました"), bgcolor=ft.Colors.RED_600)
self.page.snack_bar.open = True
self.page.update()
except Exception:
pass
return
# create_customer がID(int)を返す場合にも対応
created_customer_obj = None
if isinstance(new_customer, Customer):
created_customer_obj = new_customer
elif isinstance(new_customer, int):
# IDから顧客を再取得
try:
if hasattr(self.app_service.customer, "get_customer_by_id"):
created_customer_obj = self.app_service.customer.get_customer_by_id(new_customer)
else:
# 全件から検索
for c in self.app_service.customer.get_all_customers():
if c.id == new_customer:
created_customer_obj = c
break
except Exception as e:
logging.error(f"顧客再取得エラー: {e}")
if created_customer_obj:
self.customers = self.app_service.customer.get_all_customers()
self.selected_customer = created_customer_obj
if self.editing_invoice:
self.editing_invoice.customer = created_customer_obj
logging.info(f"新規顧客登録: {created_customer_obj.formal_name}")
self.is_customer_picker_open = False
self.is_new_customer_form_open = False
self.update_main_content()
else:
logging.error("新規顧客登録失敗")
try:
self.page.snack_bar = ft.SnackBar(content=ft.Text("保存に失敗しました"), bgcolor=ft.Colors.RED_600)
self.page.snack_bar.open = True
self.page.update()
except Exception:
pass
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,
border_radius=10,
),
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(
content=ft.Text("保存", color=ft.Colors.WHITE),
bgcolor=ft.Colors.BLUE_GREY_800,
on_click=save_customer,
),
ft.Button(
content=ft.Text("キャンセル"),
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 create_customer_picker_screen(self) -> ft.Container:
"""簡易顧客選択画面(画面遷移用)"""
customers = getattr(self, "customers", []) or []
def close_picker(_=None):
self.is_customer_picker_open = False
self.update_main_content()
def on_pick(customer: Customer):
self.selected_customer = customer
if self.editing_invoice:
self.editing_invoice.customer = customer
close_picker()
def open_new_customer(_=None):
self.is_new_customer_form_open = True
self.is_customer_picker_open = False
self.update_main_content()
customer_cards = []
for c in customers:
customer_cards.append(
ft.Container(
content=ft.Card(
content=ft.ListTile(
title=ft.Text(c.formal_name, weight=ft.FontWeight.BOLD),
subtitle=ft.Text(c.address or ""),
trailing=ft.Text(c.phone or ""),
)
),
on_click=lambda _, cu=c: on_pick(cu),
)
)
body = ft.Column(
[
ft.Row([
ft.IconButton(ft.Icons.ARROW_BACK, on_click=close_picker),
ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD, expand=True),
ft.IconButton(ft.Icons.ADD, tooltip="新規顧客", on_click=open_new_customer),
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
ft.Divider(),
ft.Column(customer_cards, spacing=6, scroll=ft.ScrollMode.AUTO, expand=True),
],
spacing=10,
expand=True,
)
return ft.Container(
content=body,
padding=ft.Padding.all(12),
expand=True,
)
def open_master_editor(self, e=None):
"""マスタ編集画面を開く"""
self.is_customer_picker_open = False
self.is_new_customer_form_open = False
self.current_tab = 2
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):
"""帳票種類選択"""
resolved_type = None
if isinstance(doc_type, DocumentType):
resolved_type = doc_type
else:
for dt in DocumentType:
if dt.value == doc_type:
resolved_type = dt
break
if not resolved_type:
logging.warning(f"未知の帳票種類: {doc_type}")
return
if resolved_type == getattr(self, "selected_document_type", None):
return
self.selected_document_type = resolved_type
logging.info(f"帳票種類を選択: {resolved_type.value}")
self.update_main_content()
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,
query: str = "",
date_from: Optional[str] = None,
date_to: Optional[str] = None,
sort_by: str = "date",
sort_desc: bool = True,
limit: int = 50,
offset: int = 0,
include_offsets: bool = False,
) -> List[Invoice]:
"""伝票データ読み込み - Explorer条件を適用。"""
return self.app_service.invoice.search_invoices(
query=query,
date_from=date_from,
date_to=date_to,
sort_by=sort_by,
sort_desc=sort_desc,
limit=limit,
offset=offset,
include_offsets=include_offsets,
)
def main(page: ft.Page):
"""メイン関数"""
app = FlutterStyleDashboard(page)
page.update()
if __name__ == "__main__":
import flet as ft
ft.run(main, port=8550)