商品マスタ実装

This commit is contained in:
joe 2026-02-23 23:25:50 +09:00
parent a1e82e0714
commit d9aad0d35b
3 changed files with 457 additions and 82 deletions

View file

@ -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,
),
]
)

425
main.py
View file

@ -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 []

View file

@ -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")