h-1.flet.3/components/hierarchical_product_master.py

771 lines
27 KiB
Python

"""
階層構造商品マスタコンポーネント
入れ子構造と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