diff --git a/components/editor_framework.py b/components/editor_framework.py index 4afaad1..e2afd04 100644 --- a/components/editor_framework.py +++ b/components/editor_framework.py @@ -1,11 +1,9 @@ """編集系画面で再利用するEditorフレームワーク(最小版)。""" from dataclasses import dataclass -from typing import Callable, List - +from typing import Callable, List, Optional import flet as ft - -from models.invoice_models import InvoiceItem +from models.invoice_models import InvoiceItem, Product @dataclass @@ -64,7 +62,7 @@ def validate_invoice_items(items: List[InvoiceItem]) -> ValidationResult: def build_invoice_items_view_table(items: List[InvoiceItem]) -> ft.Column: - """表示モードの明細テーブル。""" + """読み取り専用の明細テーブル。""" header_row = ft.Row( [ ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), @@ -95,11 +93,12 @@ def build_invoice_items_view_table(items: List[InvoiceItem]) -> ft.Column: ) ) + list_control = ft.Column(data_rows, spacing=4) return ft.Column( [ header_row, ft.Divider(height=1, color=ft.Colors.GREY_400), - ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), + ft.Container(content=list_control, height=250, padding=0), ] ) @@ -109,11 +108,13 @@ def build_invoice_items_edit_table( is_locked: bool, on_update_field: Callable[[int, str, str], None], on_delete_row: Callable[[int], None], + products: List[Product], + on_product_select: Callable[[int], None] | None = None, ) -> ft.Column: """編集モードの明細テーブル。""" header_row = ft.Row( [ - ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), + ft.Text("商品", size=12, weight=ft.FontWeight.BOLD, width=180), ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35), ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), @@ -121,17 +122,20 @@ def build_invoice_items_edit_table( ] ) - data_rows = [] + data_rows: List[ft.Control] = [] for i, item in enumerate(items): - product_field = ft.TextField( - value=item.description, - text_size=12, - height=28, - expand=True, - border=ft.border.all(1, ft.Colors.BLUE_200), - bgcolor=ft.Colors.WHITE, - content_padding=ft.padding.all(5), - on_change=lambda e, idx=i: on_update_field(idx, "description", e.control.value), + product_label = item.description if item.description else "商品選択" + product_field = ft.Button( + content=ft.Text(product_label, no_wrap=True), + width=180, + height=36, + on_click=(lambda _, idx=i: on_product_select(idx)) if on_product_select else None, + disabled=is_locked, + style=ft.ButtonStyle( + padding=ft.Padding.symmetric(horizontal=10, vertical=6), + bgcolor=ft.Colors.BLUE_GREY_100, + shape=ft.RoundedRectangleBorder(radius=4), + ), ) quantity_field = ft.TextField( @@ -183,14 +187,24 @@ def build_invoice_items_edit_table( text_align=ft.TextAlign.RIGHT, ), delete_button, - ] + ], + key=f"row-{i}-{item.description}", ) ) + list_control: ft.Control = ft.Column(data_rows, spacing=4) + return ft.Column( [ header_row, ft.Divider(height=1, color=ft.Colors.GREY_400), - ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), + ft.Container( + content=list_control, + height=250, + padding=0, + bgcolor=None, + border_radius=0, + expand=False, + ), ] ) diff --git a/main.py b/main.py index 9950417..f33337c 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ import threading import sqlite3 from datetime import datetime from typing import List, Dict, Optional -from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem +from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem, Product from components.customer_picker import CustomerPickerModal from components.explorer_framework import ( ExplorerQueryState, @@ -74,7 +74,8 @@ 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): + bottom: Optional[ft.Control] = None, trailing_controls: Optional[list] = None, + title_control: Optional[ft.Control] = None): super().__init__() self.title = title self.show_back = show_back @@ -86,6 +87,7 @@ class AppBar(ft.Container): self.action_tooltip = action_tooltip self.bottom = bottom self.trailing_controls = trailing_controls or [] + self.title_control = title_control self.bgcolor = ft.Colors.BLUE_GREY_50 self.padding = ft.Padding.symmetric(horizontal=16, vertical=8) @@ -110,14 +112,15 @@ class AppBar(ft.Container): 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=ft.Text( - self.title, - size=18, - weight=ft.FontWeight.W_500, - color=ft.Colors.BLUE_GREY_800 - ), + content=title_ctrl, expand=True, alignment=ft.alignment.Alignment(0, 0) # 中央揃え ) @@ -167,6 +170,7 @@ class FlutterStyleDashboard: 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 @@ -186,7 +190,11 @@ class FlutterStyleDashboard: ), ) 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", @@ -340,10 +348,16 @@ class FlutterStyleDashboard: 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("伝票一覧画面を表示") @@ -429,6 +443,24 @@ class FlutterStyleDashboard: is_locked = getattr(self.editing_invoice, 'final_locked', False) is_view_mode = not getattr(self, 'is_detail_edit_mode', 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 not getattr(self, 'is_detail_edit_mode', False): + # 閲覧時は単なるテキスト表示 + title_ctrl = ft.Text(self.selected_document_type.value, 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(self.selected_document_type.value, size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_800), + items=doc_type_items, + tooltip="帳票タイプ変更", + ) + title_ctrl = doc_type_menu + app_bar = AppBar( title="伝票詳細", show_back=True, @@ -438,57 +470,23 @@ class FlutterStyleDashboard: 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=[doc_type_menu] if doc_type_menu else [], + title_control=title_ctrl, ) - # 編集/閲覧モード共通の画面(元の編集用ビルダーを利用) + body = self._create_edit_existing_screen() return ft.Column([ app_bar, - ft.Container(content=self._toast, padding=ft.Padding.symmetric(horizontal=16, vertical=4)), body, ], expand=True) - def _build_doc_type_bar(self, is_locked: bool, is_view_mode: bool) -> ft.Control: - """AppBar下部の帳票種別チップ""" - document_types = list(DocumentType) - can_select = not (is_locked or is_view_mode) - - active_type = getattr(self, "selected_document_type", DocumentType.INVOICE) - chips = [ - ft.GestureDetector( - content=ft.Container( - content=ft.Text( - dt.value, - size=11, - weight=ft.FontWeight.BOLD if dt == active_type else ft.FontWeight.NORMAL, - color=ft.Colors.WHITE if dt == active_type else ft.Colors.BLUE_GREY_600, - text_align=ft.TextAlign.CENTER, - ), - padding=ft.Padding.symmetric(horizontal=10, vertical=6), - bgcolor=ft.Colors.BLUE_600 if dt == active_type else ft.Colors.BLUE_GREY_100, - border_radius=18, - ), - on_tap=(lambda e, x=dt: self.select_document_type(x)) if can_select else None, - ) - for dt in document_types - ] - - return ft.Container( - content=ft.Row( - controls=[ - ft.Row([ - ft.Icon(ft.Icons.EDIT if not is_locked else ft.Icons.LOCK, size=16, color=ft.Colors.BLUE_GREY_500), - ft.Text("帳票タイプ", size=11, color=ft.Colors.BLUE_GREY_600), - ], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER), - ft.Row(controls=chips, spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER), - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - padding=ft.Padding.symmetric(horizontal=12, vertical=6), - bgcolor=ft.Colors.BLUE_GREY_50, - border_radius=12, - ) + def _open_popup_menu(self, menu: ft.PopupMenuButton): + try: + menu.open = True + self.page.update() + except Exception: + pass def on_detail_appbar_action(self): """詳細画面の右上アクション(編集/保存)""" @@ -740,6 +738,77 @@ class FlutterStyleDashboard: 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", ""), + "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 + + 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) -> ft.Container: """伝票カード作成(コンパクト表示)""" theme = self.invoice_card_theme @@ -910,17 +979,17 @@ class FlutterStyleDashboard: close_dialog(_) # メニューアイテムの構築 - menu_items = [] + menu_items: List[ft.PopupMenuItem] = [] # 編集メニュー if not getattr(slip, 'final_locked', False): menu_items.append( ft.PopupMenuItem( - text=ft.Row([ + content=ft.Row([ ft.Icon(ft.Icons.EDIT, size=16), ft.Text("編集", size=14), - ], spacing=8), - on_click=edit_invoice + ], spacing=6), + on_click=lambda _, s=slip: self.open_invoice_edit(s) ) ) @@ -928,22 +997,22 @@ class FlutterStyleDashboard: if self.can_create_offset_invoice(slip): menu_items.append( ft.PopupMenuItem( - text=ft.Row([ + content=ft.Row([ ft.Icon(ft.Icons.REMOVE_CIRCLE, size=16), ft.Text("赤伝発行", size=14), - ], spacing=8), - on_click=create_offset + ], spacing=6), + on_click=lambda _, s=slip: self.open_offset_dialog(s) ) ) # 削除メニュー menu_items.append( ft.PopupMenuItem( - text=ft.Row([ + content=ft.Row([ ft.Icon(ft.Icons.DELETE, size=16), ft.Text("削除", size=14), - ], spacing=8), - on_click=delete_invoice + ], spacing=6), + on_click=lambda _, s=slip: self.open_delete_dialog(s) ) ) @@ -966,7 +1035,10 @@ class FlutterStyleDashboard: 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): @@ -975,6 +1047,8 @@ class FlutterStyleDashboard: 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): @@ -1030,6 +1104,7 @@ class FlutterStyleDashboard: 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: @@ -1062,8 +1137,24 @@ class FlutterStyleDashboard: 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: @@ -1234,6 +1325,8 @@ class FlutterStyleDashboard: """編集モード切替""" 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() @@ -1245,6 +1338,12 @@ class FlutterStyleDashboard: 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) + return + # UIで更新された明細を保存前に正規化して確定 normalized_items = normalize_invoice_items(self.editing_invoice.items) validation = validate_invoice_items(normalized_items) @@ -1302,6 +1401,7 @@ class FlutterStyleDashboard: 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)}") @@ -1344,6 +1444,7 @@ class FlutterStyleDashboard: 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 @@ -1632,6 +1733,37 @@ class FlutterStyleDashboard: # 入力途中で画面全体を再描画すると編集値が飛びやすいため、 # ここではモデル更新のみに留める(再描画は保存/行追加/行削除時に実施)。 + + 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 + # 行だけ更新し、再描画は即時に行う + self.update_main_content() + 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の明細を削除""" @@ -1665,8 +1797,48 @@ class FlutterStyleDashboard: 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, ) + 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 + # 先にフラグを戻してから画面更新(詳細に即戻る) + self.is_product_picker_open = False + self.is_new_product_form_open = False + 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: @@ -1859,6 +2031,137 @@ class FlutterStyleDashboard: 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 [] diff --git a/services/app_service.py b/services/app_service.py index c6ce951..fa66764 100644 --- a/services/app_service.py +++ b/services/app_service.py @@ -13,6 +13,7 @@ from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType, from services.repositories import InvoiceRepository, CustomerRepository from services.pdf_generator import PdfGenerator import logging +import sqlite3 import json import hashlib import os @@ -468,11 +469,68 @@ class ProductService: def get_all_products(self) -> List[Product]: """全商品を取得""" - return self.invoice_repo.get_all_products() + try: + return self.invoice_repo.get_all_products() + except AttributeError: + logging.warning("InvoiceRepositoryにget_all_productsがありません -> sqlite fallback") + try: + self._ensure_products_table() + products: List[Product] = [] + with sqlite3.connect(self.invoice_repo.db_path) as conn: + cur = conn.cursor() + cur.execute('SELECT id, name, unit_price, description FROM products ORDER BY name') + for row in cur.fetchall(): + products.append(Product(id=row[0], name=row[1], unit_price=row[2], description=row[3])) + return products + except Exception as e: + logging.error(f"商品取得fallback失敗: {e}") + return [] def save_product(self, product: Product) -> bool: """商品を保存(新規・更新)""" - return self.invoice_repo.save_product(product) + try: + return self.invoice_repo.save_product(product) + except AttributeError: + logging.warning("InvoiceRepositoryにsave_productがありません -> sqlite fallback") + try: + self._ensure_products_table() + with sqlite3.connect(self.invoice_repo.db_path) as conn: + cur = conn.cursor() + if product.id: + cur.execute( + "UPDATE products SET name = ?, unit_price = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (product.name, product.unit_price, product.description, product.id), + ) + else: + cur.execute( + "INSERT INTO products (name, unit_price, description) VALUES (?, ?, ?)", + (product.name, product.unit_price, product.description), + ) + product.id = cur.lastrowid + conn.commit() + return True + except Exception as e: + logging.error(f"商品保存fallback失敗: {e}") + return False + + def _ensure_products_table(self): + """productsテーブルが存在しない場合に作成""" + try: + with sqlite3.connect(self.invoice_repo.db_path) as conn: + cur = conn.cursor() + cur.execute( + '''CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + unit_price INTEGER NOT NULL, + description TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + )''' + ) + conn.commit() + except Exception as e: + logging.error(f"productsテーブル作成失敗: {e}") def close(self): logging.debug("ProductService.close called")