1974 lines
80 KiB
Python
1974 lines
80 KiB
Python
"""
|
||
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(_):
|
||
# 長押しは直接編集画面へ(ビューワではなく編集)
|
||
logging.info(f"long_press -> open_invoice_edit: {getattr(slip, 'invoice_number', 'unknown')}")
|
||
self.open_invoice_edit(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 = True # 編集モードで開く
|
||
self.is_customer_picker_open = False
|
||
self.is_new_customer_form_open = 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}")
|
||
self._show_snack(validation.errors[0], ft.Colors.RED_600)
|
||
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._show_snack("伝票を保存しました", ft.Colors.GREEN_600)
|
||
# 一覧を更新して新規作成画面を閉じる
|
||
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._show_snack("保存に失敗しました。編集内容を確認してください。", ft.Colors.RED_600)
|
||
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}")
|
||
|
||
# 既存顧客ならマスタも更新
|
||
if getattr(self.editing_invoice.customer, "id", 0) > 0:
|
||
try:
|
||
self.app_service.customer.update_customer(self.editing_invoice.customer)
|
||
except Exception as e:
|
||
logging.warning(f"顧客更新失敗(続行): {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._show_snack("伝票を更新しました", ft.Colors.GREEN_600)
|
||
# 一覧データは更新
|
||
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 # 一覧タブに戻る
|
||
self.update_main_content()
|
||
else:
|
||
logging.error(f"伝票更新失敗: {self.editing_invoice.invoice_number}")
|
||
self._show_snack("更新に失敗しました。編集内容を確認してください。", ft.Colors.RED_600)
|
||
except Exception as e:
|
||
logging.error(f"伝票保存エラー: {e}")
|
||
import traceback
|
||
logging.error(f"詳細エラー: {traceback.format_exc()}")
|
||
self._show_snack("保存中にエラーが発生しました", ft.Colors.RED_600)
|
||
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 _show_snack(self, message: str, color=ft.Colors.BLUE_GREY_800):
|
||
try:
|
||
snack = ft.SnackBar(content=ft.Text(message), bgcolor=color)
|
||
# prefer show_snack_bar API if available (more reliable on web)
|
||
if hasattr(self.page, "show_snack_bar"):
|
||
self.page.show_snack_bar(snack)
|
||
else:
|
||
self.page.snack_bar = snack
|
||
self.page.snack_bar.open = True
|
||
self.page.update()
|
||
except Exception as e:
|
||
logging.warning(f"snack_bar表示失敗: {e}")
|
||
def create_new_customer_screen(self) -> ft.Container:
|
||
"""新規/既存顧客登録・編集画面"""
|
||
editing_customer = getattr(self, "editing_customer_for_form", None)
|
||
|
||
name_field = ft.TextField(label="顧客名(略称)", value=getattr(editing_customer, "name", ""))
|
||
formal_name_field = ft.TextField(label="正式名称", value=getattr(editing_customer, "formal_name", ""))
|
||
address_field = ft.TextField(label="住所", value=getattr(editing_customer, "address", ""))
|
||
phone_field = ft.TextField(label="電話番号", value=getattr(editing_customer, "phone", ""))
|
||
|
||
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:
|
||
self._show_snack("顧客名と正式名称は必須です", ft.Colors.RED_600)
|
||
return
|
||
|
||
# 既存編集か新規かで分岐
|
||
if editing_customer and getattr(editing_customer, "id", 0) > 0:
|
||
try:
|
||
editing_customer.name = name
|
||
editing_customer.formal_name = formal_name
|
||
editing_customer.address = address
|
||
editing_customer.phone = phone
|
||
self.app_service.customer.update_customer(editing_customer)
|
||
self.customers = self.app_service.customer.get_all_customers()
|
||
self.selected_customer = editing_customer
|
||
if self.editing_invoice:
|
||
self.editing_invoice.customer = editing_customer
|
||
self._show_snack("顧客を更新しました", ft.Colors.GREEN_600)
|
||
self.editing_customer_for_form = None
|
||
self.is_customer_picker_open = False
|
||
self.is_new_customer_form_open = False
|
||
self.update_main_content()
|
||
return
|
||
except Exception as e:
|
||
logging.error(f"顧客更新エラー: {e}")
|
||
self._show_snack("保存に失敗しました", ft.Colors.RED_600)
|
||
return
|
||
|
||
# 新規登録フロー
|
||
try:
|
||
new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone)
|
||
except Exception as e:
|
||
logging.error(f"顧客登録エラー: {e}")
|
||
self._show_snack("保存に失敗しました", ft.Colors.RED_600)
|
||
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.editing_customer_for_form = None
|
||
self.update_main_content()
|
||
else:
|
||
logging.error("新規顧客登録失敗")
|
||
self._show_snack("保存に失敗しました", ft.Colors.RED_600)
|
||
|
||
def cancel(_):
|
||
self.editing_customer_for_form = None
|
||
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()
|
||
|
||
def open_edit_customer(c: Customer):
|
||
# 長押しで顧客を編集
|
||
self.editing_customer_for_form = c
|
||
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),
|
||
on_long_press=lambda _, cu=c: open_edit_customer(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)
|