import 'dart:io'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; import 'package:intl/intl.dart'; import '../models/hash_chain_models.dart'; import '../models/invoice_models.dart'; import '../models/customer_model.dart'; import '../models/customer_contact.dart'; import '../models/sales_summary.dart'; import 'database_helper.dart'; import 'activity_log_repository.dart'; import 'company_repository.dart'; class InvoiceRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); final ActivityLogRepository _logRepo = ActivityLogRepository(); final CompanyRepository _companyRepo = CompanyRepository(); Future saveInvoice(Invoice invoice) async { final db = await _dbHelper.database; // 正式発行(下書きでない)場合はロックを掛ける final companyInfo = await _companyRepo.getCompanyInfo(); String? sealHash; if (companyInfo.sealPath != null) { final file = File(companyInfo.sealPath!); if (await file.exists()) { sealHash = sha256.convert(await file.readAsBytes()).toString(); } } final companySnapshot = jsonEncode({ 'name': companyInfo.name, 'zipCode': companyInfo.zipCode, 'address': companyInfo.address, 'tel': companyInfo.tel, 'fax': companyInfo.fax, 'email': companyInfo.email, 'url': companyInfo.url, 'defaultTaxRate': companyInfo.defaultTaxRate, 'taxDisplayMode': companyInfo.taxDisplayMode, 'registrationNumber': companyInfo.registrationNumber, }); final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true); await db.transaction((txn) async { // 最新の連絡先をスナップショットする(なければ空) CustomerContact? activeContact; final contactRows = await txn.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [invoice.customer.id]); if (contactRows.isNotEmpty) { activeContact = CustomerContact.fromMap(contactRows.first); } final Invoice savingWithContact = toSave.copyWith( contactVersionId: activeContact?.version, contactEmailSnapshot: activeContact?.email, contactTelSnapshot: activeContact?.tel, contactAddressSnapshot: activeContact?.address, companySnapshot: companySnapshot, companySealHash: sealHash, metaJson: null, metaHash: null, ); Invoice savingWithChain = savingWithContact; if (!savingWithContact.isDraft) { final previousEntry = await _fetchLatestChainEntry(txn); final previousHash = previousEntry?['chain_hash'] as String?; final computedHash = _computeChainHash( previousHash, savingWithContact.contentHash, savingWithContact.updatedAt, savingWithContact.id, ); savingWithChain = savingWithContact.copyWith( previousChainHash: previousHash, chainHash: computedHash, chainStatus: 'pending', ); } else { savingWithChain = savingWithContact.copyWith( previousChainHash: null, chainHash: null, chainStatus: 'draft', ); } // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) final List> oldItems = await txn.query( 'invoice_items', where: 'invoice_id = ?', whereArgs: [invoice.id], ); // 旧在庫を戻す for (var item in oldItems) { if (item['product_id'] != null) { await txn.execute( 'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?', [item['quantity'], item['product_id']], ); } } // 伝票ヘッダーの保存 await txn.insert( 'invoices', savingWithChain.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); // 既存の明細を一旦削除 await txn.delete( 'invoice_items', where: 'invoice_id = ?', whereArgs: [invoice.id], ); // 新しい明細の保存と在庫の減算 for (var item in invoice.items) { await txn.insert('invoice_items', item.toMap(invoice.id)); if (item.productId != null) { await txn.execute( 'UPDATE products SET stock_quantity = stock_quantity - ? WHERE id = ?', [item.quantity, item.productId], ); if (!invoice.isDraft) { await txn.execute('UPDATE products SET is_locked = 1 WHERE id = ?', [item.productId]); } } } // 顧客をロック if (!invoice.isDraft) { await txn.execute('UPDATE customers SET is_locked = 1 WHERE id = ?', [invoice.customer.id]); } }); await _logRepo.logAction( action: "SAVE_INVOICE", targetType: "INVOICE", targetId: invoice.id, details: "種別: ${invoice.documentTypeName}, 取引先: ${invoice.customerNameForDisplay}, 合計: ¥${invoice.totalAmount}", ); } Future updateInvoice(Invoice invoice) async { await saveInvoice(invoice); } Future> getAllInvoices(List customers) async { final db = await _dbHelper.database; List> invoiceMaps = await db.query('invoices', orderBy: 'date DESC'); // サンプル自動投入(伝票が0件なら) if (invoiceMaps.isEmpty && customers.isNotEmpty) { await _generateSampleInvoices(customers.take(3).toList()); invoiceMaps = await db.query('invoices', orderBy: 'date DESC'); } List invoices = []; for (var iMap in invoiceMaps) { final customer = customers.firstWhere( (c) => c.id == iMap['customer_id'], orElse: () => Customer(id: iMap['customer_id'], displayName: "不明", formalName: "不明"), ); final List> itemMaps = await db.query( 'invoice_items', where: 'invoice_id = ?', whereArgs: [iMap['id']], ); final items = List.generate(itemMaps.length, (i) => InvoiceItem.fromMap(itemMaps[i])); // document_typeのパース DocumentType docType = DocumentType.invoice; if (iMap['document_type'] != null) { try { docType = DocumentType.values.firstWhere((e) => e.name == iMap['document_type']); } catch (_) {} } invoices.add(Invoice( id: iMap['id'], customer: customer, date: DateTime.parse(iMap['date']), items: items, notes: iMap['notes'], filePath: iMap['file_path'], taxRate: iMap['tax_rate'] ?? 0.10, documentType: docType, customerFormalNameSnapshot: iMap['customer_formal_name'], odooId: iMap['odoo_id'], isSynced: iMap['is_synced'] == 1, updatedAt: DateTime.parse(iMap['updated_at']), latitude: iMap['latitude'], longitude: iMap['longitude'], terminalId: iMap['terminal_id'] ?? "T1", isDraft: (iMap['is_draft'] ?? 0) == 1, subject: iMap['subject'], isLocked: (iMap['is_locked'] ?? 0) == 1, contactVersionId: iMap['contact_version_id'], contactEmailSnapshot: iMap['contact_email_snapshot'], contactTelSnapshot: iMap['contact_tel_snapshot'], contactAddressSnapshot: iMap['contact_address_snapshot'], companySnapshot: iMap['company_snapshot'], companySealHash: iMap['company_seal_hash'], metaJson: iMap['meta_json'], metaHash: iMap['meta_hash'], previousChainHash: iMap['previous_chain_hash'], chainHash: iMap['chain_hash'], chainStatus: iMap['chain_status'] ?? 'pending', )); } return invoices; } Future _generateSampleInvoices(List customers) async { if (customers.isEmpty) return; final now = DateTime.now(); final items = [ InvoiceItem(description: "商品A", quantity: 2, unitPrice: 1200), InvoiceItem(description: "商品B", quantity: 1, unitPrice: 3000), ]; final List samples = List.generate( 3, (i) => Invoice( customer: customers[i % customers.length], date: now.subtract(Duration(days: i * 3)), items: items, isDraft: i == 0, // 1件だけ下書き subject: "サンプル案件${i + 1}", ), ); for (final inv in samples) { await saveInvoice(inv); } } Future deleteInvoice(String id) async { final db = await _dbHelper.database; await db.transaction((txn) async { // 在庫の復元 final List> items = await txn.query( 'invoice_items', where: 'invoice_id = ?', whereArgs: [id], ); for (var item in items) { if (item['product_id'] != null) { await txn.execute( 'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?', [item['quantity'], item['product_id']], ); } } // PDFパスの取得(削除用) final List> maps = await txn.query( 'invoices', columns: ['file_path'], where: 'id = ?', whereArgs: [id], ); if (maps.isNotEmpty && maps.first['file_path'] != null) { final file = File(maps.first['file_path']); if (await file.exists()) { await file.delete(); } } await txn.delete( 'invoice_items', where: 'invoice_id = ?', whereArgs: [id], ); await txn.delete( 'invoices', where: 'id = ?', whereArgs: [id], ); }); } Future cleanupOrphanedPdfs() async { try { final directory = await getExternalStorageDirectory(); if (directory == null) return 0; final files = directory.listSync().whereType().toList(); final db = await _dbHelper.database; final List> results = await db.query('invoices', columns: ['file_path']); final activePaths = results.map((r) => r['file_path'] as String?).where((p) => p != null).toSet(); int count = 0; for (var file in files) { if (file.path.endsWith('.pdf') && !activePaths.contains(file.path)) { await file.delete(); count++; } } return count; } catch (e) { return 0; } } /// meta_json と meta_hash の整合性を検証する(trueなら一致)。 bool verifyInvoiceMeta(Invoice invoice) { final metaJson = invoice.metaJson ?? invoice.metaJsonValue; final expected = sha256.convert(utf8.encode(metaJson)).toString(); final stored = invoice.metaHash ?? expected; return expected == stored; } /// IDを指定してDBから取得し、メタデータ整合性を検証する。 Future verifyInvoiceMetaById(String id, List customers) async { final invoices = await getAllInvoices(customers); final target = invoices.firstWhere((i) => i.id == id, orElse: () => throw Exception('invoice not found')); return verifyInvoiceMeta(target); } Future verifyHashChain() async { final db = await _dbHelper.database; final now = DateTime.now(); return await db.transaction((txn) async { final rows = await txn.query( 'invoices', columns: [ 'id', 'updated_at', 'content_hash', 'previous_chain_hash', 'chain_hash', 'document_type', 'terminal_id', 'date', ], where: 'COALESCE(is_draft, 0) = 0', orderBy: 'updated_at ASC, id ASC', ); String? expectedPreviousHash; final breaks = []; for (final row in rows) { final invoiceId = row['id'] as String; final invoiceNumber = _buildInvoiceNumberFromRow(row); final updatedAtStr = row['updated_at'] as String; final updatedAt = DateTime.parse(updatedAtStr); final contentHash = row['content_hash'] as String? ?? ''; final actualPrev = row['previous_chain_hash'] as String?; final actualHash = row['chain_hash'] as String?; final expectedHash = _computeChainHash(expectedPreviousHash, contentHash, updatedAt, invoiceId); bool broken = false; if ((expectedPreviousHash ?? '') != (actualPrev ?? '')) { final info = HashChainBreak( invoiceId: invoiceId, invoiceNumber: invoiceNumber, issue: 'previous_hash_mismatch', expectedHash: expectedHash, actualHash: actualHash, expectedPreviousHash: expectedPreviousHash, actualPreviousHash: actualPrev, ); breaks.add(info); await _logChainBreak(info); broken = true; } if (actualHash == null || actualHash != expectedHash) { final info = HashChainBreak( invoiceId: invoiceId, invoiceNumber: invoiceNumber, issue: actualHash == null ? 'hash_missing' : 'hash_mismatch', expectedHash: expectedHash, actualHash: actualHash, expectedPreviousHash: expectedPreviousHash, actualPreviousHash: actualPrev, ); breaks.add(info); await _logChainBreak(info); broken = true; } await txn.update( 'invoices', {'chain_status': broken ? 'broken' : 'healthy'}, where: 'id = ?', whereArgs: [invoiceId], ); expectedPreviousHash = expectedHash; } await _logRepo.logAction( action: 'HASH_CHAIN_VERIFY', targetType: 'INVOICE', details: jsonEncode({ 'checkedCount': rows.length, 'breakCount': breaks.length, 'verifiedAt': now.toIso8601String(), }), ); return HashChainVerificationResult( isHealthy: breaks.isEmpty, checkedCount: rows.length, verifiedAt: now, breaks: breaks, ); }); } Future?> _fetchLatestChainEntry(DatabaseExecutor txn) async { final rows = await txn.query( 'invoices', columns: ['chain_hash'], where: 'COALESCE(is_draft, 0) = 0 AND chain_hash IS NOT NULL', orderBy: 'updated_at DESC, id DESC', limit: 1, ); if (rows.isEmpty) return null; return rows.first; } String _computeChainHash(String? previousHash, String contentHash, DateTime updatedAt, String id) { final payload = '${previousHash ?? ''}|$contentHash|${updatedAt.toIso8601String()}|$id'; return sha256.convert(utf8.encode(payload)).toString(); } Future _logChainBreak(HashChainBreak info) async { await _logRepo.logAction( action: 'HASH_CHAIN_BREAK', targetType: 'INVOICE', targetId: info.invoiceId, details: jsonEncode({ 'issue': info.issue, 'invoiceNumber': info.invoiceNumber, 'expectedHash': info.expectedHash, 'actualHash': info.actualHash, 'expectedPreviousHash': info.expectedPreviousHash, 'actualPreviousHash': info.actualPreviousHash, }), ); } String _buildInvoiceNumberFromRow(Map row) { final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; DocumentType docType; try { docType = DocumentType.values.firstWhere((e) => e.name == docTypeName); } catch (_) { docType = DocumentType.invoice; } final prefix = _documentPrefix(docType); final terminalId = row['terminal_id'] as String? ?? 'T1'; final dateStr = row['date'] as String? ?? row['updated_at'] as String; final date = DateTime.tryParse(dateStr) ?? DateTime.now(); final id = row['id'] as String; final suffix = id.length >= 4 ? id.substring(id.length - 4) : id; final formatter = DateFormat('yyyyMMdd'); return '$prefix-$terminalId-${formatter.format(date)}-$suffix'; } String _documentPrefix(DocumentType type) { switch (type) { case DocumentType.estimation: return 'EST'; case DocumentType.delivery: return 'DEL'; case DocumentType.invoice: return 'INV'; case DocumentType.receipt: return 'REC'; } } Future fetchSalesSummary({ required int year, DocumentType? documentType, bool includeDrafts = false, int topCustomerLimit = 5, }) async { final db = await _dbHelper.database; final baseArgs = [year.toString()]; final whereBuffer = StringBuffer("strftime('%Y', date) = ?"); if (documentType != null) { whereBuffer.write(" AND document_type = ?"); baseArgs.add(documentType.name); } if (!includeDrafts) { whereBuffer.write(" AND COALESCE(is_draft, 0) = 0"); } final whereClause = whereBuffer.toString(); final monthlyRows = await db.rawQuery( ''' SELECT CAST(strftime('%m', date) AS INTEGER) as month, SUM(total_amount) as total FROM invoices WHERE $whereClause GROUP BY month ORDER BY month ASC '''. trim(), List.from(baseArgs), ); final monthlyTotals = {}; for (final row in monthlyRows) { if (row['month'] == null || row['total'] == null) continue; monthlyTotals[(row['month'] as num).toInt()] = (row['total'] as num).toInt(); } final yearlyTotal = monthlyTotals.values.fold(0, (sum, value) => sum + value); final customerRows = await db.rawQuery( ''' SELECT COALESCE(customer_formal_name, customer_id) as customer_name, SUM(total_amount) as total FROM invoices WHERE $whereClause GROUP BY customer_name ORDER BY total DESC LIMIT ? '''. trim(), [...baseArgs, topCustomerLimit], ); final customerStats = customerRows .where((row) => row['customer_name'] != null && row['total'] != null) .map( (row) => SalesCustomerStat( customerName: row['customer_name'] as String, totalAmount: (row['total'] as num).toInt(), ), ) .toList(); return SalesSummary( year: year, documentType: documentType, monthlyTotals: monthlyTotals, yearlyTotal: yearlyTotal, customerStats: customerStats, ); } Future> getMonthlySales(int year) async { final summary = await fetchSalesSummary(year: year); return summary.monthlyTotals.map((key, value) => MapEntry(key.toString().padLeft(2, '0'), value)); } Future getYearlyTotal(int year) async { final summary = await fetchSalesSummary(year: year); return summary.yearlyTotal; } }