295 lines
12 KiB
Dart
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),
|
|
],
|
|
),
|
|
);
|
|
}
|