h-1.flutter.0/lib/services/pdf_generator.dart

363 lines
14 KiB
Dart
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.

import 'dart:io';
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 '../models/invoice_models.dart';
import 'company_repository.dart';
import 'activity_log_repository.dart';
/// PDFドキュメントの構築プレビューと実保存の両方で使用
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
final pdf = pw.Document();
// フォントのロード
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ipaex = pw.Font.ttf(fontData);
final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("#,###");
// 自社情報の取得
final companyRepo = CompanyRepository();
final companyInfo = await companyRepo.getCompanyInfo();
// 印影画像のロード
pw.MemoryImage? sealImage;
if (companyInfo.sealPath != null) {
final file = File(companyInfo.sealPath!);
if (await file.exists()) {
final bytes = await file.readAsBytes();
sealImage = pw.MemoryImage(bytes);
}
}
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(
base: ipaex,
bold: ipaex,
italic: ipaex,
boldItalic: ipaex,
).copyWith(
defaultTextStyle: pw.TextStyle(fontFallback: [ipaex]),
),
build: (context) => [
// タイトル
pw.Header(
level: 0,
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(invoice.documentTypeName, 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.Container(
decoration: const pw.BoxDecoration(
border: pw.Border(bottom: pw.BorderSide(width: 1)),
),
child: pw.Text(invoice.customer.invoiceName,
style: const pw.TextStyle(fontSize: 18)),
),
pw.SizedBox(height: 10),
pw.Text(invoice.documentType == DocumentType.receipt
? "上記の金額を正に領収いたしました。"
: (invoice.documentType == DocumentType.estimation
? "下記の通り、お見積り申し上げます。"
: "下記の通り、ご請求申し上げます。")),
],
),
),
pw.Expanded(
child: pw.Stack(
alignment: pw.Alignment.topRight,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
if (companyInfo.zipCode != null) pw.Text("${companyInfo.zipCode}"),
if (companyInfo.address != null) pw.Text(companyInfo.address!),
if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"),
if (companyInfo.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty)
pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)),
],
),
if (sealImage != null)
pw.Positioned(
right: 10,
top: 0,
child: pw.Opacity(
opacity: 0.8,
child: pw.Image(sealImage, width: 40, height: 40),
),
),
],
),
),
],
),
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(
invoice.documentType == DocumentType.receipt
? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)")
: (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"),
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.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {
0: const pw.FlexColumnWidth(4),
1: const pw.FixedColumnWidth(50),
2: const pw.FixedColumnWidth(80),
3: const pw.FixedColumnWidth(80),
},
children: [
// ヘッダー
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey300),
children: [
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("品名 / 項目", style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("数量", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("単価", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("金額", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
],
),
// データ行
...invoice.items.map((item) {
return pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: _parseMarkdown(item.description),
),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.unitPrice), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.subtotal), textAlign: pw.TextAlign.right)),
],
);
}),
],
),
// 計算内訳
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 200,
child: pw.Column(
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
if (companyInfo.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
if (companyInfo.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)"),
pw.Divider(),
_buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true),
],
),
),
],
),
// 備考
// 備考
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
pw.SizedBox(height: 10),
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!),
),
],
pw.SizedBox(height: 20),
// 監査用ハッシュとQRコード
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
],
),
pw.Container(
width: 50,
height: 50,
child: pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: invoice.contentHash,
drawText: false,
),
),
],
),
],
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),
),
),
),
);
return pdf;
}
/// A4サイズのプロフェッショナルな伝票PDFを生成し、保存する
Future<String?> generateInvoicePdf(Invoice invoice) async {
try {
final pdf = await buildInvoiceDocument(invoice);
final String hash = invoice.contentHash;
final String dateStr = DateFormat('yyyyMMdd').format(invoice.date);
final String amountStr = NumberFormat("#,###").format(invoice.totalAmount);
final String subjectStr = invoice.subject?.isNotEmpty == true ? "_${invoice.subject}" : "";
// {日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf
// 顧客名から敬称を除去
String safeCustomerName = invoice.customerNameForDisplay
.replaceAll('株式会社', '')
.replaceAll('(株)', '')
.replaceAll('(株)', '')
.replaceAll('有限会社', '')
.replaceAll('(有)', '')
.replaceAll('(有)', '')
.replaceAll('合同会社', '')
.replaceAll('(同)', '')
.replaceAll('(同)', '')
.trim();
String fileName = "${dateStr}(${invoice.documentTypeName})${safeCustomerName}${subjectStr}_${amountStr}円_$hash.pdf";
final directory = await getExternalStorageDirectory();
if (directory == null) return null;
final file = File("${directory.path}/$fileName");
final Uint8List bytes = await pdf.save();
await file.writeAsBytes(bytes);
// 生成をログに記録
final logRepo = ActivityLogRepository();
await logRepo.logAction(
action: "GENERATE_PDF",
targetType: "INVOICE",
targetId: invoice.id,
details: "PDF生成: $fileName (${invoice.documentTypeName})",
);
return file.path;
} catch (e) {
debugPrint("PDF Generation Error: $e");
return null;
}
}
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),
],
),
);
}
pw.Widget _parseMarkdown(String text) {
final lines = text.split('\n');
final List<pw.Widget> widgets = [];
for (final line in lines) {
String content = line;
pw.EdgeInsets padding = const pw.EdgeInsets.only(bottom: 2);
pw.Widget? prefix;
// 箇条書き / インデント
if (content.startsWith('* ') || content.startsWith('- ')) {
content = content.substring(2);
prefix = pw.Padding(padding: const pw.EdgeInsets.only(right: 4), child: pw.Text(''));
} else if (content.startsWith(' ')) {
padding = padding.copyWith(left: 10);
}
// 太字 (**text**) - 簡易実装
final List<pw.TextSpan> spans = [];
final parts = content.split('**');
for (int i = 0; i < parts.length; i++) {
spans.add(pw.TextSpan(
text: parts[i],
style: i % 2 == 1 ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null,
));
}
widgets.add(
pw.Padding(
padding: padding,
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
if (prefix != null) prefix,
pw.Expanded(
child: pw.RichText(text: pw.TextSpan(children: spans, style: const pw.TextStyle(fontSize: 10))),
),
],
),
),
);
}
return pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: widgets);
}