h-1.flet.3/services/app_service.py
2026-02-22 11:59:22 +09:00

485 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
アプリケーションサービス層
UIとビジネスロジックの橋渡し
"""
import sys
import os
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, Product
from services.repositories import InvoiceRepository, CustomerRepository
from services.pdf_generator import PdfGenerator
import logging
import json
import hashlib
import os
class InvoiceService:
"""伝票ビジネスロジック"""
def __init__(self):
self.invoice_repo = InvoiceRepository()
self.customer_repo = CustomerRepository()
self.pdf_generator = PdfGenerator()
self.company_info = self._load_company_info()
def _load_company_info(self) -> Dict[str, str]:
"""自社情報を読み込み"""
# TODO: 設定ファイルから読み込み
return {
'id': 'SELF001',
'name': '自社',
'address': '',
'phone': '',
'registration_number': ''
}
def _apply_audit_fields(self, invoice: Invoice, customer: Customer):
node_id = self.invoice_repo.get_node_id()
prev_chain_hash = self.invoice_repo.get_last_chain_hash(node_id)
payload_obj = {
"schema": "invoice_payload_v1",
"node_id": node_id,
"uuid": invoice.uuid,
"document_type": invoice.document_type.value,
"invoice_number": invoice.invoice_number,
"date": invoice.date.replace(microsecond=0).isoformat(),
"tax_rate": 0.1,
"tax_calc_rule": "floor(subtotal * tax_rate)",
"customer_master_id": getattr(customer, "id", None),
"customer_snapshot": {
"name": customer.name,
"formal_name": customer.formal_name,
"address": customer.address,
"phone": customer.phone,
},
"items": [
{
"description": it.description,
"quantity": int(it.quantity),
"unit_price": int(it.unit_price),
"is_discount": bool(it.is_discount),
}
for it in invoice.items
],
"notes": invoice.notes or "",
"pdf_template_version": "v1",
"company_info_version": "v1",
"is_offset": bool(getattr(invoice, "is_offset", False)),
"offset_target_uuid": getattr(invoice, "offset_target_uuid", None),
"submitted_to_tax_authority": getattr(invoice, "submitted_to_tax_authority", False),
}
payload_json = json.dumps(payload_obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
chain_hash = hashlib.sha256(f"{prev_chain_hash}:{payload_hash}".encode("utf-8")).hexdigest()
invoice.node_id = node_id
invoice.payload_json = payload_json
invoice.payload_hash = payload_hash
invoice.prev_chain_hash = prev_chain_hash
invoice.chain_hash = chain_hash
invoice.pdf_template_version = "v1"
invoice.company_info_version = "v1"
invoice.submitted_to_tax_authority = False
def create_draft_invoice(self,
customer: Customer,
document_type: DocumentType,
amount: int,
notes: str = "") -> Optional[Invoice]:
"""下書き伝票作成
Args:
customer: 顧客情報
document_type: 帳票種類
amount: 金額(税抜)
notes: 備考
Returns:
作成されたInvoice、失敗時はNone
"""
try:
# 下書き伝票を作成
invoice = Invoice(
customer=customer,
date=datetime.now(),
items=[], # 下書きは空の明細で開始
document_type=document_type,
notes=notes,
is_draft=True # 下書きフラグを設定
)
# DBに保存
saved_invoice = self.invoice_repo.save_invoice(invoice)
if saved_invoice:
logging.info(f"下書き伝票作成成功: {saved_invoice.invoice_number}")
return saved_invoice
else:
logging.error("下書き伝票作成失敗")
return None
except Exception as e:
logging.error(f"下書き伝票作成エラー: {e}")
return None
def create_invoice(self,
customer: Customer,
document_type: DocumentType,
amount: int,
notes: str = "",
items: List[InvoiceItem] = None) -> Optional[Invoice]:
"""新規伝票作成
Args:
customer: 顧客情報
document_type: 帳票種類
amount: 金額(税抜)
notes: 備考
items: 明細リスト(指定しない場合はダミーを作成)
Returns:
作成されたInvoice、失敗時はNone
"""
try:
# 明細作成(指定された明細を使用、なければダミーを作成)
if items is None:
items = [InvoiceItem(
description=f"{document_type.value}",
quantity=1,
unit_price=amount
)]
# 空の明細リストの場合は保存しない(全て空の場合のみ)
if not items or all(item.description == "" and item.quantity == 0 and item.unit_price == 0 for item in items):
logging.warning(f"明細が空のため伝票作成を中止: {customer.name}")
return None
# 空行をフィルタリングせず、そのまま保存(ユーザーが意図した空行を保持)
# TODO: 必要に応じて空行を除外するオプションを提供
filtered_items = items
# 伝票作成
invoice = Invoice(
customer=customer,
date=datetime.now(),
items=items,
document_type=document_type,
notes=notes
)
# --- 長期保管向け: canonical payload + hash chain ---
# TODO: ユーザーが明示的に要求した場合のみ実行するべき
# self._apply_audit_fields(invoice, customer)
# DB保存PDFは仮生成物なので保存の成否と切り離す
invoice.file_path = None
if self.invoice_repo.save_invoice(invoice):
logging.info(f"伝票作成成功: {invoice.invoice_number}")
else:
logging.error("伝票DB保存失敗")
return None
# PDF生成はユーザーが明示的に要求した場合のみ実行
# TODO: 自動生成は無効化するべき
# try:
# pdf_path = self.pdf_generator.generate_invoice_pdf(invoice, self.company_info)
# if pdf_path:
# invoice.file_path = pdf_path
# invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat()
# else:
# logging.warning("PDF生成失敗DB保存は完了")
# except Exception as e:
# logging.warning(f"PDF生成例外DB保存は完了: {e}")
return invoice
except Exception as e:
logging.error(f"伝票作成エラー: {e}")
return None
def create_offset_invoice(self, target_uuid: str, notes: str = "") -> Optional[Invoice]:
"""本伝を相殺する赤伝(マイナス伝票)を作成"""
try:
target = self.invoice_repo.get_invoice_by_uuid(target_uuid)
if not target:
logging.error(f"赤伝作成失敗: 対象uuidが見つかりません: {target_uuid}")
return None
# 1行で相殺値引き行としてマイナス
items = [
InvoiceItem(
description=f"相殺(赤伝) 対象:{target.invoice_number}",
quantity=1,
unit_price=int(target.subtotal),
is_discount=True,
)
]
invoice = Invoice(
customer=target.customer,
date=datetime.now(),
items=items,
document_type=target.document_type,
notes=notes,
)
invoice.is_offset = True
invoice.offset_target_uuid = target.uuid
self._apply_audit_fields(invoice, invoice.customer)
# DB保存PDFは仮生成物
invoice.file_path = None
if not self.invoice_repo.save_invoice(invoice):
logging.error("赤伝DB保存失敗")
return None
# PDF生成任意
try:
pdf_path = self.pdf_generator.generate_invoice_pdf(invoice, self.company_info)
if pdf_path:
invoice.file_path = pdf_path
invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat()
except Exception as e:
logging.warning(f"赤伝PDF生成例外DB保存は完了: {e}")
logging.info(f"赤伝作成成功: {invoice.invoice_number} (target={target.invoice_number})")
return invoice
except Exception as e:
logging.error(f"赤伝作成エラー: {e}")
return None
def submit_to_tax_authority(self, invoice_uuid: str) -> bool:
"""税務署提出済みフラグを設定し、ロックする"""
try:
with sqlite3.connect(self.invoice_repo.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE invoices SET submitted_to_tax_authority = 1 WHERE uuid = ?",
(invoice_uuid,)
)
conn.commit()
return True
except Exception as e:
logging.error(f"税務署提出エラー: {e}")
return False
def get_recent_invoices(self, limit: int = 50) -> List[Invoice]:
"""最近の伝票を取得"""
return self.invoice_repo.get_all_invoices(limit)
def regenerate_pdf(self, invoice_uuid: str) -> Optional[str]:
"""DBを正としてPDFを再生成生成物は仮"""
invoice = self.invoice_repo.get_invoice_by_uuid(invoice_uuid)
if not invoice:
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:
"""仮生成PDFを削除"""
try:
if not pdf_path:
return True
if os.path.exists(pdf_path):
os.remove(pdf_path)
return True
except Exception as e:
logging.warning(f"PDF削除失敗: {e}")
return False
def update_invoice(self, invoice: Invoice) -> bool:
"""伝票を更新"""
try:
return self.invoice_repo.update_invoice(invoice)
except Exception as e:
logging.error(f"伝票更新エラー: {e}")
return False
def delete_invoice_by_uuid(self, invoice_uuid: str) -> bool:
"""UUIDで伝票を削除"""
try:
return self.invoice_repo.delete_invoice_by_uuid(invoice_uuid)
except Exception as e:
logging.error(f"伝票削除エラー: {e}")
return False
def delete_invoice(self, invoice_id: int) -> bool:
"""伝票を削除"""
return self.invoice_repo.delete_invoice(invoice_id)
def get_statistics(self) -> Dict[str, Any]:
"""統計情報取得"""
return self.invoice_repo.get_statistics()
def update_company_info(self, info: Dict[str, str]) -> bool:
"""自社情報を更新"""
try:
self.company_info.update(info)
# TODO: 設定ファイルに保存
return True
except Exception as e:
logging.error(f"自社情報更新エラー: {e}")
return False
class CustomerService:
"""顧客ビジネスロジック"""
def __init__(self, db_path: str = "sales.db"):
self.db_path = db_path
self.customer_repo = CustomerRepository(db_path)
self._customer_cache: List[Customer] = []
self._load_customers()
def create_customer(self, name: str, formal_name: str, address: str = "", phone: str = "") -> int:
"""顧客を新規作成"""
customer = Customer(
id=0, # 新規はID=0
name=name,
formal_name=formal_name,
address=address,
phone=phone
)
success = self.customer_repo.save_customer(customer)
if success:
self._customer_cache.append(customer)
logging.info(f"新規顧客登録: {formal_name}")
return customer.id # IDを返す
else:
logging.error(f"新規顧客登録失敗: {formal_name}")
return 0 # 失敗時は0を返す
def _load_customers(self):
"""顧客データを読み込み"""
self._customer_cache = self.customer_repo.get_all_customers()
# 初期データがない場合はサンプル作成
if not self._customer_cache:
sample_customers = [
Customer(1, "田中商事", "田中商事株式会社", "東京都千代田区丸の内1-1-1", "03-1234-5678"),
Customer(2, "鈴木商店", "鈴木商店", "東京都港区芝1-1-1", "03-2345-6789"),
Customer(3, "佐藤工業", "佐藤工業株式会社", "東京都品川区東品川1-1-1", "03-3456-7890"),
]
for customer in sample_customers:
self.add_customer(customer)
self._customer_cache = sample_customers
def get_all_customers(self) -> List[Customer]:
"""全顧客を取得"""
return self._customer_cache.copy()
def search_customers(self, query: str) -> List[Customer]:
"""顧客を検索"""
query = query.lower()
return [
c for c in self._customer_cache
if query in c.name.lower() or
query in c.formal_name.lower() or
query in c.address.lower()
]
def add_customer(self, customer: Customer) -> bool:
"""顧客を追加"""
success = self.customer_repo.save_customer(customer)
if success:
self._customer_cache.append(customer)
return success
def delete_customer(self, customer_id: int) -> bool:
"""顧客を削除"""
# TODO: 使用されている顧客は削除不可
self._customer_cache = [c for c in self._customer_cache if c.id != customer_id]
return True
# サービスファクトリ
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]:
"""ダッシュボード表示用データ"""
stats = self.invoice.get_statistics()
recent_invoices = self.invoice.get_recent_invoices(5)
return {
'total_invoices': stats['total_count'],
'total_amount': stats['total_amount'],
'monthly_amount': stats['monthly_amount'],
'recent_invoices': recent_invoices,
'customer_count': len(self.customer.get_all_customers())
}
# 使用例
if __name__ == "__main__":
# サービス初期化
app_service = AppService()
# ダッシュボードデータ取得
dashboard = app_service.get_dashboard_data()
print(f"ダッシュボード: {dashboard}")
# 顧客取得
customers = app_service.customer.get_all_customers()
print(f"\n顧客数: {len(customers)}")
if customers:
# 伝票作成テスト
from models.invoice_models import DocumentType
invoice = app_service.invoice.create_invoice(
customer=customers[0],
document_type=DocumentType.INVOICE,
amount=250000,
notes="テスト伝票"
)
if invoice:
print(f"\n伝票作成成功: {invoice.invoice_number}")
print(f"合計金額: ¥{invoice.total_amount:,}")
print(f"PDF: {invoice.file_path}")
else:
print("\n伝票作成失敗")