280 lines
10 KiB
Dart
280 lines
10 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 ttf = pw.Font.ttf(fontData);
|
||
final boldTtf = 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: ttf, bold: boldTtf),
|
||
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 (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.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)),
|
||
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 timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.terminalId}_${invoice.id.substring(invoice.id.length - 4)}_${timeStr}_$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),
|
||
],
|
||
),
|
||
);
|
||
}
|