自動で空行追加
This commit is contained in:
parent
22c4a2e6b7
commit
042bbe032d
10 changed files with 1088 additions and 31 deletions
|
|
@ -9,7 +9,7 @@ import sys
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional
|
||||
from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer
|
||||
from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem
|
||||
from components.customer_picker import CustomerPickerModal
|
||||
from services.app_service import AppService
|
||||
|
||||
|
|
@ -24,11 +24,13 @@ class FlutterStyleDashboard:
|
|||
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self.current_tab = 0 # 0: 新規作成, 1: 発行履歴
|
||||
self.current_tab = 0 # 0: 伝票一覧, 1: 詳細編集
|
||||
self.selected_customer = None
|
||||
self.selected_document_type = DocumentType.INVOICE
|
||||
self.amount_value = "250000"
|
||||
self.customer_picker = None
|
||||
self.editing_invoice = None # 編集中の伝票
|
||||
self.is_edit_mode = False # 編集モードフラグ
|
||||
self.is_customer_picker_open = False
|
||||
self.customer_search_query = ""
|
||||
self.show_offsets = False
|
||||
|
|
@ -147,14 +149,103 @@ class FlutterStyleDashboard:
|
|||
elif self.is_customer_picker_open:
|
||||
self.main_content.controls.append(self.create_customer_picker_screen())
|
||||
else:
|
||||
# 新規作成画面
|
||||
self.main_content.controls.append(self.create_slip_input_screen())
|
||||
# 伝票一覧画面
|
||||
self.main_content.controls.append(self.create_slip_list_screen())
|
||||
else:
|
||||
# 発行履歴画面
|
||||
self.main_content.controls.append(self.create_slip_history_screen())
|
||||
# 詳細編集画面
|
||||
self.main_content.controls.append(self.create_invoice_edit_screen())
|
||||
|
||||
self.page.update()
|
||||
|
||||
def create_slip_list_screen(self) -> ft.Container:
|
||||
"""伝票一覧画面"""
|
||||
# 履歴データ読み込み
|
||||
slips = self.load_slips()
|
||||
if not self.show_offsets:
|
||||
slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))]
|
||||
|
||||
def on_toggle_offsets(e):
|
||||
self.show_offsets = bool(e.control.value)
|
||||
self.update_main_content()
|
||||
|
||||
def on_verify_chain(e=None):
|
||||
res = self.app_service.invoice.invoice_repo.verify_chain()
|
||||
self.chain_verify_result = res
|
||||
self.update_main_content()
|
||||
|
||||
def create_new_slip(_):
|
||||
"""新規伝票作成"""
|
||||
self.editing_invoice = None
|
||||
self.current_tab = 1
|
||||
self.update_main_content()
|
||||
|
||||
# 履歴カードリスト
|
||||
slip_cards = []
|
||||
for slip in slips:
|
||||
card = self.create_slip_card(slip)
|
||||
slip_cards.append(card)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
# ヘッダー
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Text("📄 伝票一覧", size=20, weight=ft.FontWeight.BOLD),
|
||||
ft.Container(expand=True),
|
||||
ft.IconButton(
|
||||
ft.Icons.VERIFIED,
|
||||
tooltip="チェーン検証",
|
||||
icon_color=ft.Colors.BLUE_300,
|
||||
on_click=on_verify_chain,
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE),
|
||||
ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets),
|
||||
],
|
||||
spacing=5,
|
||||
),
|
||||
ft.IconButton(ft.Icons.CLEAR_ALL, icon_size=20),
|
||||
]),
|
||||
padding=ft.padding.all(15),
|
||||
bgcolor=ft.Colors.BLUE_GREY,
|
||||
),
|
||||
|
||||
# 検証結果表示(あれば)
|
||||
ft.Container(
|
||||
content=self._build_chain_verify_result(),
|
||||
margin=ft.Margin.only(bottom=10),
|
||||
) if self.chain_verify_result else ft.Container(height=0),
|
||||
|
||||
# 伝票リスト
|
||||
ft.Column(
|
||||
controls=slip_cards,
|
||||
spacing=10,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
expand=True,
|
||||
),
|
||||
|
||||
# 新規作成ボタン
|
||||
ft.Container(
|
||||
content=ft.Button(
|
||||
content=ft.Row([
|
||||
ft.Icon(ft.Icons.ADD, color=ft.Colors.WHITE),
|
||||
ft.Text("新規伝票を作成", color=ft.Colors.WHITE),
|
||||
], alignment=ft.MainAxisAlignment.CENTER),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.BLUE_GREY_800,
|
||||
padding=ft.padding.all(20),
|
||||
shape=ft.RoundedRectangleBorder(radius=15),
|
||||
),
|
||||
width=400,
|
||||
height=60,
|
||||
on_click=create_new_slip,
|
||||
),
|
||||
padding=ft.padding.all(20),
|
||||
),
|
||||
]),
|
||||
expand=True,
|
||||
)
|
||||
def create_customer_picker_screen(self) -> ft.Container:
|
||||
"""顧客選択画面(画面内遷移・ダイアログ不使用)"""
|
||||
self.customers = self.app_service.customer.get_all_customers()
|
||||
|
|
@ -451,25 +542,49 @@ class FlutterStyleDashboard:
|
|||
else:
|
||||
logging.error("PDF再生成失敗")
|
||||
|
||||
def edit_invoice(_=None):
|
||||
if not isinstance(slip, Invoice):
|
||||
return
|
||||
self.open_invoice_edit(slip)
|
||||
|
||||
actions_row = None
|
||||
if isinstance(slip, Invoice):
|
||||
buttons = []
|
||||
if not getattr(slip, "submitted_to_tax_authority", False):
|
||||
buttons.append(
|
||||
ft.IconButton(
|
||||
ft.Icons.REPLAY_CIRCLE_FILLED,
|
||||
tooltip="赤伝(相殺)を発行",
|
||||
icon_color=ft.Colors.RED_400,
|
||||
on_click=issue_offset,
|
||||
ft.ElevatedButton(
|
||||
content=ft.Text("編集", color=ft.Colors.WHITE),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.GREEN_600,
|
||||
padding=ft.padding.all(10),
|
||||
),
|
||||
on_click=edit_invoice,
|
||||
width=80,
|
||||
height=40,
|
||||
)
|
||||
)
|
||||
if not getattr(slip, "submitted_to_tax_authority", False):
|
||||
buttons.append(
|
||||
ft.IconButton(
|
||||
ft.Icons.CHECK_CIRCLE,
|
||||
tooltip="税務署提出済みに設定",
|
||||
icon_color=ft.Colors.ORANGE_400,
|
||||
ft.ElevatedButton(
|
||||
content=ft.Text("赤伝", color=ft.Colors.WHITE),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.RED_600,
|
||||
padding=ft.padding.all(10),
|
||||
),
|
||||
on_click=issue_offset,
|
||||
width=80,
|
||||
height=40,
|
||||
)
|
||||
)
|
||||
buttons.append(
|
||||
ft.ElevatedButton(
|
||||
content=ft.Text("提出", color=ft.Colors.WHITE),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.ORANGE_600,
|
||||
padding=ft.padding.all(10),
|
||||
),
|
||||
on_click=lambda _: self.submit_invoice_for_tax(slip.uuid),
|
||||
width=80,
|
||||
height=40,
|
||||
)
|
||||
)
|
||||
buttons.append(
|
||||
|
|
@ -480,7 +595,7 @@ class FlutterStyleDashboard:
|
|||
on_click=regenerate_and_share,
|
||||
)
|
||||
)
|
||||
actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.END)
|
||||
actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.CENTER, spacing=10)
|
||||
|
||||
display_amount = amount
|
||||
if isinstance(slip, Invoice) and getattr(slip, "is_offset", False):
|
||||
|
|
@ -547,11 +662,441 @@ class FlutterStyleDashboard:
|
|||
border_radius=8,
|
||||
)
|
||||
|
||||
def open_invoice_edit(self, invoice: Invoice):
|
||||
"""伝票編集画面を開く"""
|
||||
self.editing_invoice = invoice
|
||||
self.is_edit_mode = True
|
||||
self.selected_customer = invoice.customer
|
||||
self.selected_document_type = invoice.document_type
|
||||
self.amount_value = str(invoice.items[0].unit_price if invoice.items else "0")
|
||||
self.is_detail_edit_mode = False # 初期はビューモード
|
||||
self.current_tab = 1 # 詳細編集タブに切り替え
|
||||
self.update_main_content()
|
||||
|
||||
def open_new_customer_form(self):
|
||||
"""新規顧客フォームを開く(画面内遷移)"""
|
||||
self.is_new_customer_form_open = True
|
||||
self.update_main_content()
|
||||
|
||||
def create_invoice_edit_screen(self) -> ft.Container:
|
||||
"""伝票編集画面"""
|
||||
if self.editing_invoice:
|
||||
# 既存伝票の編集
|
||||
return self._create_edit_existing_screen()
|
||||
else:
|
||||
# 新規伝票作成
|
||||
return self.create_slip_input_screen()
|
||||
|
||||
def _create_edit_existing_screen(self) -> ft.Container:
|
||||
"""既存伝票の編集画面"""
|
||||
# 編集不可チェック
|
||||
is_locked = getattr(self.editing_invoice, 'submitted_to_tax_authority', False)
|
||||
is_view_mode = not getattr(self, 'is_detail_edit_mode', False)
|
||||
|
||||
# 伝票種類選択(ヘッダーに移動)
|
||||
document_types = list(DocumentType)
|
||||
|
||||
# 明細テーブル
|
||||
items_table = self._create_items_table(self.editing_invoice.items, is_locked or is_view_mode)
|
||||
|
||||
# 顧客表示
|
||||
customer_display = ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Text("顧客: ", weight=ft.FontWeight.BOLD),
|
||||
ft.Text(self.editing_invoice.customer.name), # 顧客名を表示
|
||||
]),
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=5),
|
||||
bgcolor=ft.Colors.GREY_100,
|
||||
border_radius=5,
|
||||
)
|
||||
|
||||
# 備考フィールド
|
||||
notes_field = ft.TextField(
|
||||
label="備考",
|
||||
value=getattr(self.editing_invoice, 'notes', ''),
|
||||
disabled=is_locked or is_view_mode,
|
||||
multiline=True,
|
||||
min_lines=2,
|
||||
max_lines=3,
|
||||
)
|
||||
|
||||
def toggle_edit_mode(_):
|
||||
"""編集モード切替"""
|
||||
old_mode = getattr(self, 'is_detail_edit_mode', False)
|
||||
self.is_detail_edit_mode = not old_mode
|
||||
logging.debug(f"Toggle edit mode: {old_mode} -> {self.is_detail_edit_mode}")
|
||||
self.update_main_content()
|
||||
|
||||
def save_changes(_):
|
||||
if is_locked:
|
||||
return
|
||||
|
||||
# 伝票を更新(現在の明細を保持)
|
||||
# TODO: テーブルから実際の値を取得して更新
|
||||
# 現在は既存の明細を維持し、備考のみ更新
|
||||
self.editing_invoice.notes = notes_field.value
|
||||
self.editing_invoice.document_type = self.selected_document_type
|
||||
|
||||
# TODO: ハッシュ再計算と保存
|
||||
logging.info(f"伝票更新: {self.editing_invoice.invoice_number}")
|
||||
|
||||
# 編集モード終了(ビューモードに戻る)
|
||||
self.is_detail_edit_mode = False # ビューモードに戻る
|
||||
self.update_main_content()
|
||||
|
||||
def cancel_edit(_):
|
||||
self.is_detail_edit_mode = False
|
||||
self.is_edit_mode = False
|
||||
self.editing_invoice = None
|
||||
self.current_tab = 0 # 一覧タブに戻る
|
||||
self.update_main_content()
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
# ヘッダー
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Container(expand=True),
|
||||
# コンパクトな伝票種類選択(セグメント化)
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.GestureDetector(
|
||||
content=ft.Container(
|
||||
content=ft.Text(
|
||||
doc_type.value,
|
||||
size=10,
|
||||
color=ft.Colors.WHITE if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_600,
|
||||
weight=ft.FontWeight.BOLD if doc_type == self.editing_invoice.document_type else ft.FontWeight.NORMAL,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=4),
|
||||
bgcolor=ft.Colors.BLUE_600 if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_300,
|
||||
border_radius=ft.border_radius.all(4),
|
||||
margin=ft.margin.only(right=1),
|
||||
),
|
||||
on_tap=lambda _, dt=doc_type: self.select_document_type(dt.value) if not is_locked and not is_view_mode else None,
|
||||
) for doc_type in document_types
|
||||
]),
|
||||
padding=ft.padding.all(2),
|
||||
bgcolor=ft.Colors.GREY_200,
|
||||
border_radius=ft.border_radius.all(6),
|
||||
margin=ft.margin.only(right=10),
|
||||
),
|
||||
ft.ElevatedButton(
|
||||
content=ft.Text("編集" if is_view_mode else "保存"),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.BLUE_600 if is_view_mode else ft.Colors.GREEN_600,
|
||||
),
|
||||
on_click=toggle_edit_mode if is_view_mode else save_changes,
|
||||
disabled=is_locked,
|
||||
width=70,
|
||||
height=30,
|
||||
) if not is_locked else ft.Container(),
|
||||
ft.Container(width=5),
|
||||
ft.IconButton(ft.Icons.CLOSE, on_click=cancel_edit),
|
||||
]),
|
||||
padding=ft.padding.symmetric(horizontal=15, vertical=8),
|
||||
bgcolor=ft.Colors.BLUE_GREY,
|
||||
),
|
||||
|
||||
# 基本情報行(コンパクトに)
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Text(f"{self.editing_invoice.invoice_number} | {self.editing_invoice.date.strftime('%Y/%m/%d')} | {self.editing_invoice.customer.name}", size=12, weight=ft.FontWeight.BOLD),
|
||||
ft.Container(expand=True),
|
||||
]),
|
||||
padding=ft.padding.symmetric(horizontal=15, vertical=3),
|
||||
bgcolor=ft.Colors.GREY_50,
|
||||
),
|
||||
|
||||
# 明細テーブル(フレキシブルに)
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Container(expand=True),
|
||||
ft.IconButton(
|
||||
ft.Icons.ADD_CIRCLE_OUTLINE,
|
||||
tooltip="行を追加",
|
||||
icon_color=ft.Colors.GREEN_600,
|
||||
disabled=is_locked or is_view_mode,
|
||||
on_click=lambda _: self._add_item_row(),
|
||||
) if not is_locked and not is_view_mode else ft.Container(),
|
||||
]),
|
||||
ft.Container(height=3),
|
||||
ft.Container(
|
||||
content=items_table,
|
||||
height=300, # 高さを縮小
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
border_radius=5,
|
||||
padding=ft.padding.all(1), # パディングを最小化
|
||||
width=None, # 幅を可変に
|
||||
expand=True, # 利用可能な幅を全て使用
|
||||
),
|
||||
ft.Container(height=10),
|
||||
# 合計金額表示
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Container(expand=True),
|
||||
ft.Text("合計: ", size=14, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(
|
||||
f"¥{sum(item.subtotal for item in self.editing_invoice.items):,}",
|
||||
size=16,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_600
|
||||
),
|
||||
]),
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=8),
|
||||
bgcolor=ft.Colors.GREY_100,
|
||||
border_radius=5,
|
||||
),
|
||||
]),
|
||||
padding=ft.padding.all(15),
|
||||
expand=True, # 明細部分が最大限のスペースを占有
|
||||
),
|
||||
|
||||
# 備考(コンパクト)
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("備考", size=12, weight=ft.FontWeight.BOLD),
|
||||
ft.Container(height=3),
|
||||
notes_field,
|
||||
ft.Container(height=5),
|
||||
ft.Text("🔒 税務署提出済みは編集できません" if is_locked else "✅ " + ("編集モード" if not is_view_mode else "ビューモード"),
|
||||
size=11, color=ft.Colors.RED_600 if is_locked else (ft.Colors.GREEN_600 if not is_view_mode else ft.Colors.BLUE_600)),
|
||||
ft.Container(height=10),
|
||||
# PDF生成ボタンを追加
|
||||
ft.ElevatedButton(
|
||||
content=ft.Row([
|
||||
ft.Icon(ft.Icons.DOWNLOAD, size=16),
|
||||
ft.Container(width=5),
|
||||
ft.Text("PDF生成", size=12, color=ft.Colors.WHITE),
|
||||
]),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.BLUE_600,
|
||||
padding=ft.padding.symmetric(horizontal=15, vertical=10),
|
||||
),
|
||||
on_click=lambda _: self.generate_pdf_from_edit(),
|
||||
width=120,
|
||||
height=40,
|
||||
) if not is_locked else ft.Container(),
|
||||
]),
|
||||
padding=ft.padding.symmetric(horizontal=15, vertical=10),
|
||||
bgcolor=ft.Colors.GREY_50,
|
||||
),
|
||||
]),
|
||||
expand=True,
|
||||
)
|
||||
def generate_pdf_from_edit(self):
|
||||
"""編集画面からPDFを生成"""
|
||||
if not self.editing_invoice:
|
||||
return
|
||||
|
||||
try:
|
||||
pdf_path = self.app_service.invoice.regenerate_pdf(self.editing_invoice.uuid)
|
||||
if pdf_path:
|
||||
self.editing_invoice.file_path = pdf_path
|
||||
self.editing_invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat()
|
||||
logging.info(f"PDF生成完了: {pdf_path}")
|
||||
# TODO: 成功メッセージ表示
|
||||
else:
|
||||
logging.error("PDF生成失敗")
|
||||
# TODO: エラーメッセージ表示
|
||||
except Exception as e:
|
||||
logging.error(f"PDF生成エラー: {e}")
|
||||
# TODO: エラーメッセージ表示
|
||||
|
||||
def _add_item_row(self):
|
||||
"""明細行を追加"""
|
||||
if not self.editing_invoice:
|
||||
return
|
||||
|
||||
# 新しい明細行を追加
|
||||
new_item = InvoiceItem(
|
||||
description="新しい商品",
|
||||
quantity=1,
|
||||
unit_price=0,
|
||||
is_discount=False
|
||||
)
|
||||
self.editing_invoice.items.append(new_item)
|
||||
self.update_main_content()
|
||||
|
||||
def _delete_item_row(self, index: int):
|
||||
"""明細行を削除"""
|
||||
if not self.editing_invoice or index >= len(self.editing_invoice.items):
|
||||
return
|
||||
|
||||
# 行を削除(最低1行は残す)
|
||||
if len(self.editing_invoice.items) > 1:
|
||||
del self.editing_invoice.items[index]
|
||||
self.update_main_content()
|
||||
|
||||
def _update_item_field(self, item_index: int, field_name: str, value: str):
|
||||
"""明細フィールドを更新"""
|
||||
if not self.editing_invoice or item_index >= len(self.editing_invoice.items):
|
||||
return
|
||||
|
||||
item = self.editing_invoice.items[item_index]
|
||||
|
||||
# デバッグ用:更新前の値をログ出力
|
||||
old_value = getattr(item, field_name)
|
||||
logging.debug(f"Updating item {item_index} {field_name}: '{old_value}' -> '{value}'")
|
||||
|
||||
if field_name == 'description':
|
||||
item.description = value
|
||||
elif field_name == 'quantity':
|
||||
try:
|
||||
# 空文字の場合は1を設定
|
||||
if not value or value.strip() == '':
|
||||
item.quantity = 1
|
||||
else:
|
||||
item.quantity = int(value)
|
||||
logging.debug(f"Quantity updated to: {item.quantity}")
|
||||
except ValueError as e:
|
||||
item.quantity = 1
|
||||
logging.error(f"Quantity update error: {e}")
|
||||
elif field_name == 'unit_price':
|
||||
try:
|
||||
# 空文字の場合は0を設定
|
||||
if not value or value.strip() == '':
|
||||
item.unit_price = 0
|
||||
else:
|
||||
item.unit_price = int(value)
|
||||
logging.debug(f"Unit price updated to: {item.unit_price}")
|
||||
except ValueError as e:
|
||||
item.unit_price = 0
|
||||
logging.error(f"Unit price update error: {e}")
|
||||
|
||||
# 合計金額を更新するために画面を更新
|
||||
self.update_main_content()
|
||||
|
||||
def _create_items_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column:
|
||||
"""明細テーブルを作成(編集モードと表示モードで別の見せ方)"""
|
||||
# ビューモード状態を取得
|
||||
is_view_mode = not getattr(self, 'is_detail_edit_mode', False)
|
||||
|
||||
# デバッグ用:メソッド開始時の状態をログ出力
|
||||
logging.debug(f"Creating items table: locked={is_locked}, view_mode={is_view_mode}, edit_mode={getattr(self, 'is_detail_edit_mode', False)}")
|
||||
logging.debug(f"Items count: {len(items)}")
|
||||
|
||||
if is_view_mode or is_locked:
|
||||
# 表示モード:表形式で整然と表示
|
||||
return self._create_view_mode_table(items)
|
||||
else:
|
||||
# 編集モード:入力フォーム風に表示
|
||||
return self._create_edit_mode_table(items, is_locked)
|
||||
|
||||
def _create_view_mode_table(self, items: List[InvoiceItem]) -> 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=35), # 固定幅
|
||||
ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅
|
||||
ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅
|
||||
ft.Container(width=35), # 削除ボタン用スペースを確保
|
||||
])
|
||||
|
||||
# データ行
|
||||
data_rows = []
|
||||
for i, item in enumerate(items):
|
||||
row = ft.Row([
|
||||
ft.Text(item.description, size=12, expand=True), # 可変幅
|
||||
ft.Text(str(item.quantity), size=12, width=35, text_align=ft.TextAlign.RIGHT), # 固定幅
|
||||
ft.Text(f"¥{item.unit_price:,}", size=12, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅
|
||||
ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅
|
||||
ft.Container(width=35), # 削除ボタン用スペース
|
||||
])
|
||||
data_rows.append(row)
|
||||
|
||||
return ft.Column([
|
||||
header_row,
|
||||
ft.Divider(height=1, color=ft.Colors.GREY_400),
|
||||
ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限
|
||||
])
|
||||
|
||||
def _create_edit_mode_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column:
|
||||
"""編集モード:フレキシブルな表形式"""
|
||||
# 編集モードに入ったら自動で空行を追加
|
||||
if not any(item.description == "" and item.quantity == 0 and item.unit_price == 0 for item in items):
|
||||
items.append(InvoiceItem(description="", quantity=0, unit_price=0))
|
||||
|
||||
# ヘッダー行
|
||||
header_row = ft.Row([
|
||||
ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅
|
||||
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), # 固定幅
|
||||
ft.Container(width=35), # 削除ボタン用スペースを確保
|
||||
])
|
||||
|
||||
# データ行
|
||||
data_rows = []
|
||||
for i, item in enumerate(items):
|
||||
# 商品名フィールド
|
||||
product_field = ft.TextField(
|
||||
value=item.description,
|
||||
text_size=12,
|
||||
height=28,
|
||||
width=None, # 幅を可変に
|
||||
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: self._update_item_field(i, 'description', e.control.value),
|
||||
)
|
||||
|
||||
# 数量フィールド
|
||||
quantity_field = ft.TextField(
|
||||
value=str(item.quantity),
|
||||
text_size=12,
|
||||
height=28,
|
||||
width=35, # 固定幅
|
||||
text_align=ft.TextAlign.RIGHT,
|
||||
border=ft.border.all(1, ft.Colors.BLUE_200),
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
content_padding=ft.padding.all(5), # 内部余白を最小化
|
||||
on_change=lambda e: self._update_item_field(i, 'quantity', e.control.value),
|
||||
keyboard_type=ft.KeyboardType.NUMBER,
|
||||
)
|
||||
|
||||
# 単価フィールド
|
||||
unit_price_field = ft.TextField(
|
||||
value=f"{item.unit_price:,}",
|
||||
text_size=12,
|
||||
height=28,
|
||||
width=70, # 固定幅
|
||||
text_align=ft.TextAlign.RIGHT,
|
||||
border=ft.border.all(1, ft.Colors.BLUE_200),
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
content_padding=ft.padding.all(5), # 内部余白を最小化
|
||||
on_change=lambda e: self._update_item_field(i, 'unit_price', e.control.value.replace(',', '')),
|
||||
keyboard_type=ft.KeyboardType.NUMBER,
|
||||
)
|
||||
|
||||
# 削除ボタン
|
||||
delete_button = ft.IconButton(
|
||||
ft.Icons.DELETE_OUTLINE,
|
||||
tooltip="行を削除",
|
||||
icon_color=ft.Colors.RED_600,
|
||||
disabled=is_locked,
|
||||
icon_size=16,
|
||||
on_click=lambda _, idx=i: self._delete_item_row(idx),
|
||||
)
|
||||
|
||||
# データ行
|
||||
row = ft.Row([
|
||||
product_field,
|
||||
quantity_field,
|
||||
unit_price_field,
|
||||
ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅
|
||||
delete_button,
|
||||
])
|
||||
data_rows.append(row)
|
||||
|
||||
return ft.Column([
|
||||
header_row,
|
||||
ft.Divider(height=1, color=ft.Colors.GREY_400),
|
||||
ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限
|
||||
])
|
||||
def create_new_customer_screen(self) -> ft.Container:
|
||||
"""新規顧客登録画面"""
|
||||
name_field = ft.TextField(label="顧客名(略称)")
|
||||
|
|
@ -629,6 +1174,16 @@ class FlutterStyleDashboard:
|
|||
# UIを更新して選択された顧客を表示
|
||||
self.update_main_content()
|
||||
|
||||
def submit_invoice_for_tax(self, invoice_uuid: str) -> bool:
|
||||
"""税務署提出済みフラグを設定"""
|
||||
success = self.app_service.invoice.submit_to_tax_authority(invoice_uuid)
|
||||
if success:
|
||||
self.invoices = self.app_service.invoice.get_recent_invoices(20)
|
||||
self.update_main_content()
|
||||
logging.info(f"税務署提出済み: {invoice_uuid}")
|
||||
else:
|
||||
logging.error(f"税務署提出失敗: {invoice_uuid}")
|
||||
return success
|
||||
def on_customer_deleted(self, customer: Customer):
|
||||
"""顧客削除時の処理"""
|
||||
self.app_service.customer.delete_customer(customer.id)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
Flutter風のModalBottomSheetをFletで実装
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import flet as ft
|
||||
from typing import List, Callable, Optional
|
||||
from models.invoice_models import Customer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (SELF001) /CreationDate (D:20260221233306+09'00') /Creator (anonymous) /Keywords (node_id=2026-02-20 12:41:09) /ModDate (D:20260221233306+09'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (3772e7ed-19be-4049-ac62-025c725d0912) /Title (\376\377\212\313lBf\370\000 \0002\0000\0002\0006\0000\0002\0002\0000\000-\0002\0001\0004\0001) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 556
|
||||
>>
|
||||
stream
|
||||
Gatm94\rsL&Dd4649e37qlnCi7jOH5;arYj"sh@khmg11=sm?a72%,-m!JKpj7u@YmA9CPpic))GE4E<LgC&FaFe^8X*a7nAXsk=@@\(-dlNI@Jq@U[jJ)ULJ7eXp+IRelrI07ZqQGXgrdLTI&?pi'CIAr2#f\+:3M`-HC0)3lLaEBUH'-=0UJ92(*$adZ2[?`6aB+cc?=Q01^!)PX+3Uj7qgfA>1?4\B;fQt]12\ROd2;NX?G0*&X$GL/!+n=)3ME98`Bms,$-EN.`D`*Qd*S].F"0q%5[/T%gpn9\WnKg85_NALLp@a;F;n/u7d^2@F:\S^9\(N7lcj!ALlp\6]Eo/q\l!.(a5_7TDa%<;.B!2k]=?<\&;B4:;IR./?IdW45;Cj]5cm.l1]:#_Zda$&"'QSWZO\&F!>4'?:C9W:(W+b$Tr/;?Xj/*n)C/QDEGZP!&8_OV!s'al<j*RR#DQ8cf(=")=?RSIPq8Rl-L6MD4S7]*>_+@6eN5""Cm'uTK5h*$]>dMlW\igb%dE8`<Gh-YF9O3<9uJ2/\+p4&@'Zj~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000414 00000 n
|
||||
0000000617 00000 n
|
||||
0000000685 00000 n
|
||||
0000001081 00000 n
|
||||
0000001140 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<14435c1529362f9feebdcdad3f6da730><14435c1529362f9feebdcdad3f6da730>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 10
|
||||
>>
|
||||
startxref
|
||||
1786
|
||||
%%EOF
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (SELF) /CreationDate (D:20260221162914+09'00') /Creator (anonymous) /Keywords () /ModDate (D:20260221162914+09'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (aaa8cbcc-06c2-4aff-9955-0d7eda07d4e7) /Title (\376\377\212\313lBf\370\000 \000T\000E\000S\000T\0000\0000\0001) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 571
|
||||
>>
|
||||
stream
|
||||
Gatm9]8kZ#%.*F5=?8abj$TQ:/H]7X+AH+<$]lRJD>q9XmIT$^>O/A1$Nl,lE]AH*-jK6ta7lc=J1j&JIq<#=r\nf3!<"j$l(>uq!YU7fOteWiGmY,mX[lkkrfn_=l`5Y["mXCro*=LZ1ue0MB.?].`Y;sEa:hOLF<S5M$oA>j*UZZD[>^6TC^OA;1eG7l#`29a25gs-3TMf7`-ne7>G"S2?=3I*01,^0UF5%UbZ2!FWJf#'/1:]12Rf^gF][U1=\`a!07ZP'`B>6/&>kt`q,F"Y$.YP\#QF)9d`O0<#-c7WN#(,1)6m^_-FkC*@4ER'Q?i]658R0'aW#7fY=ncee0/:g&uWc<;eF8OT[^>BkOB_C6MAoLamY.2[fhLAWTZVSFO.9Whi0V>E!rn?S#q!7p)QD!e_Ao!p)T/>q>R@`InGM0kn=Z:@=\lE<UYV.0DiNg"1\b<)(fUY=2AM#hiVbBN<92mYLX3@->s<p\m1EY4mMuZ85G/<Kp7!4!R!0\$ZQ?M'OFujmaNLpaFeP"gUjFj1M/c$Hk#?*h-?@C0T0rRBd2CHK;X2-U$!m~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000414 00000 n
|
||||
0000000617 00000 n
|
||||
0000000685 00000 n
|
||||
0000001021 00000 n
|
||||
0000001080 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<647dfafbf6f40c211061b89af760d29a><647dfafbf6f40c211061b89af760d29a>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 10
|
||||
>>
|
||||
startxref
|
||||
1741
|
||||
%%EOF
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (SELF001) /CreationDate (D:20260221233933+09'00') /Creator (anonymous) /Keywords (node_id=2026-02-20 12:39:44) /ModDate (D:20260221233933+09'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (7face002-113c-45ad-a89c-a73082390cf6) /Title (\376\377\212\313lBf\370\000 \0002\0000\0002\0006\0000\0002\0002\0000\000-\0002\0001\0003\0009) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 556
|
||||
>>
|
||||
stream
|
||||
Gatm94\rsL&Dd4649e37qlnCi7jOH5;arYj"sh@khmg11=sm?a72%,-m!JKpj7u@YmA9CPpic))GE4E<LgC&FaFe^8X*a7nAXsk=@@\(-dlNI@Jq@U[jJ)ULJ7eXp+IRelrI07ZqQGXgrdLTI&?pi'CIAr2#f\+:3M`-HC0)3lLaEBUH'-=0UJ92(*$adZ2[?`6aB+cc?=Q01^!)PX+3Uj7qgfA>1?4ZlEcH8'12\ROd2;NX?G0*&X$GL/!+n=)3ME98`Bms,$-EN.`D`*Qd*S].F"0q%5[/T%gpn9\WnKg85_NALLp@a;F;n/u7d^2@F:\S^9\(N7lcj!ALlp\6]Eo/q\l!.(a5_7TDa%<;.B!2k]=?<\&;B4:;IR./?IdW45;Cj]5cm.l1]:#_Zda$&"'QSWZO\&F!>4'?:C9W:(W+b$Tr/;?Xj/*n)C/QDEGZP!&8_OV!s'al<j*RR#DQ8cf(=")=?RSIPq8Rl-L6MD4S7]*>_+@6eN5""Cm'uTK5h*$]>dMlW\igb%dE8`<Gh-YF9O3<9uJ2/\+p1N:U7;~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000414 00000 n
|
||||
0000000617 00000 n
|
||||
0000000685 00000 n
|
||||
0000001081 00000 n
|
||||
0000001140 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<473678096162e2d017466f3e776ba6ca><473678096162e2d017466f3e776ba6ca>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 10
|
||||
>>
|
||||
startxref
|
||||
1786
|
||||
%%EOF
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (SELF) /CreationDate (D:20260221162429+09'00') /Creator (anonymous) /Keywords () /ModDate (D:20260221162429+09'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (4458bd92-1629-45aa-b3d9-00376ff9d14f) /Title (\376\377\212\313lBf\370\000 \000T\000E\000S\000T\0000\0000\0001) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 541
|
||||
>>
|
||||
stream
|
||||
Gatm9\PC%-&FK'(;]:=D3VH.''IlM'90+=*,f9S*N#g[Cg5_@^'KK@am$frSgK=`d+1M7lhZ-G04?bR*5lD#Ucp@U3]6_ro$]&Tl-"/k&F6CCF$s>>QZrQ[-8&#M/'2Z*on*cqK>4>VN%oa;G[3<U0OTh[UYpUGW)O_mLl0^kZ%1,13Sg?5T=X9uT+=*Ye9gQr6P,jTN?ZUV9HCgaN$d5`"rB[B+D`,qYql!W&W/Fe_Jl?d-KqV`TW#E'n@bFK_/HK`b.'DeW#[pEsfUi5p.:M#/_Hu`nej?G6p,O9P8P`Dq$9])hZB48"m?NbaXV7]r(3-Is+ou37GT1SDN/;HeRca$=h8GdU(0\lFJ)2fD!&6qXrMp'Up;CKCKgOb&RU!M!@,s%h];ER+GB)J(&5iIu!,'0=<NcIrR?*ajOHj)$(1-rca_0Z(ilK*9Hp^7-Esu-2iKiYKMC+&N(#lIIEqKp;[E2V60K6-!faCWMXO>NE\e5'3C*4D3g1t=F$A"!)a,m;@E.r!6_^dHo/0YRT_<)R#-G0i~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000414 00000 n
|
||||
0000000617 00000 n
|
||||
0000000685 00000 n
|
||||
0000001021 00000 n
|
||||
0000001080 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<9f94a7ecbd060db21eed7c31734d1ad5><9f94a7ecbd060db21eed7c31734d1ad5>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 10
|
||||
>>
|
||||
startxref
|
||||
1711
|
||||
%%EOF
|
||||
|
|
@ -16,14 +16,43 @@ class DocumentType(Enum):
|
|||
RECEIPT = "領収書"
|
||||
SALES = "売上伝票"
|
||||
|
||||
class Product:
|
||||
"""商品マスタモデル"""
|
||||
|
||||
def __init__(self, id: Optional[int] = None, name: str = "", unit_price: int = 0, description: str = ""):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.unit_price = unit_price
|
||||
self.description = description
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON変換"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'unit_price': self.unit_price,
|
||||
'description': self.description
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Product':
|
||||
"""JSONから復元"""
|
||||
return cls(
|
||||
id=data.get('id'),
|
||||
name=data.get('name', ''),
|
||||
unit_price=data.get('unit_price', 0),
|
||||
description=data.get('description', '')
|
||||
)
|
||||
|
||||
class InvoiceItem:
|
||||
"""伝票の各明細行を表すモデル"""
|
||||
|
||||
def __init__(self, description: str, quantity: int, unit_price: int, is_discount: bool = False):
|
||||
def __init__(self, description: str, quantity: int, unit_price: int, is_discount: bool = False, product_id: Optional[int] = None):
|
||||
self.description = description
|
||||
self.quantity = quantity
|
||||
self.unit_price = unit_price
|
||||
self.is_discount = is_discount # 値引き項目かどうかを示すフラグ
|
||||
self.product_id = product_id # 商品マスタID
|
||||
|
||||
@property
|
||||
def subtotal(self) -> int:
|
||||
|
|
@ -36,7 +65,8 @@ class InvoiceItem:
|
|||
description=kwargs.get('description', self.description),
|
||||
quantity=kwargs.get('quantity', self.quantity),
|
||||
unit_price=kwargs.get('unit_price', self.unit_price),
|
||||
is_discount=kwargs.get('is_discount', self.is_discount)
|
||||
is_discount=kwargs.get('is_discount', self.is_discount),
|
||||
product_id=kwargs.get('product_id', self.product_id)
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
|
@ -45,7 +75,8 @@ class InvoiceItem:
|
|||
'description': self.description,
|
||||
'quantity': self.quantity,
|
||||
'unit_price': self.unit_price,
|
||||
'is_discount': self.is_discount
|
||||
'is_discount': self.is_discount,
|
||||
'product_id': self.product_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -55,18 +86,20 @@ class InvoiceItem:
|
|||
description=data['description'],
|
||||
quantity=data['quantity'],
|
||||
unit_price=data['unit_price'],
|
||||
is_discount=data.get('is_discount', False)
|
||||
is_discount=data.get('is_discount', False),
|
||||
product_id=data.get('product_id')
|
||||
)
|
||||
|
||||
class Customer:
|
||||
"""顧客情報モデル"""
|
||||
|
||||
def __init__(self, id: int, name: str, formal_name: str, address: str = "", phone: str = ""):
|
||||
def __init__(self, id: int, name: str, formal_name: str, address: str = "", phone: str = "", email: str = ""):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.formal_name = formal_name
|
||||
self.address = address
|
||||
self.phone = phone
|
||||
self.email = email
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON変換"""
|
||||
|
|
@ -86,7 +119,8 @@ class Customer:
|
|||
name=data['name'],
|
||||
formal_name=data['formal_name'],
|
||||
address=data.get('address', ''),
|
||||
phone=data.get('phone', '')
|
||||
phone=data.get('phone', ''),
|
||||
email=data.get('email', '')
|
||||
)
|
||||
|
||||
class Invoice:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType
|
||||
from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType, Product
|
||||
from services.repositories import InvoiceRepository, CustomerRepository
|
||||
from services.pdf_generator import PdfGenerator
|
||||
import logging
|
||||
|
|
@ -226,11 +226,20 @@ class InvoiceService:
|
|||
logging.error(f"PDF再生成失敗: uuidが見つかりません: {invoice_uuid}")
|
||||
return None
|
||||
|
||||
# 既存のPDFパスとnotesをクリアして新規生成
|
||||
invoice.file_path = None # 既存パスをクリア
|
||||
invoice.notes = "" # notesもクリア(古いパスが含まれている可能性)
|
||||
|
||||
# 会社情報は現状v1固定。将来はcompany_info_versionで分岐。
|
||||
pdf_path = self.pdf_generator.generate_invoice_pdf(invoice, self.company_info)
|
||||
if not pdf_path:
|
||||
return None
|
||||
|
||||
# 新しいPDFパスをDBに保存
|
||||
invoice.file_path = pdf_path
|
||||
invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat()
|
||||
self.invoice_repo.update_invoice_file_path(invoice.uuid, pdf_path, invoice.pdf_generated_at)
|
||||
|
||||
return pdf_path
|
||||
|
||||
def delete_pdf_file(self, pdf_path: str) -> bool:
|
||||
|
|
@ -335,12 +344,28 @@ class CustomerService:
|
|||
|
||||
|
||||
# サービスファクトリ
|
||||
class ProductService:
|
||||
"""商品ビジネスロジック"""
|
||||
|
||||
def __init__(self):
|
||||
self.invoice_repo = InvoiceRepository()
|
||||
|
||||
def get_all_products(self) -> List[Product]:
|
||||
"""全商品を取得"""
|
||||
return self.invoice_repo.get_all_products()
|
||||
|
||||
def save_product(self, product: Product) -> bool:
|
||||
"""商品を保存(新規・更新)"""
|
||||
return self.invoice_repo.save_product(product)
|
||||
|
||||
|
||||
class AppService:
|
||||
"""アプリケーションサービス統合"""
|
||||
|
||||
def __init__(self):
|
||||
self.invoice = InvoiceService()
|
||||
self.customer = CustomerService()
|
||||
self.product = ProductService()
|
||||
|
||||
def get_dashboard_data(self) -> Dict[str, Any]:
|
||||
"""ダッシュボード表示用データ"""
|
||||
|
|
|
|||
|
|
@ -42,9 +42,41 @@ class PdfGenerator:
|
|||
生成されたPDFファイルパス、失敗時はNone
|
||||
"""
|
||||
try:
|
||||
# ファイル名生成: {会社ID}_{端末ID}_{連番}.pdf
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{company_info.get('id', 'COMP')}_{timestamp}.pdf"
|
||||
# ファイル名生成ルール: {日付}({タイプ}){株式会社等を除く顧客名}_{件名or商品1行目}_{金額}円_{HASH下8桁}.PDF
|
||||
date_str = invoice.date.strftime("%Y%m%d")
|
||||
doc_type = invoice.document_type.value if invoice.document_type else "請求"
|
||||
|
||||
# 顧客名から株式会社等を除去
|
||||
customer_name = invoice.customer.name
|
||||
for suffix in ["株式会社", "有限会社", "合資会社", "合同会社"]:
|
||||
customer_name = customer_name.replace(suffix, "")
|
||||
|
||||
# 顧客名から不正な文字を除去(ファイル名に使えない文字)
|
||||
import re
|
||||
customer_name = re.sub(r'[\\/:*?"<>|]', '', customer_name)
|
||||
|
||||
# 件名または商品1行目
|
||||
subject_or_product = invoice.notes or ""
|
||||
if not subject_or_product and invoice.items:
|
||||
subject_or_product = invoice.items[0].description
|
||||
|
||||
# 件名から不正な文字を除去
|
||||
subject_or_product = re.sub(r'[\\/:*?"<>|]', '', subject_or_product)
|
||||
|
||||
# 件名が長すぎる場合は短縮
|
||||
if len(subject_or_product) > 30:
|
||||
subject_or_product = subject_or_product[:30] + "..."
|
||||
|
||||
# 金額
|
||||
total_amount = sum(item.subtotal for item in invoice.items)
|
||||
amount_str = f"{total_amount:,}円"
|
||||
|
||||
# ハッシュ(仮実装)
|
||||
import hashlib
|
||||
hash_input = f"{date_str}{doc_type}{customer_name}{subject_or_product}{total_amount}"
|
||||
hash_value = hashlib.md5(hash_input.encode()).hexdigest()[:8]
|
||||
|
||||
filename = f"{date_str}({doc_type}){customer_name}_{subject_or_product}_{amount_str}_{hash_value}.PDF"
|
||||
filepath = os.path.join(self.output_dir, filename)
|
||||
|
||||
# PDF生成
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import sqlite3
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType
|
||||
from models.invoice_models import Invoice, Customer, InvoiceItem, DocumentType, Product
|
||||
import logging
|
||||
import uuid as uuid_module
|
||||
import hashlib
|
||||
|
|
@ -82,10 +82,23 @@ class InvoiceRepository:
|
|||
quantity INTEGER,
|
||||
unit_price INTEGER,
|
||||
is_discount BOOLEAN,
|
||||
product_id INTEGER,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 商品マスタテーブル
|
||||
cursor.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()
|
||||
|
||||
# 既存DB向けの軽量マイグレーション(列追加)
|
||||
|
|
@ -325,6 +338,20 @@ class InvoiceRepository:
|
|||
logging.error(f"伝票保存エラー: {e}")
|
||||
return False
|
||||
|
||||
def update_invoice_file_path(self, invoice_uuid: str, file_path: str, pdf_generated_at: str):
|
||||
"""PDFファイルパスを更新"""
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE invoices
|
||||
SET file_path = ?, pdf_generated_at = ?
|
||||
WHERE uuid = ?
|
||||
''', (file_path, pdf_generated_at, invoice_uuid))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logging.error(f"PDFパス更新エラー: {e}")
|
||||
|
||||
def get_all_invoices(self, limit: int = 100) -> List[Invoice]:
|
||||
"""全伝票を取得"""
|
||||
invoices = []
|
||||
|
|
@ -572,6 +599,66 @@ class CustomerRepository:
|
|||
logging.error(f"顧客更新エラー: {e}")
|
||||
return False
|
||||
|
||||
def get_all_products(self) -> List[Product]:
|
||||
"""全商品を取得"""
|
||||
products = []
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM products ORDER BY name')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
for row in rows:
|
||||
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"商品取得エラー: {e}")
|
||||
return []
|
||||
|
||||
def save_product(self, product: Product) -> bool:
|
||||
"""商品を保存(新規・更新)"""
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if product.id:
|
||||
# 更新
|
||||
cursor.execute("""
|
||||
UPDATE products
|
||||
SET name = ?, unit_price = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
product.name,
|
||||
product.unit_price,
|
||||
product.description,
|
||||
product.id
|
||||
))
|
||||
else:
|
||||
# 新規
|
||||
cursor.execute("""
|
||||
INSERT INTO products (name, unit_price, description)
|
||||
VALUES (?, ?, ?)
|
||||
""", (
|
||||
product.name,
|
||||
product.unit_price,
|
||||
product.description
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"商品保存エラー: {e}")
|
||||
return False
|
||||
|
||||
def get_all_customers(self) -> List[Customer]:
|
||||
"""全顧客を取得"""
|
||||
customers = []
|
||||
|
|
|
|||
Loading…
Reference in a new issue