inv/gemi_invoice/lib/services/pdf_generator.dart
2026-02-01 12:12:35 +09:00

295 lines
12 KiB
Dart

// lib/services/pdf_generator.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart' show debugPrint;
import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
import '../models/invoice_models.dart';
import '../models/company_model.dart'; // Companyモデルをインポート
import 'master_repository.dart'; // MasterRepositoryをインポート
/// A4サイズのプロフェッショナルな帳票PDFを生成し、保存する
/// 見積書、納品書、請求書、領収書の各DocumentTypeに対応
Future<String?> generateInvoicePdf(Invoice invoice) async {
try {
final pdf = pw.Document();
// フォントのロード
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ttf = pw.Font.ttf(fontData);
final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用
// 自社情報をロード
final MasterRepository masterRepository = MasterRepository();
final Company company = await masterRepository.loadCompany();
final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("¥#,###"); // ¥記号を付ける
// 帳票の種類に応じたタイトルと接尾辞
final String docTitle = invoice.type.label;
final String honorific = " 御中"; // 宛名の敬称 (estimateでもinvoiceでも共通化)
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: ttf, bold: boldTtf),
build: (context) => [
// タイトル
pw.Header(
level: 0,
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(docTitle, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text("管理番号: ${invoice.invoiceNumber}"),
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
],
),
],
),
),
pw.SizedBox(height: 20),
// 宛名と自社情報
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text("${invoice.customer.formalName}$honorific",
style: const pw.TextStyle(fontSize: 18)),
if (invoice.customer.department != null && invoice.customer.department!.isNotEmpty)
pw.Padding(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(invoice.customer.department!),
),
pw.SizedBox(height: 10),
pw.Text(invoice.type == DocumentType.estimate
? "下記の通り、御見積申し上げます。"
: "下記の通り、ご請求申し上げます。"),
],
),
),
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(company.formalName, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
if (company.zipCode != null && company.zipCode!.isNotEmpty) pw.Text(company.zipCode!),
if (company.address != null && company.address!.isNotEmpty) pw.Text(company.address!),
if (company.tel != null && company.tel!.isNotEmpty) pw.Text(company.tel!),
if (company.registrationNumber != null && company.registrationNumber!.isNotEmpty) pw.Text(company.registrationNumber! ),
],
),
),
],
),
pw.SizedBox(height: 30),
// 合計金額表示
pw.Container(
padding: const pw.EdgeInsets.all(8),
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text("${docTitle}金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)),
pw.Text("${amountFormatter.format(invoice.totalAmount)} -",
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
],
),
),
pw.SizedBox(height: 20),
// 明細テーブル
pw.TableHelper.fromTextArray(
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
cellHeight: 30,
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
headers: ["品名 / 項目", "数量", "単価", "金額"],
data: List<List<String>>.generate(
invoice.items.length,
(index) {
final item = invoice.items[index];
return [
item.description,
item.quantity.toString(),
amountFormatter.format(item.unitPrice),
amountFormatter.format(item.subtotal),
];
},
),
),
// 計算内訳
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 200,
child: pw.Column(
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
_buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)),
pw.Divider(),
_buildSummaryRow("合計", amountFormatter.format(invoice.totalAmount), isBold: true),
],
),
),
],
),
// 備考
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
pw.SizedBox(height: 40),
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
child: pw.Text(invoice.notes!)),
],
],
footer: (context) => pw.Container(
alignment: pw.Alignment.centerRight,
margin: const pw.EdgeInsets.only(top: 16),
child: pw.Text(
"Page ${context.pageNumber} / ${context.pagesCount}",
style: const pw.TextStyle(color: PdfColors.grey),
),
),
),
);
final Uint8List bytes = await pdf.save();
final String hash = sha256.convert(bytes).toString().substring(0, 8);
final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date);
String fileName = "${invoice.type.name}_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf";
final directory = await getExternalStorageDirectory();
if (directory == null) return null;
final file = File("${directory.path}/$fileName");
await file.writeAsBytes(bytes);
return file.path;
} catch (e) {
debugPrint("PDF Generation Error: $e");
return null;
}
}
/// ポケットサーマルプリンタ向けの58mmレシートPDFを生成して印刷ダイアログを表示する
Future<void> printThermalReceipt(Invoice invoice) async {
try {
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ttf = pw.Font.ttf(fontData);
final amountFormatter = NumberFormat("¥#,###"); // ¥記号を付ける
// 自社情報をロード
final MasterRepository masterRepository = MasterRepository();
final Company company = await masterRepository.loadCompany();
final doc = pw.Document();
doc.addPage(
pw.Page(
// 58mm幅のサーマルプリンタ向け設定 (約164pt)
pageFormat: const PdfPageFormat(58 * PdfPageFormat.mm, double.infinity, marginAll: 2 * PdfPageFormat.mm),
theme: pw.ThemeData.withFont(base: ttf),
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Center(
child: pw.Text(invoice.type.label, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
),
pw.SizedBox(height: 5),
pw.Text("${invoice.customer.formalName}", style: const pw.TextStyle(fontSize: 10)),
pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed),
pw.SizedBox(height: 5),
pw.Center(
child: pw.Text(amountFormatter.format(invoice.totalAmount),
style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold)),
),
pw.Center(child: pw.Text("(うち消費税 ${amountFormatter.format(invoice.tax)})", style: const pw.TextStyle(fontSize: 8))),
pw.SizedBox(height: 10),
pw.Text("但し、お品代として", style: const pw.TextStyle(fontSize: 9)),
pw.Text("上記正に領収いたしました", style: const pw.TextStyle(fontSize: 9)),
pw.SizedBox(height: 10),
// 明細簡易表示
pw.Text("--- 明細 ---\n", style: const pw.TextStyle(fontSize: 8)),
...invoice.items.map((item) => pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Expanded(child: pw.Text(item.description, style: const pw.TextStyle(fontSize: 8))),
pw.Text("x${item.quantity} ", style: const pw.TextStyle(fontSize: 8)),
pw.Text(amountFormatter.format(item.subtotal), style: const pw.TextStyle(fontSize: 8)),
],
)),
pw.Divider(thickness: 0.5),
pw.SizedBox(height: 5),
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(company.formalName, style: const pw.TextStyle(fontSize: 9)),
pw.Text(DateFormat('yyyy/MM/dd HH:mm').format(invoice.date), style: const pw.TextStyle(fontSize: 7)),
pw.Text("No: ${invoice.invoiceNumber}", style: const pw.TextStyle(fontSize: 7)),
],
),
),
pw.SizedBox(height: 10),
pw.Center(child: pw.Text("ありがとうございました", style: const pw.TextStyle(fontSize: 8))),
pw.SizedBox(height: 20), // 切り取り用の余白
],
);
},
),
);
// 印刷ダイアログを表示
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => doc.save(),
name: "${invoice.type.name}_${invoice.invoiceNumber}",
);
} catch (e) {
debugPrint("Thermal Print Error: $e");
}
}
pw.Widget _buildSummaryRow(String label, String value, {bool isBold = false}) {
final style = pw.TextStyle(fontSize: 12, fontWeight: isBold ? pw.FontWeight.bold : null);
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 2),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(label, style: style),
pw.Text(value, style: style),
],
),
);
}