416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""
|
|
伝票エクスプローラー
|
|
伝票の検索・閲覧・管理を直感的に行う
|
|
土地勘を持たせるための視覚的ナビゲーション
|
|
"""
|
|
|
|
import flet as ft
|
|
import sqlite3
|
|
import signal
|
|
import sys
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Optional
|
|
|
|
# ロギング設定
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
)
|
|
|
|
class SlipExplorer:
|
|
"""伝票エクスプローラー"""
|
|
|
|
def __init__(self, page: ft.Page):
|
|
self.page = page
|
|
self.setup_page()
|
|
self.setup_database()
|
|
self.setup_ui()
|
|
|
|
def setup_page(self):
|
|
"""ページ設定"""
|
|
self.page.title = "伝票エクスプローラー"
|
|
self.page.window.width = 420
|
|
self.page.window.height = 900
|
|
self.page.window.resizable = False
|
|
self.page.window_center = True
|
|
self.page.theme_mode = ft.ThemeMode.LIGHT
|
|
|
|
# シグナルハンドラ
|
|
def signal_handler(signum, frame):
|
|
logging.info("伝票エクスプローラー正常終了")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
def setup_database(self):
|
|
"""データベース初期化"""
|
|
try:
|
|
self.conn = sqlite3.connect('sales_assist.db')
|
|
self.cursor = self.conn.cursor()
|
|
|
|
# 伝票テーブル作成
|
|
self.cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS slips (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slip_type TEXT, -- 売上伝票、見積書、納品書、請求書、領収書
|
|
customer_name TEXT,
|
|
amount REAL,
|
|
date TEXT,
|
|
status TEXT, -- 下書き、発行済、入金済、キャンセル
|
|
description TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# サンプルデータ作成
|
|
self.create_sample_data()
|
|
|
|
logging.info("伝票データベース接続完了")
|
|
|
|
except Exception as e:
|
|
logging.error(f"データベースエラー: {e}")
|
|
self.conn = None
|
|
|
|
def create_sample_data(self):
|
|
"""サンプルデータ作成"""
|
|
try:
|
|
# サンプル伝票データ
|
|
sample_slips = [
|
|
("売上伝票", "田中商事", 50000, "2026-02-19", "発行済", "A商品100個"),
|
|
("見積書", "鈴木商店", 75000, "2026-02-18", "下書き", "B商品50個"),
|
|
("納品書", "伊藤工業", 120000, "2026-02-17", "発行済", "C製品20台"),
|
|
("請求書", "高橋建設", 200000, "2026-02-16", "入金済", "工事代金"),
|
|
("領収書", "渡辺商事", 80000, "2026-02-15", "発行済", "D商品40個"),
|
|
]
|
|
|
|
for slip in sample_slips:
|
|
self.cursor.execute('''
|
|
INSERT OR IGNORE INTO slips
|
|
(slip_type, customer_name, amount, date, status, description)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
''', slip)
|
|
|
|
self.conn.commit()
|
|
|
|
except Exception as e:
|
|
logging.error(f"サンプルデータ作成エラー: {e}")
|
|
|
|
def setup_ui(self):
|
|
"""UI構築"""
|
|
# ヘッダー
|
|
header = ft.Container(
|
|
content=ft.Column([
|
|
ft.Text(
|
|
"📋 伝票エクスプローラー",
|
|
size=20,
|
|
weight=ft.FontWeight.BOLD,
|
|
color=ft.Colors.WHITE,
|
|
text_align=ft.TextAlign.CENTER
|
|
),
|
|
ft.Text(
|
|
"伝票の検索・閲覧・管理",
|
|
size=14,
|
|
color=ft.Colors.WHITE,
|
|
text_align=ft.TextAlign.CENTER
|
|
)
|
|
], spacing=5),
|
|
padding=15,
|
|
bgcolor=ft.Colors.BLUE_600,
|
|
border_radius=15,
|
|
margin=ft.Margin.only(bottom=15)
|
|
)
|
|
|
|
# 検索バー
|
|
search_bar = ft.Container(
|
|
content=ft.Row([
|
|
ft.TextField(
|
|
hint_text="伝票を検索...",
|
|
prefix_icon=ft.Icons.SEARCH,
|
|
filled=True,
|
|
dense=True,
|
|
expand=True,
|
|
on_change=self.on_search_change
|
|
),
|
|
ft.IconButton(ft.Icons.FILTER_LIST, tooltip="フィルター", icon_size=20),
|
|
], spacing=5),
|
|
padding=ft.Padding.symmetric(horizontal=15, vertical=5),
|
|
margin=ft.Margin.only(bottom=15)
|
|
)
|
|
|
|
# フィルターパネル
|
|
filter_panel = ft.Container(
|
|
content=ft.Column([
|
|
ft.Text("🔍 フィルター", size=14, weight=ft.FontWeight.BOLD),
|
|
ft.Divider(height=1),
|
|
|
|
# 顧客フィルター(チェックボックス)
|
|
ft.Text("顧客", size=12, weight=ft.FontWeight.BOLD),
|
|
ft.Column([
|
|
self.create_checkbox_filter("田中商事", "customer"),
|
|
self.create_checkbox_filter("鈴木商店", "customer"),
|
|
self.create_checkbox_filter("伊藤工業", "customer"),
|
|
self.create_checkbox_filter("高橋建設", "customer"),
|
|
], spacing=5),
|
|
|
|
# 期間フィルター(ラジオボタン)
|
|
ft.Text("期間", size=12, weight=ft.FontWeight.BOLD),
|
|
ft.Column([
|
|
self.create_radio_filter("今日", "period", True),
|
|
self.create_radio_filter("今週", "period"),
|
|
self.create_radio_filter("今月", "period"),
|
|
self.create_radio_filter("全期間", "period"),
|
|
], spacing=5),
|
|
|
|
# 金額帯フィルター(チェックボックス)
|
|
ft.Text("金額帯", size=12, weight=ft.FontWeight.BOLD),
|
|
ft.Column([
|
|
self.create_checkbox_filter("0-1万円", "amount_0_1"),
|
|
self.create_checkbox_filter("1-5万円", "amount_1_5"),
|
|
self.create_checkbox_filter("5万円以上", "amount_5_plus"),
|
|
], spacing=5),
|
|
|
|
], spacing=10),
|
|
padding=15,
|
|
bgcolor=ft.Colors.WHITE,
|
|
border_radius=10,
|
|
margin=ft.Margin.only(bottom=15)
|
|
)
|
|
|
|
# 伝票一覧
|
|
self.slip_list = ft.Column([], spacing=8, scroll=ft.ScrollMode.AUTO)
|
|
|
|
# 統計情報
|
|
stats_container = ft.Container(
|
|
content=self.get_stats_info(),
|
|
padding=15,
|
|
bgcolor=ft.Colors.BLUE_50,
|
|
border_radius=10,
|
|
margin=ft.Margin.only(bottom=15)
|
|
)
|
|
|
|
# メインコンテナ
|
|
self.main_container = ft.Column([
|
|
header,
|
|
search_bar,
|
|
filter_panel,
|
|
stats_container,
|
|
ft.Container(
|
|
content=self.slip_list,
|
|
expand=True,
|
|
padding=ft.Padding.symmetric(horizontal=15)
|
|
)
|
|
], spacing=5)
|
|
|
|
# ページに追加
|
|
self.page.add(
|
|
ft.Container(
|
|
content=self.main_container,
|
|
padding=10,
|
|
bgcolor=ft.Colors.GREY_50,
|
|
expand=True
|
|
)
|
|
)
|
|
|
|
# 初期データ読み込み
|
|
self.load_slips()
|
|
|
|
def create_checkbox_filter(self, label: str, filter_type: str) -> ft.Row:
|
|
"""チェックボックスフィルター作成"""
|
|
checkbox = ft.Checkbox(label=label, value=False, on_change=lambda e: self.apply_filters())
|
|
return ft.Row([checkbox], spacing=0)
|
|
|
|
def create_radio_filter(self, label: str, filter_type: str, is_default: bool = False) -> ft.Row:
|
|
"""ラジオボタンフィルター作成"""
|
|
radio = ft.Radio(
|
|
value=label,
|
|
label=label,
|
|
on_change=lambda e: self.apply_filters()
|
|
)
|
|
return ft.Row([radio], spacing=0)
|
|
|
|
def apply_filters(self):
|
|
"""フィルター適用"""
|
|
# TODO: フィルターロジック実装
|
|
self.load_slips()
|
|
|
|
def get_stats_info(self) -> ft.Column:
|
|
"""統計情報取得"""
|
|
if not self.conn:
|
|
return ft.Column([
|
|
ft.Text("データベース未接続", size=12, color=ft.Colors.RED)
|
|
])
|
|
|
|
try:
|
|
# 各種統計
|
|
self.cursor.execute("SELECT COUNT(*) FROM slips")
|
|
total_slips = self.cursor.fetchone()[0]
|
|
|
|
self.cursor.execute("SELECT COUNT(*) FROM slips WHERE status = '下書き'")
|
|
draft_slips = self.cursor.fetchone()[0]
|
|
|
|
self.cursor.execute("SELECT COUNT(*) FROM slips WHERE status = '入金済'")
|
|
paid_slips = self.cursor.fetchone()[0]
|
|
|
|
self.cursor.execute("SELECT SUM(amount) FROM slips WHERE status = '入金済'")
|
|
total_amount = self.cursor.fetchone()[0] or 0
|
|
|
|
return ft.Column([
|
|
ft.Text("📊 伝票統計", size=14, weight=ft.FontWeight.BOLD),
|
|
ft.Row([
|
|
ft.Text(f"総数: {total_slips}件", size=12, expand=True),
|
|
ft.Text(f"下書き: {draft_slips}件", size=12, expand=True),
|
|
]),
|
|
ft.Row([
|
|
ft.Text(f"入金済: {paid_slips}件", size=12, expand=True),
|
|
ft.Text(f"合計: ¥{total_amount:,.0f}", size=12, expand=True),
|
|
])
|
|
], spacing=5)
|
|
|
|
except Exception as e:
|
|
return ft.Column([
|
|
ft.Text("統計取得エラー", size=12, color=ft.Colors.RED)
|
|
])
|
|
|
|
def load_slips(self, filter_type: str = "all"):
|
|
"""伝票一覧読み込み"""
|
|
if not self.conn:
|
|
return
|
|
|
|
try:
|
|
query = "SELECT * FROM slips"
|
|
params = []
|
|
|
|
if filter_type != "all":
|
|
query += " WHERE slip_type = ?"
|
|
params = [filter_type]
|
|
|
|
query += " ORDER BY date DESC, created_at DESC"
|
|
|
|
self.cursor.execute(query, params)
|
|
slips = self.cursor.fetchall()
|
|
|
|
# 一覧をクリアして再構築
|
|
self.slip_list.controls.clear()
|
|
|
|
for slip in slips:
|
|
slip_item = self.create_slip_item(slip)
|
|
self.slip_list.controls.append(slip_item)
|
|
|
|
self.page.update()
|
|
|
|
except Exception as e:
|
|
logging.error(f"伝票読み込みエラー: {e}")
|
|
|
|
def create_slip_item(self, slip: tuple, is_highlighted: bool = False) -> ft.Container:
|
|
"""伝票アイテム作成(ハイライト対応)"""
|
|
slip_id, slip_type, customer_name, amount, date, status, description, created_at = slip
|
|
|
|
# ステータスに応じた色
|
|
status_colors = {
|
|
"下書き": ft.Colors.ORANGE,
|
|
"発行済": ft.Colors.BLUE,
|
|
"入金済": ft.Colors.GREEN,
|
|
"キャンセル": ft.Colors.RED
|
|
}
|
|
|
|
# タイプに応じたアイコン
|
|
type_icons = {
|
|
"売上伝票": "💰",
|
|
"見積書": "📄",
|
|
"納品書": "📦",
|
|
"請求書": "📋",
|
|
"領収書": "🧾"
|
|
}
|
|
|
|
# ハイライト効果
|
|
if is_highlighted:
|
|
bgcolor = ft.Colors.BLUE_50
|
|
border_color = ft.Colors.BLUE_600
|
|
opacity = 1.0
|
|
shadow = ft.BoxShadow(
|
|
spread_radius=2,
|
|
blur_radius=8,
|
|
color=ft.Colors.with_opacity(0.3, ft.Colors.BLUE),
|
|
offset=ft.Offset(0, 4)
|
|
)
|
|
else:
|
|
bgcolor = ft.Colors.WHITE
|
|
border_color = ft.Colors.GREY_200
|
|
opacity = 0.3 # グレーアウト
|
|
shadow = None
|
|
|
|
return ft.Container(
|
|
content=ft.Row([
|
|
# アイコン
|
|
ft.Container(
|
|
content=ft.Text(type_icons.get(slip_type, "📝"), size=24),
|
|
width=50,
|
|
height=50,
|
|
bgcolor=ft.Colors.BLUE_50 if is_highlighted else ft.Colors.GREY_100,
|
|
alignment=ft.alignment.Alignment(0, 0),
|
|
border_radius=10
|
|
),
|
|
# メイン情報
|
|
ft.Column([
|
|
ft.Text(f"{slip_type} - {customer_name}", size=14, weight=ft.FontWeight.BOLD),
|
|
ft.Text(f"¥{amount:,.0f} - {date}", size=12, color=ft.Colors.GREY_600),
|
|
ft.Text(description or "", size=10, color=ft.Colors.GREY_500)
|
|
], expand=True),
|
|
# ステータス
|
|
ft.Container(
|
|
content=ft.Text(status, size=10, color=ft.Colors.WHITE),
|
|
padding=ft.Padding.symmetric(horizontal=8, vertical=4),
|
|
bgcolor=status_colors.get(status, ft.Colors.GREY),
|
|
border_radius=10
|
|
)
|
|
], spacing=10),
|
|
padding=12,
|
|
bgcolor=bgcolor,
|
|
border_radius=10,
|
|
border=ft.Border.all(2, border_color),
|
|
opacity=opacity,
|
|
shadow=shadow,
|
|
on_click=lambda _: self.open_slip(slip_id)
|
|
)
|
|
|
|
def on_search_change(self, e):
|
|
"""検索変更時"""
|
|
search_text = e.control.value.lower()
|
|
# TODO: 検索機能実装
|
|
pass
|
|
|
|
def filter_by_type(self, type_value: str):
|
|
"""タイプでフィルター"""
|
|
self.load_slips(type_value)
|
|
|
|
def open_slip(self, slip_id: int):
|
|
"""伝票を開く"""
|
|
self.page.snack_bar = ft.SnackBar(
|
|
content=ft.Text(f"伝票#{slip_id}を開きます"),
|
|
bgcolor=ft.Colors.BLUE
|
|
)
|
|
self.page.snack_bar.open = True
|
|
self.page.update()
|
|
|
|
# TODO: 伝票詳細画面へ遷移
|
|
pass
|
|
|
|
def main(page: ft.Page):
|
|
"""メイン関数"""
|
|
try:
|
|
explorer = SlipExplorer(page)
|
|
logging.info("伝票エクスプローラー起動完了")
|
|
|
|
except Exception as e:
|
|
logging.error(f"伝票エクスプローラー起動エラー: {e}")
|
|
page.snack_bar = ft.SnackBar(
|
|
content=ft.Text(f"起動エラー: {e}"),
|
|
bgcolor=ft.Colors.RED
|
|
)
|
|
page.snack_bar.open = True
|
|
page.update()
|
|
|
|
if __name__ == "__main__":
|
|
ft.run(main)
|