h-1.flutter.0/lib/services/invoice_repository.dart
2026-03-01 15:59:30 +09:00

328 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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,
);
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
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',
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<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'],
));
}
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<Map<String, int>> getMonthlySales(int year) async {
final db = await _dbHelper.database;
final String yearStr = year.toString();
final List<Map<String, dynamic>> 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<String, int> monthlyTotal = {};
for (var r in results) {
monthlyTotal[r['month']] = (r['total'] as num).toInt();
}
return monthlyTotal;
}
Future<int> getYearlyTotal(int year) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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();
}
}