564 lines
18 KiB
Dart
564 lines
18 KiB
Dart
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<void> 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<Map<String, dynamic>> 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<void> updateInvoice(Invoice invoice) async {
|
||
await saveInvoice(invoice);
|
||
}
|
||
|
||
Future<List<Invoice>> getAllInvoices(List<Customer> customers) async {
|
||
final db = await _dbHelper.database;
|
||
List<Map<String, dynamic>> 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<Invoice> 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<Map<String, dynamic>> 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<void> _generateSampleInvoices(List<Customer> 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<Invoice> 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<void> deleteInvoice(String id) async {
|
||
final db = await _dbHelper.database;
|
||
await db.transaction((txn) async {
|
||
// 在庫の復元
|
||
final List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<int> cleanupOrphanedPdfs() async {
|
||
try {
|
||
final directory = await getExternalStorageDirectory();
|
||
if (directory == null) return 0;
|
||
|
||
final files = directory.listSync().whereType<File>().toList();
|
||
final db = await _dbHelper.database;
|
||
final List<Map<String, dynamic>> 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<bool> verifyInvoiceMetaById(String id, List<Customer> 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<HashChainVerificationResult> 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 = <HashChainBreak>[];
|
||
|
||
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<Map<String, Object?>?> _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<void> _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<String, Object?> 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<SalesSummary> fetchSalesSummary({
|
||
required int year,
|
||
DocumentType? documentType,
|
||
bool includeDrafts = false,
|
||
int topCustomerLimit = 5,
|
||
}) async {
|
||
final db = await _dbHelper.database;
|
||
final baseArgs = <Object?>[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<Object?>.from(baseArgs),
|
||
);
|
||
|
||
final monthlyTotals = <int, int>{};
|
||
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<int>(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<Map<String, int>> getMonthlySales(int year) async {
|
||
final summary = await fetchSalesSummary(year: year);
|
||
return summary.monthlyTotals.map((key, value) => MapEntry(key.toString().padLeft(2, '0'), value));
|
||
}
|
||
|
||
Future<int> getYearlyTotal(int year) async {
|
||
final summary = await fetchSalesSummary(year: year);
|
||
return summary.yearlyTotal;
|
||
}
|
||
}
|