330 lines
11 KiB
Dart
330 lines
11 KiB
Dart
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<List<SalesEntry>> fetchEntries({SalesEntryStatus? status, int? limit}) {
|
|
return _salesEntryRepository.fetchEntries(status: status, limit: limit);
|
|
}
|
|
|
|
Future<SalesEntry?> findById(String id) {
|
|
return _salesEntryRepository.findById(id);
|
|
}
|
|
|
|
Future<void> deleteEntry(String id) {
|
|
return _salesEntryRepository.deleteEntry(id);
|
|
}
|
|
|
|
Future<SalesEntry> saveEntry(SalesEntry entry) async {
|
|
final recalculated = entry.recalcTotals().copyWith(updatedAt: DateTime.now());
|
|
await _salesEntryRepository.upsertEntry(recalculated);
|
|
return recalculated;
|
|
}
|
|
|
|
Future<List<SalesImportCandidate>> fetchImportCandidates({
|
|
String? keyword,
|
|
Set<DocumentType>? documentTypes,
|
|
DateTime? startDate,
|
|
DateTime? endDate,
|
|
bool includeDrafts = false,
|
|
}) async {
|
|
final db = await _dbHelper.database;
|
|
final whereClauses = <String>[];
|
|
final args = <Object?>[];
|
|
|
|
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<SalesEntry> createEntryFromInvoices(List<String> 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<SalesEntry> 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<SalesInvoiceImportData> invoices,
|
|
required String entryId,
|
|
SalesEntry? baseEntry,
|
|
String? subjectOverride,
|
|
DateTime? issueDateOverride,
|
|
required DateTime now,
|
|
}) {
|
|
final items = <SalesLineItem>[];
|
|
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<List<SalesInvoiceImportData>> _loadInvoiceData(List<String> 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 = <String, List<InvoiceItem>>{};
|
|
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<SalesInvoiceImportData> invoices) {
|
|
final ids = invoices.map((e) => e.customerId).whereType<String>().toSet();
|
|
if (ids.length == 1) return ids.first;
|
|
return null;
|
|
}
|
|
|
|
String? _deriveCustomerSnapshot(List<SalesInvoiceImportData> invoices) {
|
|
final names = invoices.map((e) => e.customerFormalName).whereType<String>().toSet();
|
|
if (names.isEmpty) return null;
|
|
if (names.length == 1) return names.first;
|
|
return '複数取引先';
|
|
}
|
|
|
|
DateTime? _latestDate(List<SalesInvoiceImportData> invoices) {
|
|
if (invoices.isEmpty) return null;
|
|
return invoices.map((e) => e.issueDate).reduce((a, b) => a.isAfter(b) ? a : b);
|
|
}
|
|
|
|
String _deriveSubject(List<SalesInvoiceImportData> 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<SalesEntrySource> sources;
|
|
}
|