3129 lines
128 KiB
Python
3129 lines
128 KiB
Python
"""
|
||
Flutter風ダッシュボード
|
||
下部ナビゲーションと洗練されたUIコンポーネントを実装
|
||
"""
|
||
|
||
import flet as ft
|
||
import signal
|
||
import sys
|
||
import logging
|
||
import asyncio
|
||
import threading
|
||
import sqlite3
|
||
from datetime import datetime, date, time
|
||
from typing import List, Dict, Optional, Any
|
||
from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem, Product
|
||
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, trailing_controls: Optional[list] = None,
|
||
title_control: Optional[ft.Control] = None, leading_control: 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.trailing_controls = trailing_controls or []
|
||
self.title_control = title_control
|
||
self.leading_control = leading_control
|
||
|
||
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
|
||
)
|
||
)
|
||
elif self.leading_control is not None:
|
||
controls.append(self.leading_control)
|
||
else:
|
||
controls.append(ft.Container(width=48)) # スペーーサー
|
||
|
||
# 中央:タイトル
|
||
title_ctrl = self.title_control if self.title_control else ft.Text(
|
||
self.title,
|
||
size=18,
|
||
weight=ft.FontWeight.W_500,
|
||
color=ft.Colors.BLUE_GREY_800
|
||
)
|
||
controls.append(
|
||
ft.Container(
|
||
content=title_ctrl,
|
||
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
|
||
)
|
||
)
|
||
elif self.trailing_controls:
|
||
controls.append(
|
||
ft.Row(self.trailing_controls, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||
)
|
||
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.is_product_picker_open = False
|
||
self.customer_search_query = ""
|
||
self.show_offsets = False
|
||
self._suppress_tap_after_long_press = False
|
||
self._toast_text = ft.Text("", color=ft.Colors.WHITE, size=12)
|
||
self._toast = ft.Container(
|
||
opacity=0,
|
||
visible=False,
|
||
animate_opacity=300,
|
||
content=ft.Container(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.INFO, size=16, color=ft.Colors.WHITE70),
|
||
self._toast_text,
|
||
], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||
bgcolor=ft.Colors.BLUE_GREY_800,
|
||
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
|
||
border_radius=8,
|
||
),
|
||
)
|
||
self.chain_verify_result = None
|
||
self._invoice_snapshot = None # 編集開始時のスナップショット
|
||
self.is_new_customer_form_open = False
|
||
self.is_new_product_form_open = False
|
||
self.editing_product_for_form: Optional[Product] = None
|
||
self._pending_product_row: Optional[int] = None
|
||
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.current_theme = "light"
|
||
self.theme_presets = self._build_theme_presets()
|
||
self.apply_theme(self.current_theme)
|
||
|
||
# ビジネスロジックサービス
|
||
self.app_service = AppService()
|
||
self.invoices = []
|
||
self.customers = []
|
||
self.settings_state = {
|
||
"theme": self.current_theme,
|
||
"stay_on_detail_after_save": self.stay_on_detail_after_save,
|
||
"company_name": "",
|
||
"company_kana": "",
|
||
"company_address": "",
|
||
"company_phone": "",
|
||
"company_representative": "",
|
||
"company_email": "",
|
||
"smtp_host": "",
|
||
"smtp_port": "",
|
||
"smtp_username": "",
|
||
"smtp_password": "",
|
||
"backup_path": "",
|
||
"corner_stamp_path": "",
|
||
"rep_stamp_path": "",
|
||
"bank_accounts": [{"active": False} for _ in range(4)],
|
||
}
|
||
self._item_row_refs: Dict[int, Dict[str, ft.Control]] = {}
|
||
self._total_amount_text: Optional[ft.Text] = None
|
||
self._tax_amount_text: Optional[ft.Text] = None
|
||
self._subtotal_text: Optional[ft.Text] = None
|
||
self.is_reorder_mode = False
|
||
self.is_company_settings_open = False
|
||
self._pending_stamp_target: Optional[str] = None
|
||
self._stamp_picker: Optional[ft.FilePicker] = None
|
||
self.max_active_bank_accounts = 2
|
||
self.edit_button_style = ft.ButtonStyle(
|
||
bgcolor=ft.Colors.WHITE,
|
||
color=ft.Colors.BLUE_GREY_800,
|
||
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
|
||
shape=ft.RoundedRectangleBorder(radius=6),
|
||
side=ft.BorderSide(1, ft.Colors.BLUE_200),
|
||
overlay_color=ft.Colors.BLUE_50,
|
||
)
|
||
|
||
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,
|
||
is_draft=invoice.is_draft,
|
||
)
|
||
|
||
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._settings_hide_task = None
|
||
|
||
self.settings_panel_wrapper = ft.Container(
|
||
width=640,
|
||
bgcolor=ft.Colors.WHITE,
|
||
padding=ft.Padding.symmetric(horizontal=16, vertical=24),
|
||
shadow=[ft.BoxShadow(blur_radius=24, color=ft.Colors.BLACK12, offset=ft.Offset(6, 0))],
|
||
)
|
||
self.settings_drawer_overlay = ft.Stack(
|
||
controls=[
|
||
ft.Container(
|
||
expand=True,
|
||
bgcolor=ft.Colors.BLACK54,
|
||
on_click=self.close_settings_drawer,
|
||
),
|
||
ft.Container(
|
||
expand=True,
|
||
alignment=ft.Alignment(-1, 0),
|
||
content=ft.Container(
|
||
content=self.settings_panel_wrapper,
|
||
width=640,
|
||
bgcolor=ft.Colors.WHITE,
|
||
),
|
||
),
|
||
],
|
||
expand=True,
|
||
visible=False,
|
||
)
|
||
self.page.overlay.append(self.settings_drawer_overlay)
|
||
|
||
# 初期表示
|
||
self.update_main_content()
|
||
|
||
def dispose(self, _=None):
|
||
logging.info("FlutterStyleDashboard.dispose")
|
||
try:
|
||
if self.page:
|
||
self.page.window.close()
|
||
except Exception as e:
|
||
logging.warning(f"dispose error: {e}")
|
||
|
||
def open_settings_drawer(self, _=None):
|
||
if not hasattr(self, "settings_drawer_overlay"):
|
||
return
|
||
if self._settings_hide_task:
|
||
self._settings_hide_task.cancel()
|
||
self._settings_hide_task = None
|
||
self.settings_panel_wrapper.content = self._build_settings_panel()
|
||
self.settings_drawer_overlay.visible = True
|
||
self.page.update()
|
||
|
||
def close_settings_drawer(self, _=None):
|
||
if not hasattr(self, "settings_drawer_overlay"):
|
||
return
|
||
self.page.update()
|
||
loop = asyncio.get_event_loop()
|
||
if self._settings_hide_task:
|
||
self._settings_hide_task.cancel()
|
||
self._settings_hide_task = loop.create_task(self._hide_settings_drawer_async())
|
||
|
||
async def _hide_settings_drawer_async(self):
|
||
await asyncio.sleep(0.3)
|
||
if hasattr(self, "settings_drawer_overlay"):
|
||
self.settings_drawer_overlay.visible = False
|
||
self.page.update()
|
||
self._settings_hide_task = None
|
||
|
||
def _build_settings_panel(self) -> ft.Row:
|
||
sidebar_width = int(self.settings_panel_wrapper.width * 0.25)
|
||
sidebar = ft.Container(
|
||
width=sidebar_width,
|
||
content=ft.Column(
|
||
[
|
||
ft.Text("メニュー", weight=ft.FontWeight.BOLD),
|
||
ft.Divider(),
|
||
ft.ListTile(
|
||
leading=ft.Icon(ft.Icons.PALETTE),
|
||
title=ft.Text("全体設定"),
|
||
selected=not self.is_company_settings_open,
|
||
on_click=lambda _: self._open_settings_page(False),
|
||
),
|
||
ft.ListTile(
|
||
leading=ft.Icon(ft.Icons.BUSINESS),
|
||
title=ft.Text("自社情報"),
|
||
selected=self.is_company_settings_open,
|
||
on_click=lambda _: self._open_settings_page(True),
|
||
),
|
||
],
|
||
spacing=8,
|
||
expand=True,
|
||
),
|
||
)
|
||
|
||
content = self._build_company_settings_content() if self.is_company_settings_open else self._build_settings_content()
|
||
|
||
body = ft.Row(
|
||
[
|
||
sidebar,
|
||
ft.VerticalDivider(width=1),
|
||
ft.Container(content=content, expand=True),
|
||
],
|
||
expand=True,
|
||
)
|
||
|
||
header = ft.Row(
|
||
[
|
||
ft.IconButton(ft.Icons.ARROW_BACK, tooltip="閉じる", on_click=self.close_settings_drawer),
|
||
ft.Text("設定", size=18, weight=ft.FontWeight.BOLD),
|
||
],
|
||
spacing=8,
|
||
alignment=ft.MainAxisAlignment.START,
|
||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||
)
|
||
|
||
return ft.Column(
|
||
[
|
||
header,
|
||
ft.Divider(),
|
||
body,
|
||
],
|
||
spacing=8,
|
||
expand=True,
|
||
)
|
||
|
||
def _open_settings_page(self, company: bool):
|
||
self.is_company_settings_open = company
|
||
self.settings_panel_wrapper.content = self._build_settings_panel()
|
||
self.page.update()
|
||
|
||
def _build_settings_content(self) -> ft.Column:
|
||
theme_radio = ft.RadioGroup(
|
||
value=self.settings_state.get("theme", self.current_theme),
|
||
on_change=self._on_theme_change,
|
||
content=ft.Column(
|
||
[
|
||
ft.Radio(value=name, label=name.title())
|
||
for name in self.theme_presets.keys()
|
||
],
|
||
spacing=4,
|
||
),
|
||
)
|
||
|
||
stay_switch = ft.Switch(
|
||
label="保存後も編集画面に留まる",
|
||
value=self.settings_state.get("stay_on_detail_after_save", self.stay_on_detail_after_save),
|
||
on_change=self._on_stay_setting_change,
|
||
)
|
||
|
||
def text_field(key: str, label: str, password: bool = False) -> ft.TextField:
|
||
return ft.TextField(
|
||
label=label,
|
||
value=self.settings_state.get(key, ""),
|
||
password=password,
|
||
can_reveal_password=password,
|
||
on_change=lambda e, k=key: self._on_settings_text_change(k, e.control.value),
|
||
)
|
||
|
||
smtp_section = ft.Column(
|
||
[
|
||
ft.Text("SMTPサーバ設定", weight=ft.FontWeight.BOLD),
|
||
text_field("smtp_host", "ホスト"),
|
||
text_field("smtp_port", "ポート"),
|
||
text_field("smtp_username", "ユーザー名"),
|
||
text_field("smtp_password", "パスワード", password=True),
|
||
],
|
||
spacing=8,
|
||
)
|
||
|
||
backup_section = ft.Column(
|
||
[
|
||
ft.Text("バックアップ先", weight=ft.FontWeight.BOLD),
|
||
text_field("backup_path", "保存フォルダ"),
|
||
],
|
||
spacing=8,
|
||
)
|
||
|
||
action_buttons = ft.Row(
|
||
[
|
||
ft.ElevatedButton("保存", icon=ft.Icons.SAVE, on_click=self._save_settings_and_close),
|
||
ft.TextButton("閉じる", on_click=self.close_settings_drawer),
|
||
],
|
||
spacing=12,
|
||
alignment=ft.MainAxisAlignment.END,
|
||
)
|
||
|
||
return ft.Column(
|
||
[
|
||
ft.Text("設定", size=20, weight=ft.FontWeight.BOLD),
|
||
ft.Divider(),
|
||
ft.Text("テーマ", weight=ft.FontWeight.BOLD),
|
||
theme_radio,
|
||
stay_switch,
|
||
ft.Divider(),
|
||
smtp_section,
|
||
ft.Divider(),
|
||
backup_section,
|
||
ft.Divider(),
|
||
action_buttons,
|
||
],
|
||
spacing=16,
|
||
scroll=ft.ScrollMode.AUTO,
|
||
)
|
||
|
||
def _build_company_settings_content(self) -> ft.Column:
|
||
def text_field(key: str, label: str, multiline: bool = False) -> ft.TextField:
|
||
return ft.TextField(
|
||
label=label,
|
||
value=self.settings_state.get(key, ""),
|
||
multiline=multiline,
|
||
min_lines=1 if not multiline else 2,
|
||
max_lines=4,
|
||
on_change=lambda e, k=key: self._on_settings_text_change(k, e.control.value),
|
||
)
|
||
|
||
def bank_entry(index: int, data: Dict[str, Any]) -> ft.Container:
|
||
prefix = f"bank_{index}"
|
||
|
||
def on_field_change(e, field):
|
||
account = self._ensure_bank_account(index)
|
||
account[field] = e.control.value
|
||
self._save_bank_accounts()
|
||
|
||
def on_active_change(e):
|
||
account = self._ensure_bank_account(index)
|
||
is_enabling = bool(e.control.value)
|
||
if is_enabling and self._active_bank_count() >= self.max_active_bank_accounts:
|
||
self._show_snack(f"請求書掲載は最大{self.max_active_bank_accounts}口座です", ft.Colors.RED_200)
|
||
e.control.value = False
|
||
self.page.update()
|
||
return
|
||
account["active"] = is_enabling
|
||
self._save_bank_accounts()
|
||
|
||
is_active = data.get("active", False)
|
||
return ft.Container(
|
||
content=ft.Column(
|
||
[
|
||
ft.Row(
|
||
[
|
||
ft.Text(f"口座 {index + 1}", weight=ft.FontWeight.BOLD),
|
||
ft.Switch(label="請求書に掲載", value=is_active, on_change=on_active_change),
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||
),
|
||
ft.TextField(label="銀行名", value=data.get("bank_name", ""), on_change=lambda e: on_field_change(e, "bank_name")),
|
||
ft.TextField(label="支店名", value=data.get("branch_name", ""), on_change=lambda e: on_field_change(e, "branch_name")),
|
||
ft.TextField(label="区分", value=data.get("account_type", "普通"), on_change=lambda e: on_field_change(e, "account_type")),
|
||
ft.TextField(label="口座番号", value=data.get("account_number", ""), on_change=lambda e: on_field_change(e, "account_number")),
|
||
ft.TextField(label="名義", value=data.get("holder", ""), on_change=lambda e: on_field_change(e, "holder")),
|
||
],
|
||
spacing=8,
|
||
),
|
||
border=ft.Border.all(1, ft.Colors.GREY_300),
|
||
border_radius=8,
|
||
padding=ft.Padding.all(12),
|
||
)
|
||
|
||
stamp_controls = ft.Column(
|
||
[
|
||
ft.Text("印鑑アップロード", weight=ft.FontWeight.BOLD),
|
||
ft.Row([
|
||
ft.Column([
|
||
ft.Text("角印"),
|
||
ft.Row([
|
||
ft.ElevatedButton("アップロード", on_click=lambda _: self._pick_stamp("corner_stamp_path")),
|
||
ft.Text(self._file_name(self.settings_state.get("corner_stamp_path"))),
|
||
]),
|
||
]),
|
||
ft.Column([
|
||
ft.Text("担当者印"),
|
||
ft.Row([
|
||
ft.ElevatedButton("アップロード", on_click=lambda _: self._pick_stamp("rep_stamp_path")),
|
||
ft.Text(self._file_name(self.settings_state.get("rep_stamp_path"))),
|
||
]),
|
||
]),
|
||
], spacing=16),
|
||
],
|
||
spacing=8,
|
||
)
|
||
|
||
accounts = self.settings_state.get("bank_accounts", [])
|
||
while len(accounts) < 4:
|
||
accounts.append({"active": False})
|
||
account_cards = [bank_entry(i, accounts[i]) for i in range(4)]
|
||
|
||
company_fields = ft.Column(
|
||
[
|
||
text_field("company_name", "会社名"),
|
||
text_field("company_kana", "会社名 (カナ)"),
|
||
text_field("company_address", "住所", multiline=True),
|
||
text_field("company_phone", "電話番号"),
|
||
ft.TextField(
|
||
label="代表者名",
|
||
value=self.settings_state.get("company_representative", ""),
|
||
on_change=lambda e: self._on_settings_text_change("company_representative", e.control.value),
|
||
),
|
||
ft.TextField(
|
||
label="連絡先メール",
|
||
value=self.settings_state.get("company_email", ""),
|
||
on_change=lambda e: self._on_settings_text_change("company_email", e.control.value),
|
||
),
|
||
],
|
||
spacing=10,
|
||
)
|
||
|
||
return ft.Column(
|
||
[
|
||
ft.Text("自社情報設定", size=18, weight=ft.FontWeight.BOLD),
|
||
ft.Divider(),
|
||
company_fields,
|
||
ft.Divider(),
|
||
ft.Text("銀行口座", weight=ft.FontWeight.BOLD),
|
||
ft.Text("最大4口座登録可能・2口座まで請求書に掲載できます"),
|
||
ft.Column(account_cards, spacing=12),
|
||
ft.Divider(),
|
||
stamp_controls,
|
||
ft.Divider(),
|
||
ft.Row([
|
||
ft.ElevatedButton("保存", icon=ft.Icons.SAVE, on_click=self._save_settings_and_close),
|
||
ft.TextButton("閉じる", on_click=self.close_settings_drawer),
|
||
], alignment=ft.MainAxisAlignment.END, spacing=12),
|
||
],
|
||
spacing=16,
|
||
expand=True,
|
||
scroll=ft.ScrollMode.AUTO,
|
||
)
|
||
|
||
def _ensure_bank_account(self, index: int) -> Dict[str, Any]:
|
||
accounts = self.settings_state.setdefault("bank_accounts", [])
|
||
while len(accounts) <= index:
|
||
accounts.append({"active": False})
|
||
return accounts[index]
|
||
|
||
def _save_bank_accounts(self):
|
||
self.page.update()
|
||
|
||
def _active_bank_count(self) -> int:
|
||
accounts = self.settings_state.get("bank_accounts", [])
|
||
return sum(1 for acc in accounts if acc.get("active"))
|
||
|
||
def _file_name(self, path: str) -> str:
|
||
if not path:
|
||
return "未設定"
|
||
return path.split("/")[-1]
|
||
|
||
def _pick_stamp(self, target_key: str):
|
||
if self._stamp_picker is None:
|
||
self._show_snack("この環境では印鑑アップロードに対応していません", ft.Colors.RED_200)
|
||
return
|
||
self._pending_stamp_target = target_key
|
||
self._stamp_picker.pick_files(allow_multiple=False)
|
||
|
||
def _on_stamp_file_picked(self, e):
|
||
if not e.files or not self._pending_stamp_target:
|
||
return
|
||
file = e.files[0]
|
||
temp_path = file.path or file.name
|
||
self.settings_state[self._pending_stamp_target] = temp_path
|
||
self._pending_stamp_target = None
|
||
self.settings_panel_wrapper.content = self._build_settings_panel()
|
||
self.page.update()
|
||
|
||
def _on_theme_change(self, e: ft.ControlEvent):
|
||
new_theme = e.control.value or "light"
|
||
if new_theme == self.current_theme:
|
||
return
|
||
self.settings_state["theme"] = new_theme
|
||
self.apply_theme(new_theme)
|
||
self.update_main_content()
|
||
|
||
def _on_stay_setting_change(self, e: ft.ControlEvent):
|
||
value = bool(e.control.value)
|
||
self.settings_state["stay_on_detail_after_save"] = value
|
||
self.stay_on_detail_after_save = value
|
||
|
||
def _on_settings_text_change(self, key: str, value: str):
|
||
self.settings_state[key] = value
|
||
|
||
def _save_settings_and_close(self, _=None):
|
||
self._show_snack("設定を保存しました", ft.Colors.BLUE_GREY_600)
|
||
self.close_settings_drawer()
|
||
|
||
def toggle_reorder_mode(self, _=None):
|
||
self.is_reorder_mode = not self.is_reorder_mode
|
||
self.update_main_content()
|
||
|
||
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_new_product_form_open:
|
||
logging.info("新規商品登録画面を表示")
|
||
self.main_content.controls.append(self.create_new_product_screen())
|
||
elif self.is_customer_picker_open:
|
||
# 顧客選択画面
|
||
logging.info("顧客選択画面を表示")
|
||
self.main_content.controls.append(self.create_customer_picker_screen())
|
||
elif getattr(self, "is_product_picker_open", False):
|
||
logging.info("商品選択画面を表示")
|
||
self.main_content.controls.append(self.create_product_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(戻るボタンなし、編集ボタンなし)
|
||
settings_button = ft.IconButton(
|
||
icon=ft.Icons.MENU,
|
||
tooltip="設定",
|
||
on_click=self.open_settings_drawer,
|
||
)
|
||
|
||
app_bar = AppBar(
|
||
title="伝票一覧",
|
||
show_back=False,
|
||
show_edit=False,
|
||
trailing_controls=[
|
||
ft.IconButton(ft.Icons.REFRESH, on_click=lambda _: self.refresh_invoices()),
|
||
],
|
||
leading_control=settings_button,
|
||
)
|
||
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=self._toast, padding=ft.Padding.symmetric(horizontal=16, vertical=4)),
|
||
ft.Container(
|
||
content=invoice_list,
|
||
expand=True,
|
||
padding=ft.Padding.all(16)
|
||
),
|
||
# デバッグ用: スナック表示テストボタン(一覧画面下部)
|
||
ft.Container(
|
||
content=ft.Row([
|
||
ft.Button(
|
||
content=ft.Text("スナックテストを表示"),
|
||
on_click=lambda _: self._show_snack("テストスナック"),
|
||
),
|
||
ft.Text("コンソールに 'show_snack: テストスナック' が出ればハンドラは動作", size=12, color=ft.Colors.BLUE_GREY_600),
|
||
], spacing=12),
|
||
padding=ft.Padding.symmetric(horizontal=16, vertical=8),
|
||
),
|
||
], 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_edit_mode が立っている場合は強制的に編集モードにする
|
||
if getattr(self, 'is_edit_mode', False):
|
||
self.is_detail_edit_mode = True
|
||
|
||
is_locked = getattr(self.editing_invoice, 'final_locked', False)
|
||
is_view_mode = not getattr(self, 'is_detail_edit_mode', False)
|
||
is_draft = bool(getattr(self.editing_invoice, 'is_draft', False))
|
||
|
||
def set_doc_type(dt: DocumentType):
|
||
self.select_document_type(dt)
|
||
self.update_main_content()
|
||
|
||
doc_type_items = [ft.PopupMenuItem(content=ft.Text(dt.value), on_click=lambda _, d=dt: set_doc_type(d)) for dt in DocumentType if dt != DocumentType.DRAFT]
|
||
# ドラフト切替
|
||
def toggle_draft(e):
|
||
self.editing_invoice.is_draft = bool(e.control.value)
|
||
self.update_main_content()
|
||
|
||
draft_toggle = ft.PopupMenuItem(
|
||
content=ft.Row([
|
||
ft.Text("下書き", size=12),
|
||
ft.Checkbox(value=is_draft, on_change=toggle_draft),
|
||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||
)
|
||
doc_type_items.insert(0, draft_toggle)
|
||
|
||
draft_suffix = " (下書き)" if is_draft else ""
|
||
|
||
if not getattr(self, 'is_detail_edit_mode', False):
|
||
title_ctrl = ft.Text(
|
||
f"{self.selected_document_type.value}{draft_suffix}",
|
||
size=18,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.BLUE_GREY_800,
|
||
)
|
||
doc_type_menu = None
|
||
else:
|
||
doc_type_menu = ft.PopupMenuButton(
|
||
content=ft.Text(
|
||
f"{self.selected_document_type.value}{draft_suffix}",
|
||
size=18,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.BLUE_GREY_800,
|
||
),
|
||
items=doc_type_items,
|
||
tooltip="帳票タイプ変更",
|
||
)
|
||
title_ctrl = doc_type_menu
|
||
|
||
draft_badge = ft.Container(
|
||
content=ft.Text("下書き", size=11, weight=ft.FontWeight.BOLD, color=ft.Colors.BROWN_800),
|
||
padding=ft.Padding.symmetric(horizontal=8, vertical=4),
|
||
bgcolor=ft.Colors.BROWN_100,
|
||
border_radius=12,
|
||
visible=is_draft,
|
||
)
|
||
|
||
delete_button = self._build_delete_draft_button(is_view_mode)
|
||
|
||
trailing_controls: List[ft.Control] = []
|
||
if delete_button:
|
||
trailing_controls.append(delete_button)
|
||
if is_draft:
|
||
trailing_controls.append(draft_badge)
|
||
|
||
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,
|
||
trailing_controls=trailing_controls,
|
||
title_control=title_ctrl,
|
||
)
|
||
|
||
body = self._create_edit_existing_screen()
|
||
body = ft.Container(
|
||
content=body,
|
||
bgcolor=ft.Colors.BROWN_50 if is_draft else None,
|
||
)
|
||
|
||
return ft.Column([
|
||
app_bar,
|
||
body,
|
||
], expand=True)
|
||
|
||
def _open_popup_menu(self, menu: ft.PopupMenuButton):
|
||
try:
|
||
menu.open = True
|
||
self.page.update()
|
||
except Exception:
|
||
pass
|
||
|
||
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 _capture_invoice_snapshot(self, invoice: Invoice) -> dict:
|
||
"""伝票状態のスナップショットを取得(差分検知用)"""
|
||
try:
|
||
return {
|
||
"document_type": invoice.document_type.value if getattr(invoice, "document_type", None) else None,
|
||
"date": invoice.date.isoformat() if getattr(invoice, "date", None) else None,
|
||
"notes": getattr(invoice, "notes", ""),
|
||
"is_draft": bool(getattr(invoice, "is_draft", False)),
|
||
"customer": {
|
||
"id": getattr(getattr(invoice, "customer", None), "id", None),
|
||
"formal_name": getattr(getattr(invoice, "customer", None), "formal_name", None),
|
||
"address": getattr(getattr(invoice, "customer", None), "address", None),
|
||
"phone": getattr(getattr(invoice, "customer", None), "phone", None),
|
||
},
|
||
"items": [
|
||
{
|
||
"description": it.description,
|
||
"quantity": it.quantity,
|
||
"unit_price": it.unit_price,
|
||
"is_discount": it.is_discount,
|
||
"product_id": it.product_id,
|
||
}
|
||
for it in (getattr(invoice, "items", None) or [])
|
||
],
|
||
}
|
||
except Exception as e:
|
||
logging.warning(f"スナップショット取得失敗: {e}")
|
||
return {}
|
||
|
||
def _is_invoice_changed(self, invoice: Invoice, snapshot: dict) -> bool:
|
||
"""スナップショットと現在の伝票状態を比較"""
|
||
if not snapshot:
|
||
return True
|
||
try:
|
||
if snapshot.get("document_type") != (invoice.document_type.value if getattr(invoice, "document_type", None) else None):
|
||
return True
|
||
if snapshot.get("date") != (invoice.date.isoformat() if getattr(invoice, "date", None) else None):
|
||
return True
|
||
if snapshot.get("notes", "") != getattr(invoice, "notes", ""):
|
||
return True
|
||
if snapshot.get("is_draft", False) != bool(getattr(invoice, "is_draft", False)):
|
||
return True
|
||
|
||
snap_cust = snapshot.get("customer", {}) or {}
|
||
cust = getattr(invoice, "customer", None)
|
||
if snap_cust.get("id") != getattr(cust, "id", None):
|
||
return True
|
||
if snap_cust.get("formal_name") != getattr(cust, "formal_name", None):
|
||
return True
|
||
if snap_cust.get("address") != getattr(cust, "address", None):
|
||
return True
|
||
if snap_cust.get("phone") != getattr(cust, "phone", None):
|
||
return True
|
||
|
||
snap_items = snapshot.get("items", []) or []
|
||
cur_items = getattr(invoice, "items", None) or []
|
||
if len(snap_items) != len(cur_items):
|
||
return True
|
||
for snap_it, cur_it in zip(snap_items, cur_items):
|
||
if snap_it.get("description") != cur_it.description:
|
||
return True
|
||
if snap_it.get("quantity") != cur_it.quantity:
|
||
return True
|
||
if snap_it.get("unit_price") != cur_it.unit_price:
|
||
return True
|
||
if snap_it.get("is_discount") != cur_it.is_discount:
|
||
return True
|
||
if snap_it.get("product_id") != cur_it.product_id:
|
||
return True
|
||
return False
|
||
except Exception as e:
|
||
logging.warning(f"差分判定失敗: {e}")
|
||
return True
|
||
|
||
def create_slip_card(self, slip, interactive: bool = True) -> ft.Control:
|
||
"""伝票カード作成(コンパクト表示)"""
|
||
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
|
||
|
||
is_draft_card = isinstance(slip, Invoice) and bool(getattr(slip, "is_draft", False))
|
||
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 self._suppress_tap_after_long_press:
|
||
self._suppress_tap_after_long_press = False
|
||
return
|
||
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._suppress_tap_after_long_press = True
|
||
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.Row(
|
||
[
|
||
ft.Text(f"No: {invoice_number}", size=10, color=theme["subtitle_color"]),
|
||
ft.Container(
|
||
content=ft.Text(
|
||
"下書き",
|
||
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,
|
||
visible=is_draft_card,
|
||
),
|
||
],
|
||
spacing=6,
|
||
),
|
||
],
|
||
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_bg = theme["draft_card_bg"] if is_draft_card else theme["card_bg"]
|
||
card_border = ft.Border.all(1, theme["draft_border"]) if is_draft_card else None
|
||
card_shadow = [
|
||
theme["draft_shadow_highlight"],
|
||
theme["draft_shadow_depth"]
|
||
] if is_draft_card else [theme["shadow"]]
|
||
|
||
card_body = ft.Container(
|
||
content=ft.Column(
|
||
[left_column, status_chip],
|
||
spacing=4,
|
||
expand=True,
|
||
),
|
||
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
|
||
bgcolor=card_bg,
|
||
border_radius=theme["card_radius"],
|
||
border=card_border,
|
||
shadow=card_shadow,
|
||
)
|
||
|
||
if not interactive:
|
||
return ft.Container(content=card_body)
|
||
|
||
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: List[ft.PopupMenuItem] = []
|
||
|
||
# 編集メニュー
|
||
if not getattr(slip, 'final_locked', False):
|
||
menu_items.append(
|
||
ft.PopupMenuItem(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.EDIT, size=16),
|
||
ft.Text("編集", size=14),
|
||
], spacing=6),
|
||
on_click=lambda _, s=slip: self.open_invoice_edit(s)
|
||
)
|
||
)
|
||
|
||
# 赤伝発行メニュー
|
||
if self.can_create_offset_invoice(slip):
|
||
menu_items.append(
|
||
ft.PopupMenuItem(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.REMOVE_CIRCLE, size=16),
|
||
ft.Text("赤伝発行", size=14),
|
||
], spacing=6),
|
||
on_click=lambda _, s=slip: self.open_offset_dialog(s)
|
||
)
|
||
)
|
||
|
||
# 削除メニュー
|
||
menu_items.append(
|
||
ft.PopupMenuItem(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.DELETE, size=16),
|
||
ft.Text("削除", size=14),
|
||
], spacing=6),
|
||
on_click=lambda _, s=slip: self.open_delete_dialog(s)
|
||
)
|
||
)
|
||
|
||
# コンテキストメニューダイアログ
|
||
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.is_edit_mode = False # 一覧タップでは編集モードを解除
|
||
self.selected_document_type = invoice.document_type
|
||
# 閲覧モードではスナップショットをクリア
|
||
self._invoice_snapshot = None
|
||
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._invoice_snapshot = self._capture_invoice_snapshot(invoice)
|
||
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_new_customer_form(self):
|
||
"""新規顧客フォームを開く(画面内遷移)"""
|
||
self.is_new_customer_form_open = True
|
||
self.update_main_content()
|
||
|
||
def start_new_invoice(self, _=None):
|
||
"""新規伝票作成ボタンから呼ばれる入口"""
|
||
self.editing_invoice = None
|
||
self._invoice_snapshot = None
|
||
self._init_new_invoice()
|
||
|
||
def create_invoice_edit_screen(self) -> ft.Container:
|
||
"""伝票編集画面(新規・編集統合)"""
|
||
# 常に詳細編集画面を使用
|
||
if not self.editing_invoice:
|
||
self._init_new_invoice()
|
||
|
||
# 既存・新規共通で詳細編集画面を返す
|
||
return self._create_edit_existing_screen()
|
||
|
||
def _init_new_invoice(self):
|
||
"""新規伝票オブジェクトを準備"""
|
||
default_customer = Customer(
|
||
id=0,
|
||
name="選択してください",
|
||
formal_name="選択してください",
|
||
address="",
|
||
phone=""
|
||
)
|
||
self.editing_invoice = Invoice(
|
||
customer=default_customer,
|
||
date=datetime.now(),
|
||
items=[],
|
||
document_type=DocumentType.INVOICE,
|
||
invoice_number="NEW-" + str(int(datetime.now().timestamp()))
|
||
)
|
||
self.editing_invoice.is_draft = True
|
||
self.selected_customer = default_customer
|
||
self.selected_document_type = DocumentType.INVOICE
|
||
self.is_detail_edit_mode = True
|
||
self.is_edit_mode = True
|
||
self.is_customer_picker_open = False
|
||
self.is_product_picker_open = False
|
||
self.is_new_customer_form_open = False
|
||
self.is_new_product_form_open = False
|
||
self.current_tab = 1
|
||
self._invoice_snapshot = None
|
||
self.update_main_content()
|
||
|
||
def back_to_list(self):
|
||
"""一覧画面に戻る"""
|
||
self.is_edit_mode = False
|
||
self.is_detail_edit_mode = False
|
||
self.current_tab = 0
|
||
self.is_customer_picker_open = False
|
||
self.is_product_picker_open = False
|
||
self.is_new_customer_form_open = False
|
||
self.is_new_product_form_open = False
|
||
self.editing_customer_for_form = None
|
||
self.editing_product_for_form = None
|
||
self.update_main_content()
|
||
|
||
def _create_edit_existing_screen(self) -> ft.Container:
|
||
"""既存伝票の編集画面(新規・編集共通)"""
|
||
# 編集不可チェック(新規作成時はFalse)
|
||
is_new_invoice = self.editing_invoice.invoice_number.startswith("NEW-")
|
||
edit_bg = ft.Colors.BROWN_50
|
||
is_draft = bool(getattr(self.editing_invoice, "is_draft", False))
|
||
|
||
# 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()
|
||
|
||
def set_doc_type(dt: DocumentType):
|
||
if self.is_detail_edit_mode and not is_locked:
|
||
self.select_document_type(dt)
|
||
self.update_main_content()
|
||
|
||
doc_type_items = [
|
||
ft.PopupMenuItem(
|
||
content=ft.Text(dt.value),
|
||
on_click=lambda _, d=dt: set_doc_type(d)
|
||
)
|
||
for dt in DocumentType
|
||
if dt != DocumentType.DRAFT
|
||
]
|
||
|
||
def toggle_draft(e):
|
||
self.editing_invoice.is_draft = bool(e.control.value)
|
||
self.update_main_content()
|
||
|
||
doc_type_label = ft.Row(
|
||
[
|
||
ft.Icon(ft.Icons.DESCRIPTION, size=14, color=ft.Colors.BLUE_GREY_700),
|
||
ft.Text(
|
||
self.selected_document_type.value,
|
||
size=12,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.BLUE_GREY_800,
|
||
),
|
||
],
|
||
spacing=4,
|
||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||
)
|
||
doc_type_chip = ft.Container(
|
||
content=doc_type_label,
|
||
padding=ft.Padding.symmetric(horizontal=12, vertical=6),
|
||
bgcolor=ft.Colors.WHITE,
|
||
border=ft.border.all(1, ft.Colors.BLUE_200),
|
||
border_radius=8,
|
||
)
|
||
|
||
if self.is_detail_edit_mode and not is_locked:
|
||
doc_type_control = ft.PopupMenuButton(
|
||
content=doc_type_chip,
|
||
items=doc_type_items,
|
||
tooltip="帳票タイプ変更",
|
||
)
|
||
else:
|
||
doc_type_control = doc_type_chip
|
||
|
||
if self.is_detail_edit_mode and not is_locked:
|
||
draft_control = ft.Switch(
|
||
label="下書き",
|
||
value=is_draft,
|
||
on_change=toggle_draft,
|
||
)
|
||
elif is_draft:
|
||
draft_control = ft.Container(
|
||
content=ft.Text("下書き", size=11, color=ft.Colors.BROWN_800),
|
||
padding=ft.Padding.symmetric(horizontal=8, vertical=4),
|
||
bgcolor=ft.Colors.BROWN_100,
|
||
border_radius=12,
|
||
)
|
||
else:
|
||
draft_control = ft.Container()
|
||
|
||
# 日付・時間ピッカー(テキスト入力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 _parse_picker_date(value) -> date:
|
||
if isinstance(value, datetime):
|
||
return value.date()
|
||
if hasattr(value, "year") and hasattr(value, "month") and hasattr(value, "day"):
|
||
return date(value.year, value.month, value.day)
|
||
raw = str(value)
|
||
if "T" in raw:
|
||
raw = raw.split("T")[0]
|
||
return date.fromisoformat(raw)
|
||
|
||
def on_date_change(e):
|
||
if not e.data:
|
||
return
|
||
try:
|
||
picked_date = _parse_picker_date(e.data)
|
||
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(),
|
||
style=self.edit_button_style,
|
||
)
|
||
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(),
|
||
style=self.edit_button_style,
|
||
)
|
||
|
||
# 備考フィールド
|
||
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,
|
||
bgcolor=edit_bg if not is_view_mode and not is_locked else None,
|
||
)
|
||
|
||
def toggle_edit_mode(_):
|
||
"""編集モード切替"""
|
||
old_mode = getattr(self, 'is_detail_edit_mode', False)
|
||
self.is_detail_edit_mode = not old_mode
|
||
if not old_mode and not is_new_invoice and self.editing_invoice:
|
||
self._invoice_snapshot = self._capture_invoice_snapshot(self.editing_invoice)
|
||
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
|
||
|
||
# 差分なし判定(既存伝票のみ)
|
||
if not is_new_invoice and self._invoice_snapshot:
|
||
if not self._is_invoice_changed(self.editing_invoice, self._invoice_snapshot):
|
||
self._show_snack("変更はありませんでした", ft.Colors.BLUE_GREY_600)
|
||
self.is_detail_edit_mode = False
|
||
self.update_main_content()
|
||
return
|
||
|
||
# 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,
|
||
is_draft=bool(getattr(self.editing_invoice, 'is_draft', False)),
|
||
)
|
||
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._invoice_snapshot = self._capture_invoice_snapshot(self.editing_invoice)
|
||
# 一覧を更新して新規作成画面を閉じる
|
||
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)
|
||
# 保存後の顧客選択状態を保持
|
||
self.selected_customer = getattr(self.editing_invoice, "customer", None)
|
||
self._invoice_snapshot = self._capture_invoice_snapshot(self.editing_invoice)
|
||
# 設定により遷移先を変更
|
||
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)
|
||
|
||
customer_label = self.editing_invoice.customer.formal_name
|
||
if not customer_label or customer_label == "選択してください":
|
||
customer_label = "顧客を選択"
|
||
|
||
if (not is_view_mode and not is_locked) or is_new_invoice:
|
||
customer_control = ft.Button(
|
||
content=ft.Text(customer_label, no_wrap=True, color=ft.Colors.BLUE_GREY_800),
|
||
width=220,
|
||
height=36,
|
||
on_click=lambda _: select_customer(),
|
||
style=self.edit_button_style,
|
||
)
|
||
else:
|
||
customer_control = ft.Text(
|
||
customer_label,
|
||
size=13,
|
||
weight=ft.FontWeight.BOLD,
|
||
)
|
||
|
||
date_time_row = 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,
|
||
),
|
||
time_button if time_button else ft.Text(
|
||
self.editing_invoice.date.strftime("%H:%M"),
|
||
size=12,
|
||
color=ft.Colors.BLUE_GREY_600,
|
||
),
|
||
],
|
||
spacing=12,
|
||
)
|
||
|
||
customer_block = ft.Column(
|
||
[
|
||
customer_control,
|
||
date_time_row,
|
||
],
|
||
spacing=4,
|
||
alignment=ft.MainAxisAlignment.START,
|
||
)
|
||
|
||
summary_card = ft.Container(
|
||
content=ft.Column(
|
||
[
|
||
ft.Row(
|
||
[
|
||
ft.Row(
|
||
[
|
||
doc_type_control,
|
||
ft.Text(
|
||
f"No: {self.editing_invoice.invoice_number}",
|
||
size=10,
|
||
color=self.invoice_card_theme["subtitle_color"],
|
||
),
|
||
],
|
||
spacing=6,
|
||
),
|
||
draft_control,
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||
),
|
||
ft.Row(
|
||
[
|
||
customer_block,
|
||
self._build_totals_row(),
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||
),
|
||
summary_badges,
|
||
],
|
||
spacing=8,
|
||
),
|
||
padding=ft.Padding.symmetric(horizontal=12, vertical=10),
|
||
bgcolor=ft.Colors.BROWN_50 if is_draft else self.invoice_card_theme["card_bg"],
|
||
border_radius=self.invoice_card_theme["card_radius"],
|
||
shadow=[self.invoice_card_theme["shadow"]],
|
||
)
|
||
|
||
items_section = ft.Container(
|
||
content=ft.Column(
|
||
[
|
||
ft.Row(
|
||
[
|
||
ft.Text("明細", size=13, weight=ft.FontWeight.BOLD),
|
||
ft.Container(expand=True),
|
||
ft.Button(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.SHUFFLE, size=16, color=ft.Colors.BLUE_GREY_700),
|
||
ft.Text("並べ替え" + ("ON" if self.is_reorder_mode else ""), size=12),
|
||
], spacing=6),
|
||
style=self.edit_button_style,
|
||
on_click=self.toggle_reorder_mode,
|
||
disabled=is_locked or is_view_mode,
|
||
) if not is_locked and not is_view_mode else ft.Container(),
|
||
ft.Button(
|
||
content=ft.Row([
|
||
ft.Icon(ft.Icons.ADD_CIRCLE_OUTLINE, size=16, color=ft.Colors.BLUE_GREY_700),
|
||
ft.Text("行追加", size=12),
|
||
], spacing=6),
|
||
style=self.edit_button_style,
|
||
on_click=lambda _: self._add_item_row(),
|
||
disabled=is_locked or is_view_mode,
|
||
) if not is_locked and not is_view_mode else ft.Container(),
|
||
],
|
||
spacing=8,
|
||
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.BROWN_50 if is_draft else 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, color=ft.Colors.BLUE_GREY_700),
|
||
ft.Text("PDF生成", size=12),
|
||
], spacing=6),
|
||
style=self.edit_button_style,
|
||
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(
|
||
[
|
||
notes_section,
|
||
],
|
||
spacing=12,
|
||
scroll=ft.ScrollMode.AUTO,
|
||
expand=True,
|
||
),
|
||
expand=True,
|
||
)
|
||
|
||
top_stack = ft.Column(
|
||
[
|
||
summary_card,
|
||
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, index: int, field_name: str, value: str):
|
||
"""明細フィールドを更新"""
|
||
if not self.editing_invoice or index >= len(self.editing_invoice.items):
|
||
return
|
||
|
||
item = self.editing_invoice.items[index]
|
||
|
||
# デバッグ用:更新前の値をログ出力
|
||
old_value = getattr(item, field_name)
|
||
logging.debug(f"Updating item {index} {field_name}: '{old_value}' -> '{value}'")
|
||
|
||
if field_name == 'description':
|
||
item.description = value
|
||
elif field_name == 'quantity':
|
||
try:
|
||
# 空文字の場合は1を設定
|
||
if not value or value.strip() == '':
|
||
item.quantity = 1
|
||
else:
|
||
item.quantity = int(value)
|
||
logging.debug(f"Quantity updated to: {item.quantity}")
|
||
except ValueError as e:
|
||
item.quantity = 1
|
||
logging.error(f"Quantity update error: {e}")
|
||
elif field_name == 'unit_price':
|
||
try:
|
||
# 空文字の場合は0を設定
|
||
if not value or value.strip() == '':
|
||
item.unit_price = 0
|
||
else:
|
||
item.unit_price = int(value)
|
||
logging.debug(f"Unit price updated to: {item.unit_price}")
|
||
except ValueError as e:
|
||
item.unit_price = 0
|
||
logging.error(f"Unit price update error: {e}")
|
||
|
||
self._refresh_item_row(index)
|
||
self._refresh_total_amount_display()
|
||
self.page.update()
|
||
|
||
def _select_product_for_row(self, item_index: int, product_id: Optional[int]):
|
||
"""商品選択ドロップダウンから呼ばれ、商品情報を行に反映"""
|
||
if not self.editing_invoice or item_index >= len(self.editing_invoice.items):
|
||
return
|
||
if not product_id:
|
||
return
|
||
try:
|
||
products = self.app_service.product.get_all_products()
|
||
product = next((p for p in products if p.id == product_id), None)
|
||
if not product:
|
||
return
|
||
item = self.editing_invoice.items[item_index]
|
||
item.product_id = product.id
|
||
item.description = product.name
|
||
item.unit_price = product.unit_price
|
||
item.quantity = 1
|
||
self._refresh_item_row(item_index, full_refresh=True)
|
||
self._refresh_total_amount_display()
|
||
# 行だけ更新し、再描画は即時に行う
|
||
self.page.update()
|
||
except Exception as e:
|
||
logging.warning(f"商品選択反映失敗: {e}")
|
||
|
||
def _reorder_item_row(self, old_index: int, new_index: int):
|
||
"""明細行のドラッグ&ドロップ並び替え"""
|
||
if not self.editing_invoice:
|
||
return
|
||
items = self.editing_invoice.items
|
||
if old_index < 0 or old_index >= len(items) or new_index < 0 or new_index >= len(items):
|
||
return
|
||
item = items.pop(old_index)
|
||
items.insert(new_index, item)
|
||
self.update_main_content()
|
||
|
||
def _remove_empty_items(self):
|
||
"""商品名が無く数量も単価も0の明細を削除"""
|
||
if not self.editing_invoice:
|
||
return
|
||
|
||
# 空行を特定(ただし最低1行は残す)
|
||
non_empty_items = []
|
||
empty_count = 0
|
||
|
||
for item in self.editing_invoice.items:
|
||
if (not item.description or item.description.strip() == "") and \
|
||
item.quantity == 0 and \
|
||
item.unit_price == 0:
|
||
empty_count += 1
|
||
# 最低1行は残すため、空行が複数ある場合のみ削除
|
||
if empty_count > 1:
|
||
continue # 削除
|
||
non_empty_items.append(item)
|
||
|
||
self.editing_invoice.items = non_empty_items
|
||
|
||
def _create_view_mode_table(self, items: List[InvoiceItem]) -> ft.Column:
|
||
"""表示モード:フレキシブルな表形式で整然と表示"""
|
||
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,
|
||
products=self.app_service.product.get_all_products(),
|
||
on_product_select=self._open_product_picker_for_row,
|
||
row_refs=self._ensure_item_row_refs(),
|
||
enable_reorder=not is_locked and self.is_reorder_mode,
|
||
on_reorder=lambda old, new: self._reorder_item_row(old, new),
|
||
)
|
||
|
||
def _open_product_picker_for_row(self, item_index: int):
|
||
"""商品選択ボタンから商品マスタ選択画面を開き、選択結果を行に反映"""
|
||
self._pending_product_row = item_index
|
||
# 商品マスタが無ければ新規登録画面へ
|
||
try:
|
||
products = self.app_service.product.get_all_products()
|
||
except Exception:
|
||
products = []
|
||
if not products:
|
||
self.is_new_product_form_open = True
|
||
self.is_product_picker_open = False
|
||
self.update_main_content()
|
||
return
|
||
self.is_product_picker_open = True
|
||
self.update_main_content()
|
||
|
||
def _assign_product_to_pending_row(self, product_id: int):
|
||
row = getattr(self, "_pending_product_row", None)
|
||
if row is None or not self.editing_invoice or row >= len(self.editing_invoice.items):
|
||
return
|
||
try:
|
||
products = self.app_service.product.get_all_products()
|
||
product = next((p for p in products if p.id == product_id), None)
|
||
if not product:
|
||
return
|
||
item = self.editing_invoice.items[row]
|
||
item.product_id = product.id
|
||
item.description = product.name
|
||
item.unit_price = product.unit_price
|
||
item.quantity = 1
|
||
# 先にフラグを戻してから画面更新(詳細に即戻る)
|
||
self.is_product_picker_open = False
|
||
self.is_new_product_form_open = False
|
||
self._refresh_item_row(row, full_refresh=True)
|
||
self._refresh_total_amount_display()
|
||
self.update_main_content()
|
||
except Exception as e:
|
||
logging.warning(f"商品選択適用失敗: {e}")
|
||
finally:
|
||
self._pending_product_row = None
|
||
|
||
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 _build_delete_draft_button(self, is_view_mode: bool) -> Optional[ft.Control]:
|
||
if not is_view_mode:
|
||
return None
|
||
invoice = getattr(self, "editing_invoice", None)
|
||
if not invoice or not getattr(invoice, "is_draft", False):
|
||
return None
|
||
return ft.IconButton(
|
||
icon=ft.Icons.DELETE_FOREVER,
|
||
icon_color=ft.Colors.RED_400,
|
||
tooltip="下書きを削除",
|
||
on_click=self._confirm_delete_current_invoice,
|
||
)
|
||
|
||
def _confirm_delete_current_invoice(self, _=None):
|
||
invoice = getattr(self, "editing_invoice", None)
|
||
if not invoice or not getattr(invoice, "is_draft", False):
|
||
return
|
||
dialog = ft.AlertDialog(
|
||
modal=True,
|
||
title=ft.Text("下書きを削除"),
|
||
content=ft.Text("この下書きを削除しますか? この操作は元に戻せません。"),
|
||
actions=[
|
||
ft.TextButton("キャンセル", on_click=self._close_dialog),
|
||
ft.TextButton("削除", style=ft.ButtonStyle(color=ft.Colors.RED_600), on_click=lambda _: self._delete_current_draft()),
|
||
],
|
||
actions_alignment=ft.MainAxisAlignment.END,
|
||
)
|
||
self.page.dialog = dialog
|
||
dialog.open = True
|
||
self.page.update()
|
||
|
||
def _close_dialog(self, _=None):
|
||
dialog = getattr(self.page, "dialog", None)
|
||
if dialog:
|
||
dialog.open = False
|
||
self.page.update()
|
||
|
||
def _delete_current_draft(self):
|
||
invoice = getattr(self, "editing_invoice", None)
|
||
if not invoice or not getattr(invoice, "is_draft", False):
|
||
self._close_dialog()
|
||
return
|
||
try:
|
||
success = self.app_service.invoice.delete_invoice_by_uuid(invoice.uuid)
|
||
if success:
|
||
self._show_snack("下書きを削除しました", ft.Colors.GREEN_600)
|
||
self.editing_invoice = None
|
||
self.invoices = self.app_service.invoice.get_recent_invoices(20)
|
||
self.current_tab = 0
|
||
else:
|
||
self._show_snack("削除に失敗しました", ft.Colors.RED_600)
|
||
except Exception as e:
|
||
logging.error(f"ドラフト削除エラー: {e}")
|
||
self._show_snack("削除中にエラーが発生しました", ft.Colors.RED_600)
|
||
finally:
|
||
self._close_dialog()
|
||
self.update_main_content()
|
||
|
||
def _build_totals_row(self) -> ft.Column:
|
||
subtotal = self.editing_invoice.subtotal if self.editing_invoice else 0
|
||
tax = self.editing_invoice.tax if self.editing_invoice else 0
|
||
total = subtotal + tax
|
||
|
||
self._tax_amount_text = ft.Text(f"消費税 ¥{tax:,}", size=12, color=ft.Colors.BLUE_GREY_700)
|
||
self._total_amount_text = ft.Text(
|
||
f"合計 ¥{total:,}", size=15, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_700
|
||
)
|
||
|
||
return ft.Column(
|
||
[
|
||
self._tax_amount_text,
|
||
self._total_amount_text,
|
||
],
|
||
spacing=2,
|
||
alignment=ft.MainAxisAlignment.END,
|
||
)
|
||
|
||
def _refresh_total_amount_display(self):
|
||
if not self.editing_invoice:
|
||
return
|
||
if self._tax_amount_text:
|
||
self._tax_amount_text.value = f"消費税 ¥{self.editing_invoice.tax:,}"
|
||
if self._total_amount_text:
|
||
total = self.editing_invoice.total_amount
|
||
self._total_amount_text.value = f"合計 ¥{total:,}"
|
||
|
||
def _ensure_item_row_refs(self) -> Dict[int, Dict[str, ft.Control]]:
|
||
self._item_row_refs = {}
|
||
return self._item_row_refs
|
||
|
||
def _refresh_item_row(self, index: int, full_refresh: bool = False):
|
||
if not self.editing_invoice:
|
||
return
|
||
row_refs = getattr(self, "_item_row_refs", {})
|
||
row_controls = row_refs.get(index)
|
||
if not row_controls or index >= len(self.editing_invoice.items):
|
||
return
|
||
item = self.editing_invoice.items[index]
|
||
|
||
if full_refresh:
|
||
product_button = row_controls.get("product")
|
||
if product_button and isinstance(product_button.content, ft.Text):
|
||
product_button.content.value = item.description or "商品選択"
|
||
quantity_field = row_controls.get("quantity")
|
||
if quantity_field:
|
||
quantity_field.value = str(item.quantity)
|
||
unit_price_field = row_controls.get("unit_price")
|
||
if unit_price_field:
|
||
unit_price_field.value = f"{item.unit_price:,}"
|
||
|
||
subtotal_text = row_controls.get("subtotal")
|
||
if subtotal_text:
|
||
subtotal_text.value = f"¥{item.subtotal:,}"
|
||
|
||
def _show_snack(self, message: str, color=ft.Colors.BLUE_GREY_800):
|
||
try:
|
||
logging.info(f"show_snack: {message}")
|
||
snack = ft.SnackBar(content=ft.Text(message), bgcolor=color)
|
||
# attach to page to ensure it's rendered even if show_snack_bar is ignored
|
||
self.page.snack_bar = snack
|
||
# 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.open = True
|
||
# ensure open flag is set for both paths
|
||
if hasattr(self.page, "snack_bar"):
|
||
self.page.snack_bar.open = True
|
||
# in-app toast fallback (必ず画面に表示される)
|
||
if self._toast is not None:
|
||
self._toast_text.value = message
|
||
self._toast.content.bgcolor = color if color else ft.Colors.BLUE_GREY_800
|
||
self._toast.visible = True
|
||
self._toast.opacity = 1
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
loop.call_later(3, self._hide_toast)
|
||
except RuntimeError:
|
||
threading.Timer(3, self._hide_toast).start()
|
||
self.page.update()
|
||
except Exception as e:
|
||
logging.warning(f"snack_bar表示失敗: {e}")
|
||
|
||
def _hide_toast(self):
|
||
try:
|
||
if self._toast is not None:
|
||
self._toast.opacity = 0
|
||
self._toast.visible = False
|
||
self.page.update()
|
||
except Exception as e:
|
||
logging.warning(f"toast hide failed: {e}")
|
||
|
||
def _build_theme_presets(self) -> Dict[str, Dict[str, Any]]:
|
||
common_radius = 18
|
||
light_palette = {
|
||
"page_bg": "#F3F2FB",
|
||
"card_bg": ft.Colors.WHITE,
|
||
"card_radius": common_radius,
|
||
"shadow": ft.BoxShadow(
|
||
blur_radius=16,
|
||
spread_radius=0,
|
||
color="#D5D8F0",
|
||
offset=ft.Offset(0, 6),
|
||
),
|
||
"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",
|
||
"draft_card_bg": "#F5EEE4",
|
||
"draft_border": "#D7C4AF",
|
||
"draft_shadow_highlight": ft.BoxShadow(
|
||
blur_radius=8,
|
||
spread_radius=0,
|
||
color="#FFFFFF",
|
||
offset=ft.Offset(-2, -2),
|
||
),
|
||
"draft_shadow_depth": ft.BoxShadow(
|
||
blur_radius=14,
|
||
spread_radius=2,
|
||
color="#C3A88C",
|
||
offset=ft.Offset(4, 6),
|
||
),
|
||
"badge_bg": "#35C46B",
|
||
"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",
|
||
},
|
||
}
|
||
|
||
monokai_palette = {
|
||
"page_bg": "#272822",
|
||
"card_bg": "#3E3D32",
|
||
"card_radius": common_radius,
|
||
"shadow": ft.BoxShadow(
|
||
blur_radius=12,
|
||
spread_radius=0,
|
||
color="#00000055",
|
||
offset=ft.Offset(0, 4),
|
||
),
|
||
"icon_default_bg": "#F92672",
|
||
"title_color": "#F8F8F2",
|
||
"subtitle_color": "#A6E22E",
|
||
"amount_color": "#66D9EF",
|
||
"tag_text_color": "#F8F8F2",
|
||
"tag_bg": "#75715E",
|
||
"draft_card_bg": "#4F3F2F",
|
||
"draft_border": "#CDAA7D",
|
||
"draft_shadow_highlight": ft.BoxShadow(
|
||
blur_radius=6,
|
||
spread_radius=0,
|
||
color="#ffffff22",
|
||
offset=ft.Offset(-1, -1),
|
||
),
|
||
"draft_shadow_depth": ft.BoxShadow(
|
||
blur_radius=10,
|
||
spread_radius=0,
|
||
color="#00000055",
|
||
offset=ft.Offset(2, 3),
|
||
),
|
||
"badge_bg": "#AE81FF",
|
||
"doc_type_palette": {
|
||
DocumentType.INVOICE.value: "#F92672",
|
||
DocumentType.ESTIMATE.value: "#AE81FF",
|
||
DocumentType.DELIVERY.value: "#A6E22E",
|
||
DocumentType.RECEIPT.value: "#FD971F",
|
||
DocumentType.SALES.value: "#66D9EF",
|
||
DocumentType.DRAFT.value: "#75715E",
|
||
},
|
||
}
|
||
|
||
return {
|
||
"light": light_palette,
|
||
"monokai": monokai_palette,
|
||
}
|
||
|
||
def apply_theme(self, name: str):
|
||
preset = self.theme_presets.get(name) or self.theme_presets.get("light")
|
||
self.current_theme = name if name in self.theme_presets else "light"
|
||
self.invoice_card_theme = {k: v for k, v in preset.items() if k != "doc_type_palette"}
|
||
self.doc_type_palette = preset["doc_type_palette"].copy()
|
||
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
|
||
# 既存伝票一覧も更新しておく(顧客名がすぐ反映されるように)
|
||
try:
|
||
self.invoices = self.app_service.invoice.get_recent_invoices(20)
|
||
except Exception:
|
||
pass
|
||
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_product_picker_screen(self) -> ft.Container:
|
||
"""商品選択画面(明細のボタンから遷移)"""
|
||
try:
|
||
products = self.app_service.product.get_all_products()
|
||
except Exception:
|
||
products = []
|
||
|
||
def close_picker(_=None):
|
||
self.is_product_picker_open = False
|
||
self.update_main_content()
|
||
|
||
def open_new_product(_=None):
|
||
self.is_new_product_form_open = True
|
||
self.is_product_picker_open = False
|
||
self.update_main_content()
|
||
|
||
if not products:
|
||
return ft.Container(
|
||
content=ft.Column([
|
||
ft.Row([
|
||
ft.IconButton(ft.Icons.ARROW_BACK, on_click=close_picker),
|
||
ft.Text("商品マスタがありません。新規登録してください。", weight=ft.FontWeight.BOLD),
|
||
]),
|
||
ft.Container(height=12),
|
||
ft.Button("新規商品を登録", on_click=open_new_product),
|
||
], spacing=10),
|
||
padding=ft.Padding.all(16),
|
||
expand=True,
|
||
)
|
||
|
||
def build_card(p: Product, idx: int):
|
||
return ft.GestureDetector(
|
||
on_tap=lambda _=None, pid=p.id: self._assign_product_to_pending_row(pid),
|
||
on_long_press=lambda _=None, prod=p: self._open_product_edit(prod),
|
||
content=ft.Container(
|
||
content=ft.Row([
|
||
ft.Column([
|
||
ft.Text(p.name, weight=ft.FontWeight.BOLD),
|
||
ft.Text(f"単価: ¥{p.unit_price:,}", size=12, color=ft.Colors.BLUE_GREY_600),
|
||
ft.Text(p.description or "", size=12, color=ft.Colors.BLUE_GREY_400),
|
||
], spacing=2, expand=True),
|
||
ft.Icon(ft.Icons.CHEVRON_RIGHT),
|
||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||
padding=ft.Padding.all(12),
|
||
border=ft.Border.all(1, ft.Colors.BLUE_GREY_100),
|
||
border_radius=8,
|
||
bgcolor=ft.Colors.WHITE,
|
||
)
|
||
)
|
||
|
||
product_cards = [build_card(p, i) for i, p in enumerate(products)]
|
||
|
||
return ft.Container(
|
||
content=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.Button("新規商品", on_click=open_new_product),
|
||
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||
ft.Divider(),
|
||
ft.Column(product_cards, spacing=6, scroll=ft.ScrollMode.AUTO, expand=True),
|
||
], expand=True),
|
||
padding=ft.Padding.all(16),
|
||
expand=True,
|
||
)
|
||
|
||
def create_new_product_screen(self) -> ft.Container:
|
||
"""新規商品登録画面"""
|
||
editing_product = getattr(self, "editing_product_for_form", None)
|
||
name_field = ft.TextField(label="商品名", value=getattr(editing_product, "name", ""), expand=True)
|
||
price_field = ft.TextField(label="単価", value=str(getattr(editing_product, "unit_price", "")), keyboard_type=ft.KeyboardType.NUMBER)
|
||
desc_field = ft.TextField(label="説明", value=getattr(editing_product, "description", ""), multiline=True, min_lines=2, max_lines=3)
|
||
|
||
def save_product(_):
|
||
name = (name_field.value or "").strip()
|
||
price_raw = (price_field.value or "0").replace(",", "").strip()
|
||
desc = (desc_field.value or "").strip()
|
||
if not name:
|
||
self._show_snack("商品名は必須です", ft.Colors.RED_600)
|
||
return
|
||
try:
|
||
price = int(price_raw)
|
||
except ValueError:
|
||
self._show_snack("単価は数値で入力してください", ft.Colors.RED_600)
|
||
return
|
||
prod = Product(id=getattr(editing_product, "id", None), name=name, unit_price=price, description=desc)
|
||
ok = self.app_service.product.save_product(prod)
|
||
if ok:
|
||
self._show_snack("商品を保存しました", ft.Colors.GREEN_600)
|
||
self.editing_product_for_form = None
|
||
self.is_new_product_form_open = False
|
||
self.is_product_picker_open = True
|
||
self.update_main_content()
|
||
else:
|
||
self._show_snack("商品保存に失敗しました", ft.Colors.RED_600)
|
||
|
||
def cancel(_):
|
||
self.editing_product_for_form = None
|
||
self.is_new_product_form_open = False
|
||
# 直前が商品ピッカーなら戻す
|
||
self.is_product_picker_open = True
|
||
self.update_main_content()
|
||
|
||
return ft.Container(
|
||
content=ft.Column([
|
||
ft.Row([
|
||
ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel),
|
||
ft.Text("商品登録", size=18, weight=ft.FontWeight.BOLD),
|
||
]),
|
||
ft.Divider(),
|
||
ft.Column([
|
||
name_field,
|
||
price_field,
|
||
desc_field,
|
||
ft.Row([
|
||
ft.Button("保存", on_click=save_product, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE),
|
||
ft.Button("キャンセル", on_click=cancel),
|
||
], spacing=8),
|
||
], spacing=10),
|
||
], spacing=12),
|
||
padding=ft.Padding.all(16),
|
||
expand=True,
|
||
)
|
||
|
||
def _open_product_edit(self, product: Product):
|
||
"""商品マスタカード長押しで編集フォームへ"""
|
||
self.editing_product_for_form = product
|
||
self.is_product_picker_open = False
|
||
self.is_new_product_form_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}")
|
||
|
||
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="",
|
||
is_draft=bool(getattr(self, "editing_invoice", None) and getattr(self.editing_invoice, "is_draft", False)),
|
||
)
|
||
|
||
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)
|