import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:intl/intl.dart'; import 'customer_model.dart'; class InvoiceItem { final String? id; final String? productId; // 追加 String description; int quantity; int unitPrice; InvoiceItem({ this.id, this.productId, // 追加 required this.description, required this.quantity, required this.unitPrice, }); int get subtotal => quantity * unitPrice; Map toMap(String invoiceId) { return { 'id': id ?? DateTime.now().microsecondsSinceEpoch.toString(), 'invoice_id': invoiceId, 'product_id': productId, // 追加 'description': description, 'quantity': quantity, 'unit_price': unitPrice, }; } factory InvoiceItem.fromMap(Map map) { return InvoiceItem( id: map['id'], productId: map['product_id'], // 追加 description: map['description'], quantity: map['quantity'], unitPrice: map['unit_price'], ); } InvoiceItem copyWith({ String? id, // Added this to be complete String? description, int? quantity, int? unitPrice, String? productId, }) { return InvoiceItem( id: id ?? this.id, // Added this to be complete description: description ?? this.description, quantity: quantity ?? this.quantity, unitPrice: unitPrice ?? this.unitPrice, productId: productId ?? this.productId, ); } } enum DocumentType { estimation, // 見積 delivery, // 納品 invoice, // 請求 receipt, // 領収 } extension DocumentTypeDisplay on DocumentType { String get displayName { switch (this) { case DocumentType.estimation: return '見積書'; case DocumentType.delivery: return '納品書'; case DocumentType.invoice: return '請求書'; case DocumentType.receipt: return '領収書'; } } } class Invoice { static const String lockStatement = '正式発行ボタン押下時にこの伝票はロックされ、以後の編集・削除はできません。ロック状態はハッシュチェーンで保護されます。'; static const String hashDescription = 'metaJson = JSON.stringify({id, invoiceNumber, customer, date, total, documentType, hash, lockStatement, companySnapshot, companySealHash}); metaHash = SHA-256(metaJson).'; final String id; final Customer customer; final DateTime date; final List items; final String? notes; final String? filePath; final double taxRate; final DocumentType documentType; // 追加 final String? customerFormalNameSnapshot; final String? odooId; final bool isSynced; final DateTime updatedAt; final double? latitude; // 追加 final double? longitude; // 追加 final String terminalId; // 追加: 端末識別子 final bool isDraft; // 追加: 下書きフラグ final String? subject; // 追加: 案件名 final bool isLocked; // 追加: ロック final int? contactVersionId; // 追加: 連絡先バージョン final String? contactEmailSnapshot; final String? contactTelSnapshot; final String? contactAddressSnapshot; final String? companySnapshot; // 追加: 発行時会社情報スナップショット final String? companySealHash; // 追加: 角印画像ハッシュ final String? metaJson; final String? metaHash; final String? previousChainHash; final String? chainHash; final String chainStatus; Invoice({ String? id, required this.customer, required this.date, required this.items, this.notes, this.filePath, this.taxRate = 0.10, this.documentType = DocumentType.invoice, // デフォルト請求書 this.customerFormalNameSnapshot, this.odooId, this.isSynced = false, DateTime? updatedAt, this.latitude, // 追加 this.longitude, // 追加 String? terminalId, // 追加 this.isDraft = false, // 追加: デフォルトは通常 this.subject, // 追加: 案件 this.isLocked = false, this.contactVersionId, this.contactEmailSnapshot, this.contactTelSnapshot, this.contactAddressSnapshot, this.companySnapshot, this.companySealHash, this.metaJson, this.metaHash, this.previousChainHash, this.chainHash, this.chainStatus = 'pending', }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), terminalId = terminalId ?? "T1", // デフォルト端末ID updatedAt = updatedAt ?? DateTime.now(); /// 伝票内容から決定論的なハッシュを生成する (SHA256の一部) String get contentHash { final input = "$id|$terminalId|${date.toIso8601String()}|${customer.id}|$totalAmount|${subject ?? ""}|${items.map((e) => "${e.description}${e.quantity}${e.unitPrice}").join()}"; final bytes = utf8.encode(input); return sha256.convert(bytes).toString().substring(0, 8).toUpperCase(); } String get documentTypeName { switch (documentType) { case DocumentType.estimation: return "見積書"; case DocumentType.delivery: return "納品書"; case DocumentType.invoice: return "請求書"; case DocumentType.receipt: return "領収書"; } } static const Map _docTypeShortLabel = { DocumentType.estimation: '見積', DocumentType.delivery: '納品', DocumentType.invoice: '請求', DocumentType.receipt: '領収', }; String get invoiceNumberPrefix { switch (documentType) { case DocumentType.estimation: return "EST"; case DocumentType.delivery: return "DEL"; case DocumentType.invoice: return "INV"; case DocumentType.receipt: return "REC"; } } String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; // 表示用の宛名(スナップショットがあれば優先)。必ず敬称を付与。 String get customerNameForDisplay { final base = customerFormalNameSnapshot ?? customer.formalName; final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base); return hasHonorific ? base : '$base ${customer.title}'; } int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal); int get tax => (subtotal * taxRate).floor(); int get totalAmount => subtotal + tax; String get _projectLabel { if (subject != null && subject!.trim().isNotEmpty) { return subject!.trim(); } return '案件'; } String get mailTitleCore { final dateStr = DateFormat('yyyyMMdd').format(date); final docLabel = _docTypeShortLabel[documentType] ?? documentTypeName.replaceAll('書', ''); final customerCompact = customerNameForDisplay.replaceAll(RegExp(r'\s+'), ''); final amountStr = NumberFormat('#,###').format(totalAmount); final buffer = StringBuffer() ..write(dateStr) ..write('($docLabel)') ..write(_projectLabel) ..write('@') ..write(customerCompact) ..write('_') ..write(amountStr) ..write('円'); final raw = buffer.toString(); return _sanitizeForFile(raw); } String get mailAttachmentFileName => '$mailTitleCore.PDF'; String get mailBodyText => '請求書をお送りします。ご確認ください。'; static String _sanitizeForFile(String input) { var sanitized = input.replaceAll(RegExp(r'[\\/:*?"<>|]'), '-'); sanitized = sanitized.replaceAll(RegExp(r'[\r\n]+'), ''); sanitized = sanitized.replaceAll(' ', ''); sanitized = sanitized.replaceAll(' ', ''); return sanitized; } Map metaPayload() { return { 'id': id, 'invoiceNumber': invoiceNumber, 'customer': customerNameForDisplay, 'date': date.toIso8601String(), 'total': totalAmount, 'documentType': documentType.name, 'hash': contentHash, 'lockStatement': lockStatement, 'hashDescription': hashDescription, 'companySnapshot': companySnapshot, 'companySealHash': companySealHash, }; } String get metaJsonValue => metaJson ?? jsonEncode(metaPayload()); String get metaHashValue => metaHash ?? sha256.convert(utf8.encode(metaJsonValue)).toString(); Map toMap() { return { 'id': id, 'customer_id': customer.id, 'date': date.toIso8601String(), 'notes': notes, 'file_path': filePath, 'total_amount': totalAmount, 'tax_rate': taxRate, 'document_type': documentType.name, // 追加 'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName, 'odoo_id': odooId, 'is_synced': isSynced ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), 'latitude': latitude, // 追加 'longitude': longitude, // 追加 'terminal_id': terminalId, // 追加 'content_hash': contentHash, // 追加 'is_draft': isDraft ? 1 : 0, // 追加 'subject': subject, // 追加 'is_locked': isLocked ? 1 : 0, 'contact_version_id': contactVersionId, 'contact_email_snapshot': contactEmailSnapshot, 'contact_tel_snapshot': contactTelSnapshot, 'contact_address_snapshot': contactAddressSnapshot, 'company_snapshot': companySnapshot, 'company_seal_hash': companySealHash, 'meta_json': metaJsonValue, 'meta_hash': metaHashValue, 'previous_chain_hash': previousChainHash, 'chain_hash': chainHash, 'chain_status': chainStatus, }; } Invoice copyWith({ String? id, Customer? customer, DateTime? date, List? items, String? notes, String? filePath, double? taxRate, DocumentType? documentType, String? customerFormalNameSnapshot, String? odooId, bool? isSynced, DateTime? updatedAt, double? latitude, double? longitude, String? terminalId, bool? isDraft, String? subject, bool? isLocked, int? contactVersionId, String? contactEmailSnapshot, String? contactTelSnapshot, String? contactAddressSnapshot, String? companySnapshot, String? companySealHash, String? metaJson, String? metaHash, String? previousChainHash, String? chainHash, String? chainStatus, }) { return Invoice( id: id ?? this.id, customer: customer ?? this.customer, date: date ?? this.date, items: items ?? List.from(this.items), notes: notes ?? this.notes, filePath: filePath ?? this.filePath, taxRate: taxRate ?? this.taxRate, documentType: documentType ?? this.documentType, customerFormalNameSnapshot: customerFormalNameSnapshot ?? this.customerFormalNameSnapshot, odooId: odooId ?? this.odooId, isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, terminalId: terminalId ?? this.terminalId, isDraft: isDraft ?? this.isDraft, subject: subject ?? this.subject, isLocked: isLocked ?? this.isLocked, contactVersionId: contactVersionId ?? this.contactVersionId, contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot, contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot, contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot, companySnapshot: companySnapshot ?? this.companySnapshot, companySealHash: companySealHash ?? this.companySealHash, metaJson: metaJson ?? this.metaJson, metaHash: metaHash ?? this.metaHash, previousChainHash: previousChainHash ?? this.previousChainHash, chainHash: chainHash ?? this.chainHash, chainStatus: chainStatus ?? this.chainStatus, ); } String toCsv() { final buffer = StringBuffer(); buffer.writeln('伝票種別,伝票番号,日付,取引先,合計金額,緯度,経度'); buffer.writeln('$documentTypeName,$invoiceNumber,${DateFormat('yyyy/MM/dd').format(date)},$customerNameForDisplay,$totalAmount,${latitude ?? ""},${longitude ?? ""}'); buffer.writeln(''); buffer.writeln('品名,数量,単価,小計'); for (var item in items) { buffer.writeln('${item.description},${item.quantity},${item.unitPrice},${item.subtotal}'); } return buffer.toString(); } }