import 'package:intl/intl.dart'; import 'package:uuid/uuid.dart'; import '../models/invoice_models.dart'; import '../models/sales_entry_models.dart'; import 'database_helper.dart'; import 'sales_entry_repository.dart'; class SalesEntryService { SalesEntryService({ SalesEntryRepository? salesEntryRepository, DatabaseHelper? databaseHelper, }) : _salesEntryRepository = salesEntryRepository ?? SalesEntryRepository(), _dbHelper = databaseHelper ?? DatabaseHelper(); final SalesEntryRepository _salesEntryRepository; final DatabaseHelper _dbHelper; final Uuid _uuid = const Uuid(); final DateFormat _numberDateFormat = DateFormat('yyyyMMdd'); Future> fetchEntries({SalesEntryStatus? status, int? limit}) { return _salesEntryRepository.fetchEntries(status: status, limit: limit); } Future findById(String id) { return _salesEntryRepository.findById(id); } Future deleteEntry(String id) { return _salesEntryRepository.deleteEntry(id); } Future saveEntry(SalesEntry entry) async { final recalculated = entry.recalcTotals().copyWith(updatedAt: DateTime.now()); await _salesEntryRepository.upsertEntry(recalculated); return recalculated; } Future> fetchImportCandidates({ String? keyword, Set? documentTypes, DateTime? startDate, DateTime? endDate, bool includeDrafts = false, }) async { final db = await _dbHelper.database; final whereClauses = []; final args = []; if (!includeDrafts) { whereClauses.add('COALESCE(is_draft, 0) = 0'); } if (keyword != null && keyword.trim().isNotEmpty) { whereClauses.add('(customer_formal_name LIKE ? OR subject LIKE ?)'); final like = '%${keyword.trim()}%'; args..add(like)..add(like); } if (startDate != null) { whereClauses.add('date >= ?'); args.add(startDate.toIso8601String()); } if (endDate != null) { whereClauses.add('date <= ?'); args.add(endDate.toIso8601String()); } if (documentTypes != null && documentTypes.isNotEmpty) { final placeholders = List.filled(documentTypes.length, '?').join(','); whereClauses.add('document_type IN ($placeholders)'); args.addAll(documentTypes.map((d) => d.name)); } final whereSql = whereClauses.isEmpty ? '' : 'WHERE ${whereClauses.join(' AND ')}'; final rows = await db.rawQuery(''' SELECT id, customer_formal_name, date, total_amount, document_type, terminal_id, subject, is_locked, chain_status, content_hash FROM invoices $whereSql ORDER BY date DESC, id DESC LIMIT 200 ''', args); return rows.map((row) { final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; final documentType = DocumentType.values.firstWhere( (type) => type.name == docTypeName, orElse: () => DocumentType.invoice, ); final invoiceNumber = _buildInvoiceNumber( documentType, row['terminal_id'] as String? ?? 'T1', DateTime.parse(row['date'] as String), row['id'] as String, ); final map = { 'id': row['id'], 'invoice_number': invoiceNumber, 'document_type': documentType.name, 'date': row['date'], 'customer_name': row['customer_formal_name'], 'total_amount': row['total_amount'], 'is_locked': row['is_locked'], 'chain_status': row['chain_status'], 'content_hash': row['content_hash'], 'subject': row['subject'], }; return SalesImportCandidate.fromMap(map); }).toList(); } Future createEntryFromInvoices(List invoiceIds, {String? subject, DateTime? issueDate}) async { if (invoiceIds.isEmpty) { throw ArgumentError('invoiceIds must not be empty'); } final invoices = await _loadInvoiceData(invoiceIds); if (invoices.isEmpty) { throw StateError('指定された伝票が見つかりませんでした'); } final now = DateTime.now(); final entryId = _uuid.v4(); final built = _buildEntryFromInvoices( invoices: invoices, entryId: entryId, baseEntry: null, subjectOverride: subject, issueDateOverride: issueDate, now: now, ); await _salesEntryRepository.upsertEntry(built.entry); await _salesEntryRepository.upsertSources(built.entry.id, built.sources); return built.entry; } Future reimportEntry(String salesEntryId) async { final existing = await _salesEntryRepository.findById(salesEntryId); if (existing == null) { throw StateError('sales entry not found: $salesEntryId'); } final sources = await _salesEntryRepository.fetchSources(salesEntryId); if (sources.isEmpty) { throw StateError('再インポート対象の元伝票が登録されていません'); } final invoiceIds = sources.map((s) => s.invoiceId).toList(); final invoices = await _loadInvoiceData(invoiceIds); if (invoices.isEmpty) { throw StateError('元伝票が見つかりませんでした'); } final now = DateTime.now(); final built = _buildEntryFromInvoices( invoices: invoices, entryId: existing.id, baseEntry: existing, now: now, ); await _salesEntryRepository.upsertEntry(built.entry); await _salesEntryRepository.upsertSources(existing.id, built.sources); return built.entry; } _ImportBuildResult _buildEntryFromInvoices({ required List invoices, required String entryId, SalesEntry? baseEntry, String? subjectOverride, DateTime? issueDateOverride, required DateTime now, }) { final items = []; for (final invoice in invoices) { for (final line in invoice.items) { items.add( SalesLineItem( id: _uuid.v4(), salesEntryId: entryId, description: '[${invoice.documentType.displayName}] ${line.description}', quantity: line.quantity, unitPrice: line.unitPrice, lineTotal: line.subtotal, productId: line.productId, taxRate: invoice.taxRate, sourceInvoiceId: invoice.invoiceId, sourceInvoiceItemId: line.id, ), ); } } final issueDate = issueDateOverride ?? _latestDate(invoices) ?? baseEntry?.issueDate ?? now; final subject = subjectOverride ?? baseEntry?.subject ?? _deriveSubject(invoices); final customerId = _commonCustomerId(invoices) ?? baseEntry?.customerId; final customerNameSnapshot = _deriveCustomerSnapshot(invoices) ?? baseEntry?.customerNameSnapshot; final entry = (baseEntry ?? SalesEntry( id: entryId, customerId: customerId, customerNameSnapshot: customerNameSnapshot, subject: subject, issueDate: issueDate, status: SalesEntryStatus.draft, notes: baseEntry?.notes, createdAt: baseEntry?.createdAt ?? now, updatedAt: now, items: items, )) .copyWith( customerId: customerId, customerNameSnapshot: customerNameSnapshot, subject: subject, issueDate: issueDate, status: baseEntry?.status ?? SalesEntryStatus.draft, items: items, updatedAt: now, ) .recalcTotals(); final sources = invoices .map( (inv) => SalesEntrySource( id: _uuid.v4(), salesEntryId: entry.id, invoiceId: inv.invoiceId, importedAt: now, invoiceHashSnapshot: inv.contentHash, ), ) .toList(); return _ImportBuildResult(entry: entry, sources: sources); } Future> _loadInvoiceData(List invoiceIds) async { if (invoiceIds.isEmpty) return []; final db = await _dbHelper.database; final placeholders = List.filled(invoiceIds.length, '?').join(','); final invoiceRows = await db.rawQuery(''' SELECT id, customer_id, customer_formal_name, date, tax_rate, total_amount, document_type, subject, is_locked, chain_status, content_hash FROM invoices WHERE id IN ($placeholders) ''', invoiceIds); if (invoiceRows.isEmpty) return []; final itemRows = await db.query( 'invoice_items', where: 'invoice_id IN ($placeholders)', whereArgs: invoiceIds, ); final itemsByInvoice = >{}; for (final row in itemRows) { final invoiceId = row['invoice_id'] as String; final list = itemsByInvoice.putIfAbsent(invoiceId, () => []); list.add(InvoiceItem.fromMap(row)); } return invoiceRows.map((row) { final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; final documentType = DocumentType.values.firstWhere( (type) => type.name == docTypeName, orElse: () => DocumentType.invoice, ); final invoiceId = row['id'] as String; return SalesInvoiceImportData( invoiceId: invoiceId, documentType: documentType, issueDate: DateTime.parse(row['date'] as String), taxRate: (row['tax_rate'] as num?)?.toDouble() ?? 0.1, totalAmount: (row['total_amount'] as num?)?.toInt() ?? 0, items: itemsByInvoice[invoiceId] ?? const [], isLocked: (row['is_locked'] as int?) == 1, chainStatus: row['chain_status'] as String? ?? 'pending', contentHash: row['content_hash'] as String? ?? '', customerId: row['customer_id'] as String?, customerFormalName: row['customer_formal_name'] as String?, subject: row['subject'] as String?, ); }).toList(); } String? _commonCustomerId(List invoices) { final ids = invoices.map((e) => e.customerId).whereType().toSet(); if (ids.length == 1) return ids.first; return null; } String? _deriveCustomerSnapshot(List invoices) { final names = invoices.map((e) => e.customerFormalName).whereType().toSet(); if (names.isEmpty) return null; if (names.length == 1) return names.first; return '複数取引先'; } DateTime? _latestDate(List invoices) { if (invoices.isEmpty) return null; return invoices.map((e) => e.issueDate).reduce((a, b) => a.isAfter(b) ? a : b); } String _deriveSubject(List invoices) { if (invoices.isEmpty) return '売上伝票'; if (invoices.length == 1) { return invoices.first.subject ?? '${invoices.first.documentType.displayName}取込'; } return '複数伝票取込(${invoices.length}件)'; } String _buildInvoiceNumber(DocumentType type, String terminalId, DateTime date, String id) { final suffix = id.length >= 4 ? id.substring(id.length - 4) : id; final datePart = _numberDateFormat.format(date); final prefix = _documentPrefix(type); return '$prefix-$terminalId-$datePart-$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'; } } } class _ImportBuildResult { const _ImportBuildResult({required this.entry, required this.sources}); final SalesEntry entry; final List sources; }