import 'dart:io'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; import '../models/invoice_models.dart'; import '../models/customer_model.dart'; import '../models/customer_contact.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, ); // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) 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', savingWithContact.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'], )); } 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> getMonthlySales(int year) async { final db = await _dbHelper.database; final String yearStr = year.toString(); final List> results = await db.rawQuery(''' SELECT strftime('%m', date) as month, SUM(total_amount) as total FROM invoices WHERE strftime('%Y', date) = ? AND document_type = 'invoice' GROUP BY month ORDER BY month ASC ''', [yearStr]); Map monthlyTotal = {}; for (var r in results) { monthlyTotal[r['month']] = (r['total'] as num).toInt(); } return monthlyTotal; } Future getYearlyTotal(int year) async { final db = await _dbHelper.database; final List> results = await db.rawQuery(''' SELECT SUM(total_amount) as total FROM invoices WHERE strftime('%Y', date) = ? AND document_type = 'invoice' ''', [year.toString()]); if (results.isEmpty || results.first['total'] == null) return 0; return (results.first['total'] as num).toInt(); } }