363 lines
14 KiB
Dart
363 lines
14 KiB
Dart
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);
|
||
}
|