自動で空行追加

This commit is contained in:
joe 2026-02-21 23:49:15 +09:00
parent 22c4a2e6b7
commit 042bbe032d
10 changed files with 1088 additions and 31 deletions

View file

@ -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,27 +542,51 @@ 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,
on_click=lambda _: self.submit_invoice_for_tax(slip.uuid),
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(
ft.IconButton(
ft.Icons.DOWNLOAD,
@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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]:
"""ダッシュボード表示用データ"""

View file

@ -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生成

View file

@ -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 = []