Files
tindagrid/frontend/lib/core/database/app_database.dart
T

970 lines
33 KiB
Dart

import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import 'tables/products.dart';
import 'tables/inventory_logs.dart';
import 'tables/sales.dart';
import 'tables/debts.dart';
import 'tables/payables.dart';
import 'tables/gcash.dart';
import 'tables/sync_queue.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [
Products,
InventoryLogs,
Sales,
SaleItems,
Debts,
DebtItems,
Payables,
GcashLedger,
GcashFeeConfig,
SyncQueue,
])
class DualFloatBalance {
final double gcashFloat;
final double cashDrawer;
final double totalFeesEarned;
final int totalTransactions;
final int cashInCount;
final int cashOutCount;
final int storePaymentCount;
const DualFloatBalance({
required this.gcashFloat,
required this.cashDrawer,
required this.totalFeesEarned,
required this.totalTransactions,
required this.cashInCount,
required this.cashOutCount,
required this.storePaymentCount,
});
}
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
AppDatabase.forTesting(super.e);
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(name: 'tindagrid_db');
}
static const _uuid = Uuid();
// --------------------------------------------------------------------------
// PRODUCTS & INVENTORY OPERATIONS
// --------------------------------------------------------------------------
/// Watch active products for the Tingi Grid & inventory screens (real-time stream)
Stream<List<Product>> watchAllProducts() {
return (select(products)
..where((p) => p.isDeleted.equals(false))
..orderBy([(p) => OrderingTerm(expression: p.name)]))
.watch();
}
/// Watch unbarcoded or Tingi goods for the Quick-Tap Visual Grid
Stream<List<Product>> watchTingiProducts() {
return (select(products)
..where((p) => (p.isTingi.equals(true) | p.barcode.isNull() | p.barcode.equals('')) & p.isDeleted.equals(false))
..orderBy([(p) => OrderingTerm(expression: p.name)]))
.watch();
}
/// Search product by barcode (exact match for handheld scanner)
Future<Product?> getProductByBarcode(String barcode) {
return (select(products)
..where((p) => p.barcode.equals(barcode) & p.isDeleted.equals(false))
..limit(1))
.getSingleOrNull();
}
/// Search products by name, barcode, or category (instant search)
Future<List<Product>> searchProducts(String query) {
final lowerQuery = '%${query.toLowerCase()}%';
return (select(products)
..where((p) =>
(p.name.lower().like(lowerQuery) |
p.barcode.lower().like(lowerQuery) |
p.category.lower().like(lowerQuery)) &
p.isDeleted.equals(false))
..limit(50))
.get();
}
/// Internal helper to enqueue local mutations into sync_queue as JSON
Future<void> _enqueueSync({
required String entityTable,
required String entityId,
required String operation,
required Map<String, dynamic> payload,
}) async {
await into(syncQueue).insert(SyncQueueCompanion.insert(
id: _uuid.v4(),
entityTable: entityTable,
entityId: entityId,
operation: operation,
payload: jsonEncode(payload),
createdAt: Value(DateTime.now()),
));
}
/// Upsert a product and log the mutation to the sync queue
Future<void> saveProduct(ProductsCompanion product) async {
await transaction(() async {
await into(products).insertOnConflictUpdate(product);
final saved = await (select(products)..where((p) => p.id.equals(product.id.value))).getSingle();
// Enqueue sync operation with full JSON payload
await _enqueueSync(
entityTable: 'products',
entityId: saved.id,
operation: 'UPSERT',
payload: {
'id': saved.id,
'barcode': saved.barcode,
'name': saved.name,
'category': saved.category,
'costPrice': saved.costPrice,
'sellingPrice': saved.sellingPrice,
'currentStock': saved.currentStock,
'unit': saved.unit,
'isTingi': saved.isTingi,
'imageUrl': saved.imageUrl,
'isDeleted': saved.isDeleted,
'createdAt': saved.createdAt.toIso8601String(),
'updatedAt': saved.updatedAt.toIso8601String(),
},
);
});
}
/// Adjust stock with audit reason (restock, damage, audit, etc.)
Future<void> adjustStock({
required String productId,
required double changeQuantity,
required String reason,
String? notes,
}) async {
await transaction(() async {
final current = await (select(products)..where((p) => p.id.equals(productId))).getSingle();
final newStock = current.currentStock + changeQuantity;
final now = DateTime.now();
// Update product current stock
await (update(products)..where((p) => p.id.equals(productId))).write(
ProductsCompanion(
currentStock: Value(newStock),
updatedAt: Value(now),
),
);
final logId = _uuid.v4();
// Record audit log
await into(inventoryLogs).insert(InventoryLogsCompanion.insert(
id: logId,
productId: productId,
changeQuantity: changeQuantity,
previousStock: current.currentStock,
newStock: newStock,
reason: reason,
notes: Value(notes),
createdAt: Value(now),
));
// Enqueue product update mutation
await _enqueueSync(
entityTable: 'products',
entityId: productId,
operation: 'UPDATE',
payload: {
'id': current.id,
'barcode': current.barcode,
'name': current.name,
'category': current.category,
'costPrice': current.costPrice,
'sellingPrice': current.sellingPrice,
'currentStock': newStock,
'unit': current.unit,
'isTingi': current.isTingi,
'imageUrl': current.imageUrl,
'isDeleted': current.isDeleted,
'updatedAt': now.toIso8601String(),
},
);
// Enqueue inventory audit log mutation
await _enqueueSync(
entityTable: 'inventory_logs',
entityId: logId,
operation: 'INSERT',
payload: {
'id': logId,
'productId': productId,
'changeQuantity': changeQuantity,
'previousStock': current.currentStock,
'newStock': newStock,
'reason': reason,
'notes': notes,
'createdAt': now.toIso8601String(),
},
);
});
}
// --------------------------------------------------------------------------
// POS & SALES OPERATIONS
// --------------------------------------------------------------------------
/// Process complete POS checkout transaction atomically
Future<String> recordSale({
required String paymentMethod,
required double totalAmount,
required double amountReceived,
required double sukliAmount,
required List<SaleItemsCompanion> items,
bool isDebt = false,
String? debtId,
}) async {
final saleId = _uuid.v4();
final now = DateTime.now();
await transaction(() async {
double totalProfit = 0;
for (final item in items) {
if (item.profit.present) {
totalProfit += item.profit.value;
}
}
// 1. Insert Master Sale record
await into(sales).insert(SalesCompanion.insert(
id: saleId,
timestamp: Value(now),
paymentMethod: Value(paymentMethod),
totalAmount: totalAmount,
amountReceived: Value(amountReceived),
sukliAmount: Value(sukliAmount),
totalProfit: Value(totalProfit),
isDebt: Value(isDebt),
debtId: Value(debtId),
createdAt: Value(now),
));
// 2. Insert items and decrement inventory stock
final serializedItems = <Map<String, dynamic>>[];
for (final item in items) {
final itemWithSaleId = item.copyWith(saleId: Value(saleId));
await into(saleItems).insert(itemWithSaleId);
serializedItems.add({
'id': item.id.present ? item.id.value : _uuid.v4(),
'productId': item.productId.value,
'productName': item.productName.value,
'quantity': item.quantity.present ? item.quantity.value : 1.0,
'costPrice': item.costPrice.present ? item.costPrice.value : 0.0,
'unitPrice': item.unitPrice.value,
'subtotal': item.subtotal.value,
'profit': item.profit.present ? item.profit.value : 0.0,
});
// Deduct inventory
final product = await (select(products)..where((p) => p.id.equals(item.productId.value))).getSingleOrNull();
if (product != null) {
final newStock = product.currentStock - item.quantity.value;
await (update(products)..where((p) => p.id.equals(product.id))).write(
ProductsCompanion(
currentStock: Value(newStock),
updatedAt: Value(now),
),
);
// Inventory log for sale
final logId = _uuid.v4();
await into(inventoryLogs).insert(InventoryLogsCompanion.insert(
id: logId,
productId: product.id,
changeQuantity: -item.quantity.value,
previousStock: product.currentStock,
newStock: newStock,
reason: 'sale',
notes: Value('Sale #$saleId'),
createdAt: Value(now),
));
}
}
// 3. Queue for cloud sync with full sale details and item breakdown
await _enqueueSync(
entityTable: 'sales',
entityId: saleId,
operation: 'INSERT',
payload: {
'id': saleId,
'timestamp': now.toIso8601String(),
'paymentMethod': paymentMethod,
'totalAmount': totalAmount,
'amountReceived': amountReceived,
'sukliAmount': sukliAmount,
'totalProfit': totalProfit,
'isDebt': isDebt,
'debtId': debtId,
'items': serializedItems,
},
);
});
return saleId;
}
/// Watch total sales for today (real-time store ticker)
Stream<List<Sale>> watchTodaySales() {
final now = DateTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
return (select(sales)
..where((s) => s.timestamp.isBiggerOrEqualValue(startOfDay))
..orderBy([(s) => OrderingTerm.desc(s.timestamp)]))
.watch();
}
// --------------------------------------------------------------------------
// PAUTANG (DEBTS) OPERATIONS
// --------------------------------------------------------------------------
/// Watch active pending customer debts
Stream<List<Debt>> watchActiveDebts() {
return (select(debts)
..where((d) => d.status.equals('PENDING') | d.status.equals('PARTIALLY_PAID'))
..orderBy([(d) => OrderingTerm.desc(d.borrowedDate)]))
.watch();
}
/// Create a new Pautang entry with items
Future<String> recordDebt({
required String customerName,
String? customerPhone,
required double totalAmount,
DateTime? dueDate,
String? notes,
required List<DebtItemsCompanion> items,
}) async {
final debtId = _uuid.v4();
final now = DateTime.now();
await transaction(() async {
await into(debts).insert(DebtsCompanion.insert(
id: debtId,
customerName: customerName,
customerPhone: Value(customerPhone),
totalAmount: totalAmount,
remainingBalance: totalAmount,
borrowedDate: Value(now),
dueDate: Value(dueDate),
status: const Value('PENDING'),
notes: Value(notes),
updatedAt: Value(now),
));
final serializedItems = <Map<String, dynamic>>[];
for (final item in items) {
await into(debtItems).insert(item.copyWith(debtId: Value(debtId)));
serializedItems.add({
'id': item.id.present ? item.id.value : _uuid.v4(),
'debtId': debtId,
'productId': item.productId.present ? item.productId.value : null,
'description': item.description.value,
'quantity': item.quantity.present ? item.quantity.value : 1.0,
'amount': item.amount.value,
});
}
// Enqueue debt for cloud sync
await _enqueueSync(
entityTable: 'debts',
entityId: debtId,
operation: 'INSERT',
payload: {
'id': debtId,
'customerName': customerName,
'customerPhone': customerPhone,
'totalAmount': totalAmount,
'remainingBalance': totalAmount,
'borrowedDate': now.toIso8601String(),
'dueDate': dueDate?.toIso8601String(),
'status': 'PENDING',
'notes': notes,
'items': serializedItems,
'updatedAt': now.toIso8601String(),
},
);
});
return debtId;
}
/// Record partial or full payment on customer debt
Future<void> payDebt(String debtId, double paymentAmount) async {
await transaction(() async {
final current = await (select(debts)..where((d) => d.id.equals(debtId))).getSingle();
final newBalance = (current.remainingBalance - paymentAmount).clamp(0.0, double.infinity);
final newStatus = newBalance <= 0.0 ? 'PAID' : 'PARTIALLY_PAID';
final now = DateTime.now();
await (update(debts)..where((d) => d.id.equals(debtId))).write(
DebtsCompanion(
remainingBalance: Value(newBalance),
status: Value(newStatus),
updatedAt: Value(now),
),
);
// Enqueue debt update
await _enqueueSync(
entityTable: 'debts',
entityId: debtId,
operation: 'UPDATE',
payload: {
'id': debtId,
'customerName': current.customerName,
'customerPhone': current.customerPhone,
'totalAmount': current.totalAmount,
'remainingBalance': newBalance,
'status': newStatus,
'notes': current.notes,
'paymentAmount': paymentAmount,
'updatedAt': now.toIso8601String(),
},
);
});
}
// --------------------------------------------------------------------------
// PAYABLES (SUPPLIER / BODEGA) OPERATIONS
// --------------------------------------------------------------------------
/// Watch pending bodega/supplier payables
Stream<List<Payable>> watchPendingPayables() {
return (select(payables)
..where((p) => p.status.equals('UNPAID') | p.status.equals('PARTIALLY_PAID'))
..orderBy([(p) => OrderingTerm.asc(p.dueDate)]))
.watch();
}
/// Record payment to bodega/supplier
Future<void> paySupplier(String payableId, double amount) async {
await transaction(() async {
final current = await (select(payables)..where((p) => p.id.equals(payableId))).getSingle();
final newPaid = current.paidAmount + amount;
final newStatus = newPaid >= current.totalAmount ? 'SETTLED' : 'PARTIALLY_PAID';
final now = DateTime.now();
await (update(payables)..where((p) => p.id.equals(payableId))).write(
PayablesCompanion(
paidAmount: Value(newPaid),
status: Value(newStatus),
updatedAt: Value(now),
),
);
await _enqueueSync(
entityTable: 'payables',
entityId: payableId,
operation: 'UPDATE',
payload: {
'id': payableId,
'supplierName': current.supplierName,
'paidAmount': newPaid,
'totalAmount': current.totalAmount,
'status': newStatus,
'updatedAt': now.toIso8601String(),
},
);
});
}
// --------------------------------------------------------------------------
// GCASH LEDGER OPERATIONS & DUAL-FLOAT ENGINE
// --------------------------------------------------------------------------
/// Watch GCash transaction history
Stream<List<GcashTransaction>> watchGcashTransactions() {
return (select(gcashLedger)..orderBy([(g) => OrderingTerm.desc(g.timestamp)])).watch();
}
/// Check if a GCash reference number has already been logged (anti-fraud/scam check)
Future<bool> isReferenceNumberUsed(String refNo) async {
final clean = refNo.replaceAll(RegExp(r'\s+'), '').trim();
if (clean.isEmpty) return false;
final all = await select(gcashLedger).get();
return all.any((t) =>
t.referenceNumber != null &&
t.referenceNumber!.replaceAll(RegExp(r'\s+'), '').trim().toLowerCase() == clean.toLowerCase());
}
/// Record GCash cash-in / cash-out with convenience fee
Future<String> recordGcash({
required String transactionType,
required double amount,
required double feeCharged,
String? referenceNumber,
String? customerName,
String? customerPhone,
String? notes,
}) async {
final gcashId = _uuid.v4();
final now = DateTime.now();
await transaction(() async {
await into(gcashLedger).insert(GcashLedgerCompanion.insert(
id: gcashId,
transactionType: transactionType,
amount: amount,
feeCharged: Value(feeCharged),
referenceNumber: Value(referenceNumber),
customerName: Value(customerName),
customerPhone: Value(customerPhone),
timestamp: Value(now),
notes: Value(notes),
));
await _enqueueSync(
entityTable: 'gcash_ledger',
entityId: gcashId,
operation: 'INSERT',
payload: {
'id': gcashId,
'transactionType': transactionType,
'amount': amount,
'feeCharged': feeCharged,
'referenceNumber': referenceNumber,
'customerName': customerName,
'customerPhone': customerPhone,
'timestamp': now.toIso8601String(),
'notes': notes,
},
);
});
return gcashId;
}
/// Alias for recordGcash to support flexible naming
Future<String> recordGcashEntry({
required String transactionType,
required double amount,
required double feeCharged,
String? referenceNumber,
String? customerName,
String? customerPhone,
String? notes,
}) => recordGcash(
transactionType: transactionType,
amount: amount,
feeCharged: feeCharged,
referenceNumber: referenceNumber,
customerName: customerName,
customerPhone: customerPhone,
notes: notes,
);
/// Watch customizable fee schedule brackets
Stream<List<GcashFeeBracket>> watchFeeBrackets() {
return (select(gcashFeeConfig)..orderBy([(b) => OrderingTerm.asc(b.minAmount)])).watch();
}
/// Get current fee brackets
Future<List<GcashFeeBracket>> getFeeBrackets() {
return (select(gcashFeeConfig)..orderBy([(b) => OrderingTerm.asc(b.minAmount)])).get();
}
/// Seed default Philippine sari-sari store fee schedule if empty
Future<void> seedDefaultFeeBrackets() async {
final existing = await select(gcashFeeConfig).get();
if (existing.isNotEmpty) return;
final defaultBrackets = [
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 1.0,
maxAmount: 500.0,
fixedFee: const Value(10.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 501.0,
maxAmount: 1000.0,
fixedFee: const Value(15.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 1001.0,
maxAmount: 1500.0,
fixedFee: const Value(20.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 1501.0,
maxAmount: 2000.0,
fixedFee: const Value(25.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 2001.0,
maxAmount: 3000.0,
fixedFee: const Value(35.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 3001.0,
maxAmount: 5000.0,
fixedFee: const Value(50.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 5001.0,
maxAmount: 10000.0,
fixedFee: const Value(0.0),
percentageFee: const Value(1.5),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 10001.0,
maxAmount: 50000.0,
fixedFee: const Value(0.0),
percentageFee: const Value(2.0),
),
];
for (final bracket in defaultBrackets) {
await into(gcashFeeConfig).insert(bracket);
}
}
/// Upsert a fee bracket with cloud sync queueing
Future<void> saveFeeBracket(GcashFeeConfigCompanion bracket) async {
await into(gcashFeeConfig).insertOnConflictUpdate(bracket);
await _enqueueSync(
entityTable: 'gcash_fee_config',
entityId: bracket.id.value,
operation: 'UPSERT',
payload: {
'id': bracket.id.value,
'minAmount': bracket.minAmount.value,
'maxAmount': bracket.maxAmount.value,
'fixedFee': bracket.fixedFee.present ? bracket.fixedFee.value : 0.0,
'percentageFee': bracket.percentageFee.present ? bracket.percentageFee.value : 0.0,
},
);
}
/// Delete a fee bracket with sync queueing
Future<void> deleteFeeBracket(String id) async {
await (delete(gcashFeeConfig)..where((b) => b.id.equals(id))).go();
await _enqueueSync(
entityTable: 'gcash_fee_config',
entityId: id,
operation: 'DELETE',
payload: {'id': id},
);
}
/// Calculate convenience fee for a given amount using active brackets
Future<double> calculateGcashFee(double amount) async {
final brackets = await getFeeBrackets();
for (final b in brackets) {
if (amount >= b.minAmount && amount <= b.maxAmount) {
if (b.percentageFee > 0) {
return (amount * (b.percentageFee / 100.0));
}
return b.fixedFee;
}
}
// Default fallback brackets if table is empty
if (amount <= 500) return 10.0;
if (amount <= 1000) return 15.0;
if (amount <= 1500) return 20.0;
if (amount <= 2000) return 25.0;
if (amount <= 3000) return 35.0;
if (amount <= 5000) return 50.0;
return (amount * 0.015);
}
/// Watch real-time dual-float balances (GCash E-Wallet vs Physical Cash Drawer)
Stream<DualFloatBalance> watchDualFloatBalance({
double baseCash = 5000.0,
double baseGcash = 10000.0,
}) {
return watchGcashTransactions().asyncMap((txList) async {
final cashSales = await (select(sales)..where((s) => s.paymentMethod.equals('CASH'))).get();
double gcash = baseGcash;
double cash = baseCash;
double fees = 0.0;
int cashIn = 0;
int cashOut = 0;
int storePay = 0;
for (final tx in txList) {
fees += tx.feeCharged;
if (tx.transactionType == 'CASH_IN') {
cashIn++;
gcash -= tx.amount; // Store sent GCash
cash += (tx.amount + tx.feeCharged); // Store received cash + fee
} else if (tx.transactionType == 'CASH_OUT') {
cashOut++;
gcash += tx.amount; // Store received GCash
cash -= (tx.amount - tx.feeCharged); // Cash given to customer minus fee
} else if (tx.transactionType == 'STORE_PAYMENT') {
storePay++;
gcash += tx.amount; // Direct store sale paid to GCash
} else if (tx.transactionType == 'FLOAT_ADJUSTMENT') {
gcash += tx.amount;
}
}
for (final s in cashSales) {
cash += s.totalAmount;
}
return DualFloatBalance(
gcashFloat: gcash,
cashDrawer: cash,
totalFeesEarned: fees,
totalTransactions: txList.length,
cashInCount: cashIn,
cashOutCount: cashOut,
storePaymentCount: storePay,
);
});
}
// --------------------------------------------------------------------------
// SYNC QUEUE OPERATIONS & REPLICATION METHODS
// --------------------------------------------------------------------------
/// Watch real-time count of pending sync mutations for UI badge
Stream<int> watchPendingSyncCount() {
final countExp = syncQueue.id.count();
final query = selectOnly(syncQueue)
..addColumns([countExp])
..where(syncQueue.status.equals('PENDING'));
return query.map((row) => row.read(countExp) ?? 0).watchSingle();
}
/// Get pending mutation logs to sync to cloud
Future<List<SyncQueueEntry>> getPendingSyncQueue({int limit = 100}) {
return (select(syncQueue)
..where((s) => s.status.equals('PENDING'))
..orderBy([(s) => OrderingTerm.asc(s.createdAt)])
..limit(limit))
.get();
}
/// Mark single sync queue item as completed
Future<void> markSyncSuccess(String id) {
return (update(syncQueue)..where((s) => s.id.equals(id))).write(
const SyncQueueCompanion(status: Value('COMPLETED')),
);
}
/// Mark batch of sync queue items as completed
Future<void> markSyncSuccessBatch(List<String> ids) async {
if (ids.isEmpty) return;
await (update(syncQueue)..where((s) => s.id.isIn(ids))).write(
const SyncQueueCompanion(status: Value('COMPLETED')),
);
}
/// Mark sync queue item as failed with error details and increment retryCount
Future<void> markSyncFailed(String id, String error) async {
final item = await (select(syncQueue)..where((s) => s.id.equals(id))).getSingleOrNull();
if (item != null) {
await (update(syncQueue)..where((s) => s.id.equals(id))).write(
SyncQueueCompanion(
status: const Value('FAILED'),
retryCount: Value(item.retryCount + 1),
lastError: Value(error),
),
);
}
}
/// Apply remote products from cloud delta pull with Last-Write-Wins conflict resolution
Future<void> applyRemoteProducts(List<dynamic> remoteProducts) async {
await transaction(() async {
for (final p in remoteProducts) {
final id = p['id'] as String;
final remoteUpdated = DateTime.tryParse(p['updatedAt']?.toString() ?? '') ?? DateTime.now();
final local = await (select(products)..where((prod) => prod.id.equals(id))).getSingleOrNull();
if (local != null) {
// Last-Write-Wins: only apply if remote is newer or equal
if (remoteUpdated.isAfter(local.updatedAt) || remoteUpdated.isAtSameMomentAs(local.updatedAt)) {
await (update(products)..where((prod) => prod.id.equals(id))).write(
ProductsCompanion(
barcode: Value(p['barcode'] as String? ?? local.barcode),
name: Value(p['name'] as String? ?? local.name),
category: Value(p['category'] as String? ?? local.category),
costPrice: Value((p['costPrice'] as num?)?.toDouble() ?? local.costPrice),
sellingPrice: Value((p['sellingPrice'] as num?)?.toDouble() ?? local.sellingPrice),
currentStock: Value((p['currentStock'] as num?)?.toDouble() ?? local.currentStock),
unit: Value(p['unit'] as String? ?? local.unit),
isTingi: Value(p['isTingi'] as bool? ?? local.isTingi),
imageUrl: Value(p['imageUrl'] as String? ?? local.imageUrl),
isDeleted: Value(p['isDeleted'] as bool? ?? local.isDeleted),
updatedAt: Value(remoteUpdated),
),
);
}
} else {
// New product from cloud
await into(products).insert(
ProductsCompanion.insert(
id: id,
barcode: Value(p['barcode'] as String?),
name: p['name'] as String? ?? 'Product',
category: Value(p['category'] as String? ?? 'General'),
costPrice: Value((p['costPrice'] as num?)?.toDouble() ?? 0.0),
sellingPrice: Value((p['sellingPrice'] as num?)?.toDouble() ?? 0.0),
currentStock: Value((p['currentStock'] as num?)?.toDouble() ?? 0.0),
unit: Value(p['unit'] as String? ?? 'pcs'),
isTingi: Value(p['isTingi'] as bool? ?? false),
imageUrl: Value(p['imageUrl'] as String?),
isDeleted: Value(p['isDeleted'] as bool? ?? false),
createdAt: Value(DateTime.tryParse(p['createdAt']?.toString() ?? '') ?? DateTime.now()),
updatedAt: Value(remoteUpdated),
),
);
}
}
});
}
/// Apply remote sales from cloud (additive ledger; idempotent by ID)
Future<void> applyRemoteSales(List<dynamic> remoteSales) async {
await transaction(() async {
for (final s in remoteSales) {
final id = s['id'] as String;
final existing = await (select(sales)..where((sale) => sale.id.equals(id))).getSingleOrNull();
if (existing == null) {
await into(sales).insert(
SalesCompanion.insert(
id: id,
timestamp: Value(DateTime.tryParse(s['createdAt']?.toString() ?? '') ?? DateTime.now()),
paymentMethod: Value(s['paymentMethod'] as String? ?? 'CASH'),
totalAmount: (s['totalAmount'] as num?)?.toDouble() ?? 0.0,
totalProfit: Value((s['estimatedTubo'] as num?)?.toDouble() ?? 0.0),
createdAt: Value(DateTime.tryParse(s['createdAt']?.toString() ?? '') ?? DateTime.now()),
),
);
}
}
});
}
/// Apply remote debts from cloud with Last-Write-Wins balance updates
Future<void> applyRemoteDebts(List<dynamic> remoteDebts) async {
await transaction(() async {
for (final d in remoteDebts) {
final id = d['id'] as String;
final remoteUpdated = DateTime.tryParse(d['updatedAt']?.toString() ?? '') ?? DateTime.now();
final local = await (select(debts)..where((deb) => deb.id.equals(id))).getSingleOrNull();
if (local != null) {
if (remoteUpdated.isAfter(local.updatedAt) || remoteUpdated.isAtSameMomentAs(local.updatedAt)) {
await (update(debts)..where((deb) => deb.id.equals(id))).write(
DebtsCompanion(
customerName: Value(d['customerName'] as String? ?? local.customerName),
customerPhone: Value(d['customerPhone'] as String? ?? local.customerPhone),
totalAmount: Value((d['totalAmount'] as num?)?.toDouble() ?? local.totalAmount),
remainingBalance: Value((d['remainingBalance'] as num?)?.toDouble() ?? local.remainingBalance),
status: Value(d['status'] as String? ?? local.status),
dueDate: Value(d['dueDate'] != null ? DateTime.tryParse(d['dueDate'].toString()) : local.dueDate),
notes: Value(d['notes'] as String? ?? local.notes),
updatedAt: Value(remoteUpdated),
),
);
}
} else {
await into(debts).insert(
DebtsCompanion.insert(
id: id,
customerName: d['customerName'] as String? ?? 'Customer',
customerPhone: Value(d['customerPhone'] as String?),
totalAmount: (d['totalAmount'] as num?)?.toDouble() ?? 0.0,
remainingBalance: (d['remainingBalance'] as num?)?.toDouble() ?? ((d['totalAmount'] as num?)?.toDouble() ?? 0.0),
borrowedDate: Value(DateTime.tryParse(d['createdAt']?.toString() ?? '') ?? DateTime.now()),
dueDate: Value(d['dueDate'] != null ? DateTime.tryParse(d['dueDate'].toString()) : null),
status: Value(d['status'] as String? ?? 'PENDING'),
notes: Value(d['notes'] as String?),
updatedAt: Value(remoteUpdated),
),
);
}
}
});
}
/// Apply remote GCash transactions (additive ledger)
Future<void> applyRemoteGcash(List<dynamic> remoteGcash) async {
await transaction(() async {
for (final g in remoteGcash) {
final id = g['id'] as String;
final existing = await (select(gcashLedger)..where((item) => item.id.equals(id))).getSingleOrNull();
if (existing == null) {
await into(gcashLedger).insert(
GcashLedgerCompanion.insert(
id: id,
transactionType: g['type'] as String? ?? 'CASH_IN',
amount: (g['amount'] as num?)?.toDouble() ?? 0.0,
feeCharged: Value((g['feeCharged'] as num?)?.toDouble() ?? 0.0),
referenceNumber: Value(g['referenceNumber'] as String?),
timestamp: Value(DateTime.tryParse(g['createdAt']?.toString() ?? '') ?? DateTime.now()),
),
);
}
}
});
}
/// Cloud Disaster Recovery: Rehydrate full store state from cloud snapshot
Future<void> restoreCloudSnapshot({
required List<dynamic> remoteProducts,
required List<dynamic> remoteDebts,
required List<dynamic> remoteSales,
required List<dynamic> remoteGcash,
}) async {
await transaction(() async {
// Clear pending queue to avoid echoing restored items back to cloud
await delete(syncQueue).go();
await applyRemoteProducts(remoteProducts);
await applyRemoteDebts(remoteDebts);
await applyRemoteSales(remoteSales);
await applyRemoteGcash(remoteGcash);
});
}
}
/// Riverpod Provider exposing the single AppDatabase instance across the entire application
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(() => db.close());
return db;
});