482 lines
18 KiB
Python
482 lines
18 KiB
Python
"""
|
|
伝票入力フレームワーク
|
|
表示形式を動的に切り替える伝票入力システム
|
|
"""
|
|
|
|
import flet as ft
|
|
import sqlite3
|
|
import logging
|
|
import json
|
|
from datetime import datetime
|
|
from typing import List, Dict, Optional, Any
|
|
|
|
class DisplayTheme:
|
|
"""表示形式テーマ"""
|
|
|
|
def __init__(self, name: str, title: str, format_type: str, config: Dict):
|
|
self.name = name
|
|
self.title = title
|
|
self.format_type = format_type # 'table', 'card', 'list', 'custom'
|
|
self.config = config
|
|
|
|
class SlipEntryRenderer:
|
|
"""伝票入力レンダラ"""
|
|
|
|
def __init__(self, page: ft.Page):
|
|
self.page = page
|
|
self.current_theme = None
|
|
self.items = []
|
|
|
|
def set_theme(self, theme: DisplayTheme):
|
|
"""テーマを設定"""
|
|
self.current_theme = theme
|
|
|
|
def render_table_format(self, items: List[Dict]) -> ft.Column:
|
|
"""表形式でレンダリング"""
|
|
if not self.current_theme or self.current_theme.format_type != 'table':
|
|
return ft.Column([ft.Text("テーマが設定されていません")])
|
|
|
|
config = self.current_theme.config
|
|
columns = config.get('columns', [])
|
|
|
|
# ヘッダー行
|
|
header_row = ft.Row(
|
|
[ft.Text(col.get('label', ''), weight=ft.FontWeight.BOLD, width=col.get('width', 100))
|
|
for col in columns],
|
|
spacing=5
|
|
)
|
|
|
|
# データ行
|
|
data_rows = []
|
|
for item in items:
|
|
row_controls = []
|
|
for col in columns:
|
|
field_name = col.get('field', '')
|
|
value = item.get(field_name, '')
|
|
align = col.get('align', 'left')
|
|
|
|
text_control = ft.Text(
|
|
value=str(value),
|
|
width=col.get('width', 100),
|
|
text_align=ft.TextAlign.LEFT if align == 'left' else ft.TextAlign.RIGHT
|
|
)
|
|
row_controls.append(text_control)
|
|
|
|
data_rows.append(ft.Row(row_controls, spacing=5))
|
|
|
|
return ft.Column([header_row] + data_rows, spacing=5)
|
|
|
|
def render_card_format(self, items: List[Dict]) -> ft.Column:
|
|
"""カード形式でレンダリング"""
|
|
if not self.current_theme or self.current_theme.format_type != 'card':
|
|
return ft.Column([ft.Text("テーマが設定されていません")])
|
|
|
|
config = self.current_theme.config
|
|
card_width = config.get('card_width', 300)
|
|
card_height = config.get('card_height', 120)
|
|
|
|
cards = []
|
|
for i, item in enumerate(items):
|
|
card_content = []
|
|
|
|
# カード内のフィールド
|
|
for field_name, value in item.items():
|
|
if field_name != 'id':
|
|
card_content.append(ft.Text(f"{field_name}: {value}", size=12))
|
|
|
|
card = ft.Card(
|
|
content=ft.Container(
|
|
content=ft.Column(card_content),
|
|
padding=10,
|
|
width=card_width,
|
|
height=card_height
|
|
),
|
|
margin=ft.margin.only(bottom=5)
|
|
)
|
|
cards.append(card)
|
|
|
|
return ft.Column(cards, scroll=ft.ScrollMode.AUTO)
|
|
|
|
def render_list_format(self, items: List[Dict]) -> ft.Column:
|
|
"""リスト形式でレンダリング"""
|
|
if not self.current_theme or self.current_theme.format_type != 'list':
|
|
return ft.Column([ft.Text("テーマが設定されていません")])
|
|
|
|
config = self.current_theme.config
|
|
item_height = config.get('item_height', 60)
|
|
show_dividers = config.get('show_dividers', True)
|
|
compact_mode = config.get('compact_mode', False)
|
|
|
|
list_items = []
|
|
for item in items:
|
|
item_content = []
|
|
|
|
for field_name, value in item.items():
|
|
if field_name != 'id':
|
|
item_content.append(ft.Text(f"{field_name}: {value}", size=12))
|
|
|
|
list_item = ft.Container(
|
|
content=ft.Column(item_content),
|
|
padding=10,
|
|
height=item_height if not compact_mode else None,
|
|
bgcolor=ft.Colors.GREY_50 if not compact_mode else None
|
|
)
|
|
|
|
list_items.append(list_item)
|
|
|
|
if show_dividers:
|
|
list_items.append(ft.Divider(height=1))
|
|
|
|
return ft.Column(list_items, scroll=ft.ScrollMode.AUTO)
|
|
|
|
def render_custom_format(self, items: List[Dict]) -> ft.Column:
|
|
"""カスタム形式でレンダリング"""
|
|
if not self.current_theme or self.current_theme.format_type != 'custom':
|
|
return ft.Column([ft.Text("テーマが設定されていません")])
|
|
|
|
config = self.current_theme.config
|
|
|
|
# カスタムレンダリングロジック
|
|
custom_items = []
|
|
for item in items:
|
|
custom_content = []
|
|
|
|
# ユーザー定義のレンダリング
|
|
for field_name, value in item.items():
|
|
if field_name != 'id':
|
|
field_config = config.get('fields', {}).get(field_name, {})
|
|
field_type = field_config.get('type', 'text')
|
|
|
|
if field_type == 'text':
|
|
custom_content.append(ft.Text(f"{field_name}: {value}", size=12))
|
|
elif field_type == 'badge':
|
|
custom_content.append(
|
|
ft.Container(
|
|
content=ft.Text(value, size=10),
|
|
bgcolor=ft.Colors.BLUE,
|
|
padding=ft.padding.symmetric(5, 2),
|
|
border_radius=5
|
|
)
|
|
)
|
|
elif field_type == 'progress':
|
|
custom_content.append(
|
|
ft.ProgressBar(
|
|
value=float(value) if value else 0,
|
|
width=200
|
|
)
|
|
)
|
|
|
|
custom_items.append(ft.Container(
|
|
content=ft.Column(custom_content),
|
|
padding=10,
|
|
border=ft.border.all(1, ft.Colors.GREY_300),
|
|
border_radius=5,
|
|
margin=ft.margin.only(bottom=10)
|
|
))
|
|
|
|
return ft.Column(custom_items, scroll=ft.ScrollMode.AUTO)
|
|
|
|
class SlipEntryFramework:
|
|
"""伝票入力フレームワーク"""
|
|
|
|
def __init__(self, page: ft.Page):
|
|
self.page = page
|
|
self.renderer = SlipEntryRenderer(page)
|
|
self.themes = {}
|
|
self.current_theme_name = None
|
|
self.items = []
|
|
|
|
# UI部品
|
|
self.title = ft.Text("伝票入力フレームワーク", size=24, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_900)
|
|
|
|
# テーマ選択
|
|
self.theme_dropdown = ft.Dropdown(
|
|
label="表示形式",
|
|
options=[],
|
|
on_change=self.change_theme
|
|
)
|
|
|
|
# 入力フォーム
|
|
self.form_fields = ft.Column([], spacing=10)
|
|
|
|
# 表示エリア
|
|
self.display_area = ft.Column([], scroll=ft.ScrollMode.AUTO, height=300)
|
|
|
|
# ボタン群
|
|
self.add_item_btn = ft.Button("明細追加", on_click=self.add_item, bgcolor=ft.Colors.BLUE, color=ft.Colors.WHITE)
|
|
self.save_btn = ft.Button("保存", on_click=self.save_slip, bgcolor=ft.Colors.GREEN, color=ft.Colors.WHITE)
|
|
self.clear_btn = ft.Button("クリア", on_click=self.clear_form, bgcolor=ft.Colors.ORANGE, color=ft.Colors.WHITE)
|
|
|
|
# 操作説明
|
|
self.instructions = ft.Text(
|
|
"操作方法: 表示形式を選択して伝票を入力",
|
|
size=12,
|
|
color=ft.Colors.GREY_600
|
|
)
|
|
|
|
# 初期化
|
|
self._init_themes()
|
|
self._build_form()
|
|
|
|
def _init_themes(self):
|
|
"""テーマを初期化"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
# テーマテーブル作成
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS display_themes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
title TEXT NOT NULL,
|
|
format_type TEXT NOT NULL,
|
|
layout_config TEXT NOT NULL,
|
|
is_active BOOLEAN DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# デフォルトテーマを挿入
|
|
default_themes = [
|
|
{
|
|
'name': 'table_standard',
|
|
'title': '表形式(標準)',
|
|
'format_type': 'table',
|
|
'layout_config': json.dumps({
|
|
'columns': [
|
|
{'field': 'item_name', 'label': '商品名', 'width': 200},
|
|
{'field': 'quantity', 'label': '数量', 'width': 100, 'align': 'right'},
|
|
{'field': 'unit_price', 'label': '単価', 'width': 120, 'align': 'right'},
|
|
{'field': 'amount', 'label': '金額', 'width': 120, 'align': 'right'}
|
|
]
|
|
})
|
|
},
|
|
{
|
|
'name': 'card_standard',
|
|
'title': 'カード形式(標準)',
|
|
'format_type': 'card',
|
|
'layout_config': json.dumps({
|
|
'card_width': 350,
|
|
'card_height': 100,
|
|
'fields_layout': 'vertical'
|
|
})
|
|
},
|
|
{
|
|
'name': 'list_standard',
|
|
'title': 'リスト形式(標準)',
|
|
'format_type': 'list',
|
|
'layout_config': json.dumps({
|
|
'item_height': 80,
|
|
'show_dividers': True,
|
|
'compact_mode': False
|
|
})
|
|
}
|
|
]
|
|
|
|
# デフォルトテーマがなければ挿入
|
|
cursor.execute("SELECT COUNT(*) FROM display_themes")
|
|
if cursor.fetchone()[0] == 0:
|
|
for theme in default_themes:
|
|
cursor.execute('''
|
|
INSERT INTO display_themes (name, title, format_type, layout_config)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (theme['name'], theme['title'], theme['format_type'], theme['layout_config']))
|
|
conn.commit()
|
|
|
|
# テーマを読み込み
|
|
cursor.execute("SELECT * FROM display_themes WHERE is_active = 1 ORDER BY id")
|
|
themes_data = cursor.fetchall()
|
|
|
|
for theme_data in themes_data:
|
|
theme_id, name, title, format_type, layout_config, is_active, created_at = theme_data
|
|
self.themes[name] = DisplayTheme(name, title, format_type, json.loads(layout_config))
|
|
|
|
conn.close()
|
|
|
|
# ドロップダウンを更新
|
|
self.theme_dropdown.options = [
|
|
ft.dropdown.Option(theme.title, name)
|
|
for name, theme in self.themes.items()
|
|
]
|
|
|
|
if self.themes:
|
|
first_theme = list(self.themes.keys())[0]
|
|
self.theme_dropdown.value = first_theme
|
|
self.current_theme_name = first_theme
|
|
self.current_theme = self.themes[first_theme]
|
|
self.renderer.set_theme(self.current_theme)
|
|
|
|
self.page.update()
|
|
|
|
except Exception as e:
|
|
logging.error(f"テーマ初期化エラー: {e}")
|
|
|
|
def change_theme(self, e):
|
|
"""テーマを変更"""
|
|
theme_name = e.control.value
|
|
if theme_name in self.themes:
|
|
self.current_theme_name = theme_name
|
|
self.current_theme = self.themes[theme_name]
|
|
self.renderer.set_theme(self.current_theme)
|
|
self._update_display()
|
|
logging.info(f"テーマ変更: {theme_name}")
|
|
|
|
def _build_form(self):
|
|
"""入力フォームを構築"""
|
|
self.form_fields.controls.clear()
|
|
|
|
# 基本フィールド
|
|
fields = [
|
|
{'name': 'item_name', 'label': '商品名', 'width': 200},
|
|
{'name': 'quantity', 'label': '数量', 'width': 100},
|
|
{'name': 'unit_price', 'label': '単価', 'width': 120},
|
|
{'name': 'amount', 'label': '金額', 'width': 120}
|
|
]
|
|
|
|
for field in fields:
|
|
field_control = ft.TextField(
|
|
label=field['label'],
|
|
value='',
|
|
width=field['width']
|
|
)
|
|
self.form_fields.controls.append(field_control)
|
|
|
|
self.page.update()
|
|
|
|
def _update_display(self):
|
|
"""表示エリアを更新"""
|
|
self.display_area.controls.clear()
|
|
|
|
if self.items:
|
|
if self.current_theme.format_type == 'table':
|
|
self.display_area.controls.append(self.renderer.render_table_format(self.items))
|
|
elif self.current_theme.format_type == 'card':
|
|
self.display_area.controls.append(self.renderer.render_card_format(self.items))
|
|
elif self.current_theme.format_type == 'list':
|
|
self.display_area.controls.append(self.renderer.render_list_format(self.items))
|
|
elif self.current_theme.format_type == 'custom':
|
|
self.display_area.controls.append(self.renderer.render_custom_format(self.items))
|
|
|
|
self.page.update()
|
|
|
|
def add_item(self, e):
|
|
"""明細を追加"""
|
|
# フォームデータを収集
|
|
form_data = {}
|
|
for i, field in enumerate(self.form_fields.controls):
|
|
field_name = ['item_name', 'quantity', 'unit_price', 'amount'][i]
|
|
form_data[field_name] = field.control.value
|
|
|
|
# 金額を計算
|
|
try:
|
|
quantity = float(form_data.get('quantity', 0))
|
|
unit_price = float(form_data.get('unit_price', 0))
|
|
amount = quantity * unit_price
|
|
form_data['amount'] = str(amount)
|
|
except:
|
|
form_data['amount'] = '0'
|
|
|
|
# リストに追加
|
|
self.items.append(form_data)
|
|
|
|
# 表示を更新
|
|
self._update_display()
|
|
|
|
# フォームをクリア
|
|
self.clear_form(None)
|
|
|
|
logging.info(f"明細追加: {form_data}")
|
|
|
|
def clear_form(self, e):
|
|
"""フォームをクリア"""
|
|
for field_control in self.form_fields.controls:
|
|
field_control.value = ''
|
|
|
|
self.page.update()
|
|
logging.info("フォームをクリア")
|
|
|
|
def save_slip(self, e):
|
|
"""伝票を保存"""
|
|
if not self.items:
|
|
return
|
|
|
|
try:
|
|
# データベースに保存
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
# 伝票テーブル作成
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS slips (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
theme_name TEXT NOT NULL,
|
|
items_data TEXT NOT NULL,
|
|
total_amount REAL NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# 合計金額を計算
|
|
total_amount = sum(float(item.get('amount', 0)) for item in self.items)
|
|
|
|
# 伝票を保存
|
|
cursor.execute('''
|
|
INSERT INTO slips (title, theme_name, items_data, total_amount)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (
|
|
f"伝票_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
|
self.current_theme_name,
|
|
json.dumps(self.items),
|
|
total_amount
|
|
))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# 成功メッセージ
|
|
self._show_snackbar("伝票を保存しました", ft.Colors.GREEN)
|
|
logging.info(f"伝票保存: {len(self.items)}件, 合計: {total_amount}")
|
|
|
|
# クリア
|
|
self.items.clear()
|
|
self._update_display()
|
|
|
|
except Exception as ex:
|
|
logging.error(f"伝票保存エラー: {ex}")
|
|
self._show_snackbar("保存エラー", ft.Colors.RED)
|
|
|
|
def _show_snackbar(self, message: str, color: ft.Colors):
|
|
"""SnackBarを表示"""
|
|
try:
|
|
self.page.snack_bar = ft.SnackBar(
|
|
content=ft.Text(message),
|
|
bgcolor=color
|
|
)
|
|
self.page.snack_bar.open = True
|
|
self.page.update()
|
|
except:
|
|
pass
|
|
|
|
def build(self):
|
|
"""UIを構築して返す"""
|
|
return ft.Column([
|
|
ft.Row([
|
|
self.title,
|
|
self.theme_dropdown
|
|
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
|
ft.Divider(),
|
|
ft.Text("明細入力", size=18, weight=ft.FontWeight.BOLD),
|
|
self.form_fields,
|
|
ft.Row([self.add_item_btn, self.save_btn, self.clear_btn], spacing=10),
|
|
ft.Divider(),
|
|
ft.Text("伝票プレビュー", size=18, weight=ft.FontWeight.BOLD),
|
|
self.instructions,
|
|
self.display_area
|
|
], expand=True, spacing=15)
|
|
|
|
# 使用例
|
|
def create_slip_entry_framework(page: ft.Page) -> SlipEntryFramework:
|
|
"""伝票入力フレームワークを作成"""
|
|
return SlipEntryFramework(page)
|