商品マスタ実装
This commit is contained in:
parent
a1e82e0714
commit
d9aad0d35b
3 changed files with 457 additions and 82 deletions
|
|
@ -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
425
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 []
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue