485 lines
18 KiB
Python
485 lines
18 KiB
Python
"""
|
||
アプリケーションサービス層
|
||
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伝票作成失敗")
|