246 lines
9.5 KiB
Python
246 lines
9.5 KiB
Python
"""
|
||
PDF生成サービス
|
||
Flutter参考プロジェクトの機能をPythonで実装
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from reportlab.pdfgen import canvas
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.units import mm
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
from models.invoice_models import Invoice, InvoiceItem
|
||
import os
|
||
|
||
class PdfGenerator:
|
||
"""PDF生成サービス"""
|
||
|
||
def __init__(self):
|
||
self.output_dir = "generated_pdfs"
|
||
os.makedirs(self.output_dir, exist_ok=True)
|
||
|
||
# 日本語フォント登録(システムフォントを使用)
|
||
try:
|
||
pdfmetrics.registerFont(TTFont('Japanese', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'))
|
||
except:
|
||
# フォールバック:デフォルトフォント
|
||
pass
|
||
|
||
def generate_invoice_pdf(self, invoice: Invoice, company_info: dict) -> Optional[str]:
|
||
"""請求書PDFを生成
|
||
|
||
Args:
|
||
invoice: 伝票データモデル
|
||
company_info: 自社情報 dict{name, address, phone, registration_number}
|
||
|
||
Returns:
|
||
生成されたPDFファイルパス、失敗時はNone
|
||
"""
|
||
try:
|
||
# ファイル名生成ルール: {日付}({タイプ}){株式会社等を除く顧客名}_{件名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生成
|
||
c = canvas.Canvas(filepath, pagesize=A4)
|
||
width, height = A4
|
||
|
||
# 監査向けのメタ情報(バイナリ同一性は要求しない)
|
||
try:
|
||
c.setAuthor(company_info.get("id", ""))
|
||
c.setTitle(f"{invoice.document_type.value} {invoice.invoice_number}")
|
||
c.setSubject(getattr(invoice, "uuid", ""))
|
||
keywords = []
|
||
for key in ["payload_hash", "chain_hash", "node_id"]:
|
||
v = getattr(invoice, key, None)
|
||
if v:
|
||
keywords.append(f"{key}={v}")
|
||
if keywords:
|
||
c.setKeywords(",".join(keywords))
|
||
except Exception:
|
||
pass
|
||
|
||
# ヘッダー: 会社ロゴ・名前
|
||
self._draw_header(c, invoice, company_info, width, height)
|
||
|
||
# 顧客情報
|
||
self._draw_customer_info(c, invoice, width, height)
|
||
|
||
# 明細テーブル
|
||
self._draw_items_table(c, invoice, width, height)
|
||
|
||
# 合計金額
|
||
self._draw_totals(c, invoice, width, height)
|
||
|
||
# フッター: 登録番号など
|
||
self._draw_footer(c, company_info, width)
|
||
|
||
# payload_json をPDFに埋め込む(不可視テキストとして格納)
|
||
try:
|
||
payload_json = getattr(invoice, "payload_json", None)
|
||
if payload_json:
|
||
c.saveState()
|
||
c.setFillColorRGB(1, 1, 1)
|
||
c.setFont("Helvetica", 1)
|
||
# PDFのどこかに確実に残す(見えない)
|
||
c.drawString(1 * mm, 1 * mm, f"INVOICE_PAYLOAD_JSON:{payload_json}")
|
||
c.restoreState()
|
||
except Exception:
|
||
pass
|
||
|
||
c.save()
|
||
return filepath
|
||
|
||
except Exception as e:
|
||
print(f"PDF生成エラー: {e}")
|
||
return None
|
||
|
||
def _draw_header(self, c: canvas.Canvas, invoice: Invoice, company_info: dict, width: float, height: float):
|
||
"""ヘッダー描画"""
|
||
# 帳票種類タイトル
|
||
c.setFont("Helvetica-Bold", 24)
|
||
title = invoice.document_type.value
|
||
c.drawString(20*mm, height - 30*mm, title)
|
||
|
||
# 自社情報
|
||
c.setFont("Helvetica", 10)
|
||
company_name = company_info.get('name', '自社名未設定')
|
||
c.drawString(20*mm, height - 45*mm, f"{company_name}")
|
||
|
||
# 日付・番号
|
||
c.setFont("Helvetica", 10)
|
||
date_str = invoice.date.strftime("%Y年%m月%d日")
|
||
c.drawString(width - 80*mm, height - 30*mm, f"発行日: {date_str}")
|
||
c.drawString(width - 80*mm, height - 40*mm, f"No. {invoice.invoice_number}")
|
||
|
||
def _draw_customer_info(self, c: canvas.Canvas, invoice: Invoice, width: float, height: float):
|
||
"""顧客情報描画"""
|
||
y_pos = height - 70*mm
|
||
|
||
c.setFont("Helvetica-Bold", 12)
|
||
c.drawString(20*mm, y_pos, "御中:")
|
||
|
||
c.setFont("Helvetica", 11)
|
||
customer_name = invoice.customer.formal_name
|
||
c.drawString(20*mm, y_pos - 8*mm, customer_name)
|
||
|
||
if invoice.customer.address:
|
||
c.setFont("Helvetica", 9)
|
||
c.drawString(20*mm, y_pos - 16*mm, invoice.customer.address)
|
||
|
||
def _draw_items_table(self, c: canvas.Canvas, invoice: Invoice, width: float, height: float):
|
||
"""明細テーブル描画"""
|
||
# テーブルヘッダー
|
||
y_start = height - 110*mm
|
||
col_x = [20*mm, 80*mm, 110*mm, 140*mm, 170*mm]
|
||
|
||
# ヘッダーライン
|
||
c.setFont("Helvetica-Bold", 10)
|
||
headers = ["品名", "数量", "単価", "金額", "摘要"]
|
||
for i, header in enumerate(headers):
|
||
c.drawString(col_x[i], y_start, header)
|
||
|
||
# 明細行
|
||
c.setFont("Helvetica", 9)
|
||
y_pos = y_start - 10*mm
|
||
|
||
for item in invoice.items:
|
||
c.drawString(col_x[0], y_pos, item.description[:20])
|
||
c.drawRightString(col_x[1] + 20*mm, y_pos, str(item.quantity))
|
||
c.drawRightString(col_x[2] + 20*mm, y_pos, f"¥{item.unit_price:,}")
|
||
c.drawRightString(col_x[3] + 20*mm, y_pos, f"¥{item.subtotal:,}")
|
||
y_pos -= 8*mm
|
||
|
||
def _draw_totals(self, c: canvas.Canvas, invoice: Invoice, width: float, height: float):
|
||
"""合計金額描画"""
|
||
y_pos = height - 180*mm
|
||
x_right = width - 40*mm
|
||
|
||
c.setFont("Helvetica", 10)
|
||
c.drawRightString(x_right, y_pos, f"小計: ¥{invoice.subtotal:,}")
|
||
c.drawRightString(x_right, y_pos - 8*mm, f"消費税: ¥{invoice.tax:,}")
|
||
|
||
c.setFont("Helvetica-Bold", 14)
|
||
c.drawRightString(x_right, y_pos - 20*mm, f"合計: ¥{invoice.total_amount:,}")
|
||
|
||
def _draw_footer(self, c: canvas.Canvas, company_info: dict, width: float):
|
||
"""フッター描画"""
|
||
y_pos = 30*mm
|
||
|
||
c.setFont("Helvetica", 8)
|
||
reg_num = company_info.get('registration_number', '')
|
||
if reg_num:
|
||
c.drawString(20*mm, y_pos, f"登録番号: {reg_num}")
|
||
|
||
# 支払期限・備考
|
||
c.drawString(20*mm, y_pos - 5*mm, "お支払期限: 月末まで")
|
||
|
||
# 使用例
|
||
if __name__ == "__main__":
|
||
from models.invoice_models import Customer, InvoiceItem, DocumentType
|
||
from datetime import datetime
|
||
|
||
# サンプルデータ
|
||
customer = Customer(1, "田中商事", "田中商事株式会社", "東京都千代田区", "03-1234-5678")
|
||
|
||
invoice = Invoice(
|
||
customer=customer,
|
||
date=datetime.now(),
|
||
items=[
|
||
InvoiceItem("商品A", 2, 15000),
|
||
InvoiceItem("商品B", 1, 30000),
|
||
],
|
||
document_type=DocumentType.INVOICE,
|
||
)
|
||
|
||
company = {
|
||
'id': 'SAMPLE001',
|
||
'name': 'サンプル株式会社',
|
||
'registration_number': 'T1234567890123'
|
||
}
|
||
|
||
generator = PdfGenerator()
|
||
pdf_path = generator.generate_invoice_pdf(invoice, company)
|
||
|
||
if pdf_path:
|
||
print(f"PDF生成成功: {pdf_path}")
|
||
else:
|
||
print("PDF生成失敗")
|