""" 階層構造商品マスタコンポーネント 入れ子構造とPDF巨大カッコ表示に対応 """ import flet as ft import sqlite3 import json from typing import List, Dict, Optional from datetime import datetime class ProductNode: """商品ノードクラス""" def __init__(self, id: int = None, name: str = "", parent_id: int = None, level: int = 0, is_category: bool = True, price: float = 0.0, stock: int = 0, description: str = ""): self.id = id self.name = name self.parent_id = parent_id self.level = level self.is_category = is_category self.price = price self.stock = stock self.description = description self.children: List['ProductNode'] = [] self.expanded = True def add_child(self, child: 'ProductNode'): """子ノードを追加""" child.parent_id = self.id child.level = self.level + 1 self.children.append(child) def remove_child(self, child_id: int): """子ノードを削除""" self.children = [child for child in self.children if child.id != child_id] def to_dict(self) -> Dict: """辞書に変換""" return { 'id': self.id, 'name': self.name, 'parent_id': self.parent_id, 'level': self.level, 'is_category': self.is_category, 'price': self.price, 'stock': self.stock, 'description': self.description, 'expanded': self.expanded, 'children': [child.to_dict() for child in self.children] } @classmethod def from_dict(cls, data: Dict) -> 'ProductNode': """辞書から作成""" node = cls( id=data.get('id'), name=data.get('name', ''), parent_id=data.get('parent_id'), level=data.get('level', 0), is_category=data.get('is_category', True), price=data.get('price', 0.0), stock=data.get('stock', 0), description=data.get('description', '') ) node.expanded = data.get('expanded', True) for child_data in data.get('children', []): child = cls.from_dict(child_data) node.add_child(child) return node class HierarchicalProductMaster: """階層構造商品マスタエディタ""" def __init__(self, page: ft.Page): self.page = page self.root_nodes: List[ProductNode] = [] self.selected_node: Optional[ProductNode] = None self.next_id = 1 # UIコンポーネント self.tree_view = ft.Column([], expand=True, spacing=2) self.preview_area = ft.Column([], expand=True, spacing=5) # 入力フィールド self.name_field = ft.TextField( label="商品名/カテゴリ名", width=300, autofocus=True ) self.price_field = ft.TextField( label="価格", width=150, value="0" ) self.stock_field = ft.TextField( label="在庫数", width=150, value="0" ) self.description_field = ft.TextField( label="説明", width=400, multiline=True, height=80 ) self.is_category_checkbox = ft.Checkbox( label="カテゴリとして扱う", value=True ) # ボタン self.add_btn = ft.Button( "追加", on_click=self.add_node, bgcolor=ft.Colors.GREEN, color=ft.Colors.WHITE ) self.update_btn = ft.Button( "更新", on_click=self.update_node, bgcolor=ft.Colors.BLUE, color=ft.Colors.WHITE ) self.delete_btn = ft.Button( "削除", on_click=self.delete_node, bgcolor=ft.Colors.RED, color=ft.Colors.WHITE ) self.preview_btn = ft.Button( "PDFプレビュー", on_click=self.show_pdf_preview, bgcolor=ft.Colors.ORANGE, color=ft.Colors.WHITE ) # データベース初期化 self._init_database() # サンプルデータ作成 self._create_sample_data() def _init_database(self): """データベース初期化""" try: conn = sqlite3.connect('sales.db') cursor = conn.cursor() # 階層商品マスタテーブル作成 cursor.execute(''' CREATE TABLE IF NOT EXISTS hierarchical_products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, parent_id INTEGER, level INTEGER DEFAULT 0, is_category BOOLEAN DEFAULT 1, price REAL DEFAULT 0.0, stock INTEGER DEFAULT 0, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parent_id) REFERENCES hierarchical_products (id) ) ''') conn.commit() conn.close() except Exception as e: print(f"データベース初期化エラー: {e}") def _create_sample_data(self): """サンプルデータ作成""" try: conn = sqlite3.connect('sales.db') cursor = conn.cursor() # 既存データチェック cursor.execute("SELECT COUNT(*) FROM hierarchical_products") if cursor.fetchone()[0] > 0: conn.close() return print("サンプルデータを作成中...") # 食料品カテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("食料品", None, 0, True)) food_id = cursor.lastrowid # 生鮮食品サブカテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("生鮮食品", food_id, 1, True)) fresh_id = cursor.lastrowid # 野菜カテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("野菜", fresh_id, 2, True)) veg_id = cursor.lastrowid # 具体的な野菜商品 vegetables = [ ("キャベツ", veg_id, 3, False, 198.0, 50, "新鮮なキャベツ"), ("人参", veg_id, 3, False, 128.0, 80, "甘い人参"), ("じゃがいも", veg_id, 3, False, 98.0, 100, "ホクホクのじゃがいも"), ("玉ねぎ", veg_id, 3, False, 88.0, 120, "辛みの少ない玉ねぎ") ] for veg in vegetables: cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category, price, stock, description) VALUES (?, ?, ?, ?, ?, ?, ?) ''', veg) # 果物カテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("果物", fresh_id, 2, True)) fruit_id = cursor.lastrowid # 具体的な果物商品 fruits = [ ("りんご", fruit_id, 3, False, 158.0, 60, "シャリシャリのりんご"), ("バナナ", fruit_id, 3, False, 198.0, 40, "甘いバナナ"), ("みかん", fruit_id, 3, False, 298.0, 30, "みかん1箱") ] for fruit in fruits: cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category, price, stock, description) VALUES (?, ?, ?, ?, ?, ?, ?) ''', fruit) # 加工食品サブカテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("加工食品", food_id, 1, True)) processed_id = cursor.lastrowid # 缶詰カテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("缶詰", processed_id, 2, True)) can_id = cursor.lastrowid # 具体的な缶詰商品 cans = [ ("ツナ缶", can_id, 3, False, 128.0, 100, "水煮ツナ缶"), ("コーン缶", can_id, 3, False, 98.0, 80, "スイートコーン缶"), ("ミックスビーンズ", can_id, 3, False, 158.0, 60, "ミックスビーンズ缶") ] for can in cans: cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category, price, stock, description) VALUES (?, ?, ?, ?, ?, ?, ?) ''', can) # 日用品カテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("日用品", None, 0, True)) daily_id = cursor.lastrowid # 衛生用品サブカテゴリ cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category) VALUES (?, ?, ?, ?) ''', ("衛生用品", daily_id, 1, True)) hygiene_id = cursor.lastrowid # 具体的な衛生用品商品 hygiene_items = [ ("ティッシュペーパー", hygiene_id, 2, False, 198.0, 200, "柔らかいティッシュ"), ("トイレットペーパー", hygiene_id, 2, False, 298.0, 100, "ダブルロール12個入"), ("ハンドソープ", hygiene_id, 2, False, 398.0, 50, "除菌ハンドソープ") ] for item in hygiene_items: cursor.execute(''' INSERT INTO hierarchical_products (name, parent_id, level, is_category, price, stock, description) VALUES (?, ?, ?, ?, ?, ?, ?) ''', item) conn.commit() conn.close() print("サンプルデータ作成完了") except Exception as e: print(f"サンプルデータ作成エラー: {e}") def load_data(self): """データベースからデータを読み込み""" try: conn = sqlite3.connect('sales.db') cursor = conn.cursor() cursor.execute(''' SELECT id, name, parent_id, level, is_category, price, stock, description FROM hierarchical_products ORDER BY level, parent_id, name ''') rows = cursor.fetchall() conn.close() # ノードマップ作成 node_map = {} self.root_nodes = [] for row in rows: node = ProductNode( id=row[0], name=row[1], parent_id=row[2], level=row[3], is_category=bool(row[4]), price=row[5], stock=row[6], description=row[7] ) node_map[node.id] = node # 階層構造構築 for node in node_map.values(): if node.parent_id is None: self.root_nodes.append(node) else: parent = node_map.get(node.parent_id) if parent: parent.add_child(node) # 次のIDを設定 if rows: self.next_id = max(row[0] for row in rows) + 1 self.update_tree_view() except Exception as e: print(f"データ読み込みエラー: {e}") def save_data(self): """データベースに保存""" try: conn = sqlite3.connect('sales.db') cursor = conn.cursor() # 既存データ削除 cursor.execute("DELETE FROM hierarchical_products") # 再帰的に保存 def save_node(node: ProductNode): cursor.execute(''' INSERT INTO hierarchical_products (id, name, parent_id, level, is_category, price, stock, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''', ( node.id, node.name, node.parent_id, node.level, node.is_category, node.price, node.stock, node.description )) for child in node.children: save_node(child) for root in self.root_nodes: save_node(root) conn.commit() conn.close() except Exception as e: print(f"データ保存エラー: {e}") def update_tree_view(self): """ツリービュー更新""" self.tree_view.controls.clear() for root in self.root_nodes: self.tree_view.controls.append(self._create_tree_node(root)) self.tree_view.update() def _create_tree_node(self, node: ProductNode) -> ft.Container: """ツリーノード作成""" indent = " " * node.level # アイコン if node.is_category: if node.expanded and node.children: icon = ft.Icons.FOLDER_OPEN elif node.children: icon = ft.Icons.FOLDER else: icon = ft.Icons.FOLDER_OUTLINE else: icon = ft.Icons.SHOPPING_CART # ノードコンテナ node_container = ft.Container( content=ft.Row([ # 展開/折りたたみボタン ft.IconButton( icon=ft.Icons.EXPAND_MORE if node.expanded else ft.Icons.CHEVRON_RIGHT, on_click=lambda _, n=node: self.toggle_node(n), visible=len(node.children) > 0, width=30, height=30 ), # アイコン ft.Icon( icon, color=ft.Colors.ORANGE if node.is_category else ft.Colors.BLUE, size=20 ), # 名前 ft.Text( f"{indent}{node.name}", size=14, weight=ft.FontWeight.BOLD if node.is_category else ft.FontWeight.NORMAL, expand=True ), # 価格(商品の場合) ft.Text( f"¥{node.price:.0f}" if not node.is_category else "", size=12, color=ft.Colors.GREY_600 ), # 在庫(商品の場合) ft.Text( f"在庫:{node.stock}" if not node.is_category else "", size=12, color=ft.Colors.GREY_600 ), # 選択ボタン ft.IconButton( icon=ft.Icons.EDIT, on_click=lambda _, n=node: self.select_node(n), icon_size=16, tooltip="編集" ) ], spacing=5), padding=ft.Padding.symmetric(horizontal=10, vertical=5), bgcolor=ft.Colors.BLUE_50 if self.selected_node == node else ft.Colors.TRANSPARENT, border_radius=5, on_click=lambda _, n=node: self.select_node(n) ) # 子ノード children_container = ft.Column([], spacing=2) if node.expanded: for child in node.children: children_container.controls.append(self._create_tree_node(child)) return ft.Column([ node_container, children_container ], spacing=0) def toggle_node(self, node: ProductNode): """ノード展開/折りたたみ""" node.expanded = not node.expanded self.update_tree_view() def select_node(self, node: ProductNode): """ノード選択""" self.selected_node = node # 入力フィールドに値を設定 self.name_field.value = node.name self.price_field.value = str(node.price) self.stock_field.value = str(node.stock) self.description_field.value = node.description self.is_category_checkbox.value = node.is_category # 価格・在庫フィールドの有効/無効 self.price_field.disabled = node.is_category self.stock_field.disabled = node.is_category self.update_tree_view() self.page.update() def add_node(self, e=None): """ノード追加""" if not self.name_field.value: self.show_message("名前を入力してください", ft.Colors.RED) return new_node = ProductNode( id=self.next_id, name=self.name_field.value, parent_id=self.selected_node.id if self.selected_node else None, level=self.selected_node.level + 1 if self.selected_node else 0, is_category=self.is_category_checkbox.value, price=float(self.price_field.value) if not self.is_category_checkbox.value else 0.0, stock=int(self.stock_field.value) if not self.is_category_checkbox.value else 0, description=self.description_field.value ) self.next_id += 1 if self.selected_node: self.selected_node.add_child(new_node) if not self.selected_node.expanded: self.selected_node.expanded = True else: self.root_nodes.append(new_node) self.update_tree_view() self.clear_form() self.save_data() self.show_message("商品を追加しました", ft.Colors.GREEN) def update_node(self, e=None): """ノード更新""" if not self.selected_node or not self.name_field.value: self.show_message("更新対象を選択してください", ft.Colors.RED) return self.selected_node.name = self.name_field.value self.selected_node.is_category = self.is_category_checkbox.value if not self.is_category_checkbox.value: self.selected_node.price = float(self.price_field.value) self.selected_node.stock = int(self.stock_field.value) else: self.selected_node.price = 0.0 self.selected_node.stock = 0 self.selected_node.description = self.description_field.value self.update_tree_view() self.save_data() self.show_message("商品を更新しました", ft.Colors.GREEN) def delete_node(self, e=None): """ノード削除""" if not self.selected_node: self.show_message("削除対象を選択してください", ft.Colors.RED) return # 子ノードも削除 def remove_from_parent(node: ProductNode, target: ProductNode): if target in node.children: node.remove_child(target.id) return True for child in node.children: if remove_from_parent(child, target): return True return False # 親を探して削除 removed = False for root in self.root_nodes: if root == self.selected_node: self.root_nodes.remove(root) removed = True break if remove_from_parent(root, self.selected_node): removed = True break if removed: self.selected_node = None self.update_tree_view() self.clear_form() self.save_data() self.show_message("商品を削除しました", ft.Colors.GREEN) def clear_form(self): """フォームクリア""" self.name_field.value = "" self.price_field.value = "0" self.stock_field.value = "0" self.description_field.value = "" self.is_category_checkbox.value = True self.price_field.disabled = True self.stock_field.disabled = True def show_pdf_preview(self, e=None): """PDFプレビュー表示""" self.preview_area.controls.clear() # キャラクターベースの階層表示 preview_text = self._generate_character_preview() self.preview_area.controls = [ ft.Container( content=ft.Column([ ft.Text( "商品マスタ一覧 - PDFプレビュー", size=20, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER ), ft.Divider(), ft.Container( content=ft.Text( preview_text, size=12, font_family="Courier New" ), padding=20, bgcolor=ft.Colors.WHITE, border=ft.Border.all(1, ft.Colors.GREY_300), border_radius=5 ) ]), padding=20, bgcolor=ft.Colors.GREY_50, border_radius=10 ) ] self.preview_area.update() def _generate_character_preview(self) -> str: """キャラクターベースのプレビュー生成""" lines = [] lines.append("┌" + "─" * 60 + "┐") lines.append("│" + "商品マスタ一覧".center(58, " ") + "│") lines.append("├" + "─" * 60 + "┤") def add_node_lines(node: ProductNode, prefix: str = "", is_last: bool = True): # 現在のノード connector = "└──" if is_last else "├──" if node.is_category: line = f"{prefix}{connector}【{node.name}】" else: line = f"{prefix}{connector}・{node.name} (¥{node.price:.0f}, 在庫:{node.stock})" lines.append("│" + line.ljust(58, " ") + "│") # 子ノード if node.children: child_prefix = prefix + (" " if is_last else "│ ") for i, child in enumerate(node.children): is_last_child = (i == len(node.children) - 1) add_node_lines(child, child_prefix, is_last_child) # 全ルートノードを追加 for i, root in enumerate(self.root_nodes): is_last_root = (i == len(self.root_nodes) - 1) add_node_lines(root, "", is_last_root) lines.append("└" + "─" * 60 + "┘") return "\n".join(lines) def show_message(self, message: str, color: ft.Colors): """メッセージ表示""" self.page.snack_bar = ft.SnackBar( content=ft.Text(message), bgcolor=color ) self.page.snack_bar.open = True self.page.update() def build(self) -> ft.Row: """UI構築""" # 左側:ツリービュー left_panel = ft.Container( content=ft.Column([ ft.Text( "商品階層構造", size=18, weight=ft.FontWeight.BOLD ), ft.Divider(), ft.Container( content=self.tree_view, border=ft.Border.all(1, ft.Colors.GREY_300), border_radius=5, padding=10, height=400 ) ]), width=400, padding=10 ) # 中央:入力フォーム center_panel = ft.Container( content=ft.Column([ ft.Text( "商品情報編集", size=18, weight=ft.FontWeight.BOLD ), ft.Divider(), self.name_field, ft.Row([ self.price_field, self.stock_field ], spacing=10), self.description_field, self.is_category_checkbox, ft.Divider(), ft.Row([ self.add_btn, self.update_btn, self.delete_btn ], spacing=10) ]), width=400, padding=10 ) # 右側:プレビュー right_panel = ft.Container( content=ft.Column([ ft.Row([ ft.Text( "PDFプレビュー", size=18, weight=ft.FontWeight.BOLD ), self.preview_btn ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), ft.Divider(), ft.Container( content=self.preview_area, border=ft.Border.all(1, ft.Colors.GREY_300), border_radius=5, padding=10, height=400 ) ]), width=500, padding=10 ) return ft.Row([ left_panel, ft.VerticalDivider(width=1), center_panel, ft.VerticalDivider(width=1), right_panel ], expand=True) def create_hierarchical_product_master(page: ft.Page) -> HierarchicalProductMaster: """階層商品マスタ作成""" master = HierarchicalProductMaster(page) master.load_data() return master