added local db
This commit is contained in:
@@ -43,13 +43,13 @@ class CustomerRepositoryImpl implements CustomerRepository {
|
||||
visitCount: 0,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_store.putCustomer(created);
|
||||
await _store.putCustomer(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer> update(Customer customer) async {
|
||||
_store.putCustomer(customer);
|
||||
await _store.putCustomer(customer);
|
||||
return customer;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class CustomerRepositoryImpl implements CustomerRepository {
|
||||
visitCount: current.visitCount + 1,
|
||||
lastVisitAt: DateTime.now(),
|
||||
);
|
||||
_store.putCustomer(updated);
|
||||
await _store.putCustomer(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import '../../core/utils/extensions.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/repositories/product_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
|
||||
/// Reads come from the in-memory catalogue for speed; writes go through the
|
||||
/// store, which persists to SQLite before touching the cache.
|
||||
class ProductRepositoryImpl implements ProductRepository {
|
||||
ProductRepositoryImpl(this._store);
|
||||
|
||||
@@ -19,12 +20,8 @@ class ProductRepositoryImpl implements ProductRepository {
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<Product?> findByBarcode(String barcode) async {
|
||||
final needle = barcode.trim();
|
||||
return _store.products.firstWhereOrNull(
|
||||
(p) => p.barcode == needle && p.isActive,
|
||||
);
|
||||
}
|
||||
Future<Product?> findByBarcode(String barcode) async =>
|
||||
_store.productByBarcode(barcode);
|
||||
|
||||
@override
|
||||
Future<Product?> findById(String id) async => _store.productById(id);
|
||||
@@ -34,15 +31,13 @@ class ProductRepositoryImpl implements ProductRepository {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) return getAll();
|
||||
|
||||
final results = _store.products.where((p) => p.isActive && p.matches(q));
|
||||
|
||||
// Rank exact barcode and SKU hits above fuzzy name matches so the top
|
||||
// result is the one the cashier almost certainly meant.
|
||||
final ranked = results.toList()
|
||||
final ranked = _store.products.where((p) => p.isActive && p.matches(q)).toList()
|
||||
..sort((a, b) => _score(b, q).compareTo(_score(a, q)));
|
||||
return ranked;
|
||||
}
|
||||
|
||||
/// Exact barcode and SKU hits rank above fuzzy name matches, so the top
|
||||
/// result is the one the cashier almost certainly meant.
|
||||
int _score(Product p, String q) {
|
||||
final lq = q.toLowerCase();
|
||||
if (p.barcode == q) return 100;
|
||||
@@ -54,15 +49,9 @@ class ProductRepositoryImpl implements ProductRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||
quantities.forEach((id, qty) {
|
||||
final p = _store.productById(id);
|
||||
if (p == null) return;
|
||||
final next = (p.stock - qty).clamp(0, double.infinity).toDouble();
|
||||
_store.putProduct(p.copyWith(stock: next));
|
||||
});
|
||||
}
|
||||
Future<void> decrementStock(Map<String, double> quantities) =>
|
||||
_store.applyStockMovement(quantities);
|
||||
|
||||
@override
|
||||
Future<void> upsert(Product product) async => _store.putProduct(product);
|
||||
Future<void> upsert(Product product) => _store.putProduct(product);
|
||||
}
|
||||
|
||||
@@ -3,19 +3,25 @@ import 'package:uuid/uuid.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../domain/entities/shift_report.dart';
|
||||
import '../../domain/entities/sync_event.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../../domain/repositories/sync_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
import '../datasources/remote_catalogue_source.dart';
|
||||
import '../local/order_dao.dart';
|
||||
|
||||
class SyncRepositoryImpl implements SyncRepository {
|
||||
SyncRepositoryImpl(this._store, this._catalogue, this._reports);
|
||||
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
|
||||
|
||||
final LocalStore _store;
|
||||
final RemoteCatalogueSource _catalogue;
|
||||
final RemoteReportSink _reports;
|
||||
final RemoteOrderSink _orderSink;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// In-session event log. Order sync state itself lives on the order rows,
|
||||
/// so this is only a human-readable history of attempts.
|
||||
final List<SyncEvent> _events = [];
|
||||
|
||||
@override
|
||||
bool get hasCatalogue => _store.hasCatalogue;
|
||||
|
||||
@@ -26,134 +32,216 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
String? get catalogueRevision => _store.catalogueRevision;
|
||||
|
||||
@override
|
||||
List<SyncEvent> get events => _store.events;
|
||||
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
|
||||
|
||||
@override
|
||||
bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
|
||||
void _log(SyncEvent e) => _events.add(e);
|
||||
|
||||
// ------------------------------------------------------- Morning: import
|
||||
@override
|
||||
Future<SyncEvent> importCatalogue({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) async {
|
||||
final event = SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.catalogueImport,
|
||||
status: SyncStatus.syncing,
|
||||
createdAt: DateTime.now(),
|
||||
summary: 'Catalogue import started',
|
||||
);
|
||||
_store.addEvent(event);
|
||||
final id = _uuid.v4();
|
||||
final started = DateTime.now();
|
||||
|
||||
try {
|
||||
final snapshot = await _catalogue.fetch(onProgress: onProgress);
|
||||
|
||||
_store.importCatalogue(
|
||||
await _store.importCatalogue(
|
||||
products: snapshot.products,
|
||||
customers: snapshot.customers,
|
||||
revision: snapshot.revision,
|
||||
at: snapshot.fetchedAt,
|
||||
);
|
||||
|
||||
final done = event.copyWith(
|
||||
final event = SyncEvent(
|
||||
id: id,
|
||||
type: SyncEventType.catalogueImport,
|
||||
status: SyncStatus.synced,
|
||||
createdAt: started,
|
||||
syncedAt: DateTime.now(),
|
||||
attempts: 1,
|
||||
);
|
||||
final settled = SyncEvent(
|
||||
id: done.id,
|
||||
type: done.type,
|
||||
status: done.status,
|
||||
createdAt: done.createdAt,
|
||||
summary: '${snapshot.products.length} products, '
|
||||
'${snapshot.customers.length} customers · ${snapshot.revision}',
|
||||
summary: '${snapshot.products.length} products saved to SQLite '
|
||||
'· ${snapshot.revision}',
|
||||
payload: {
|
||||
'products': snapshot.products.length,
|
||||
'customers': snapshot.customers.length,
|
||||
'revision': snapshot.revision,
|
||||
},
|
||||
syncedAt: done.syncedAt,
|
||||
attempts: 1,
|
||||
);
|
||||
_store.updateEvent(settled);
|
||||
return settled;
|
||||
_log(event);
|
||||
return event;
|
||||
} catch (e) {
|
||||
final failed = event.copyWith(
|
||||
final event = SyncEvent(
|
||||
id: id,
|
||||
type: SyncEventType.catalogueImport,
|
||||
status: SyncStatus.failed,
|
||||
createdAt: started,
|
||||
summary: 'Catalogue import failed',
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
);
|
||||
_store.updateEvent(failed);
|
||||
return failed;
|
||||
_log(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------- Business hours: read
|
||||
@override
|
||||
ShiftReport buildShiftReport({
|
||||
required DateTime businessDate,
|
||||
Future<int> unsyncedCount() => _store.orders.unsyncedCount();
|
||||
|
||||
@override
|
||||
Future<List<SaleTransaction>> unsyncedOrders() => _store.orders.unsynced();
|
||||
|
||||
@override
|
||||
Future<ShiftReport> todayReport({
|
||||
required String terminalId,
|
||||
required String cashierName,
|
||||
}) {
|
||||
}) async {
|
||||
final today = DateTime.now();
|
||||
final orders = await _store.orders.forBusinessDate(today);
|
||||
|
||||
return ShiftReport.fromTransactions(
|
||||
transactions: _store.transactions,
|
||||
businessDate: businessDate,
|
||||
transactions: orders,
|
||||
businessDate: today,
|
||||
terminalId: terminalId,
|
||||
cashierName: cashierName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncEvent> pushShiftReport(ShiftReport report) async {
|
||||
// Queued first, so the data is durable before the network is touched.
|
||||
final queued = SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.pending,
|
||||
createdAt: DateTime.now(),
|
||||
summary: '${report.billCount} bills · '
|
||||
'${Formatters.money(report.grossSales)} · '
|
||||
'${Formatters.date(report.businessDate)}',
|
||||
payload: report.toPayload(),
|
||||
);
|
||||
_store.addEvent(queued);
|
||||
|
||||
return _attempt(queued);
|
||||
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async {
|
||||
final rows = await _store.orders.syncRows(limit: limit);
|
||||
return rows
|
||||
.map((r) => OrderSyncRow(
|
||||
orderId: r['id']! as String,
|
||||
invoiceNumber: r['invoice_number']! as String,
|
||||
total: (r['total']! as num).toDouble(),
|
||||
createdAt:
|
||||
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
||||
isSynced: (r['sync_status']! as int) == OrderDao.synced,
|
||||
syncedAt: r['synced_at'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
||||
attempts: (r['sync_attempts'] as int?) ?? 0,
|
||||
error: r['sync_error'] as String?,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ End of day: sync
|
||||
@override
|
||||
Future<SyncEvent> retry(String eventId) async {
|
||||
final matches = _store.events.where((e) => e.id == eventId).toList();
|
||||
if (matches.isEmpty) {
|
||||
throw StateError('No queued event with id $eventId');
|
||||
}
|
||||
final event = matches.first;
|
||||
if (event.type == SyncEventType.catalogueImport) {
|
||||
return importCatalogue();
|
||||
}
|
||||
return _attempt(event);
|
||||
}
|
||||
Future<SyncOutcome> syncOrders({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) async {
|
||||
onProgress?.call(0.05, 'Collecting unsynced bills…');
|
||||
|
||||
/// Sends one event, leaving it queued if the push fails.
|
||||
Future<SyncEvent> _attempt(SyncEvent event) async {
|
||||
_store.updateEvent(event.copyWith(status: SyncStatus.syncing));
|
||||
final pending = await _store.orders.unsynced();
|
||||
if (pending.isEmpty) {
|
||||
onProgress?.call(1, 'Nothing to upload');
|
||||
return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||
}
|
||||
|
||||
final started = DateTime.now();
|
||||
final ids = pending.map((o) => o.id).toList();
|
||||
|
||||
onProgress?.call(0.35, 'Uploading ${pending.length} bills…');
|
||||
|
||||
try {
|
||||
await _reports.push(event.payload);
|
||||
final done = event.copyWith(
|
||||
final accepted = await _orderSink.pushOrders(
|
||||
pending.map(_orderToPayload).toList(),
|
||||
);
|
||||
|
||||
onProgress?.call(0.85, 'Marking bills as synced…');
|
||||
|
||||
// Only what the server confirmed is flipped to 1.
|
||||
await _store.orders.markSynced(accepted);
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
final rejected = ids.where((id) => !accepted.contains(id)).toList();
|
||||
if (rejected.isNotEmpty) {
|
||||
await _store.orders.markFailed(rejected, 'Rejected by server');
|
||||
}
|
||||
|
||||
onProgress?.call(1, 'Done');
|
||||
|
||||
_log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.synced,
|
||||
createdAt: started,
|
||||
syncedAt: DateTime.now(),
|
||||
attempts: event.attempts + 1,
|
||||
clearError: true,
|
||||
);
|
||||
_store.updateEvent(done);
|
||||
return done.copyWith();
|
||||
summary: '${accepted.length} of ${pending.length} bills uploaded',
|
||||
attempts: 1,
|
||||
));
|
||||
|
||||
return SyncOutcome(attempted: pending.length, uploaded: accepted.length);
|
||||
} catch (e) {
|
||||
final failed = event.copyWith(
|
||||
// Transport failed: record the attempt but leave every row at 0.
|
||||
await _store.orders.markFailed(ids, e.toString());
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
_log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.failed,
|
||||
createdAt: started,
|
||||
summary: '${pending.length} bills still pending '
|
||||
'(${Formatters.money(pending.fold<double>(0, (s, o) => s + o.total))})',
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
));
|
||||
|
||||
return SyncOutcome(
|
||||
attempted: pending.length,
|
||||
uploaded: 0,
|
||||
error: e.toString(),
|
||||
attempts: event.attempts + 1,
|
||||
);
|
||||
_store.updateEvent(failed);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON body sent per order.
|
||||
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
||||
'id': t.id,
|
||||
'invoice_number': t.invoiceNumber,
|
||||
'created_at': t.createdAt.toIso8601String(),
|
||||
'terminal_id': t.terminalId,
|
||||
'cashier': t.cashierName,
|
||||
'customer': t.customer == null
|
||||
? null
|
||||
: {
|
||||
'id': t.customer!.id,
|
||||
'mobile': t.customer!.mobile,
|
||||
'name': t.customer!.name,
|
||||
},
|
||||
'subtotal': t.cart.subtotal,
|
||||
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
|
||||
'tax': t.cart.taxAmount,
|
||||
'round_off': t.cart.roundOff,
|
||||
'total': t.total,
|
||||
'points_earned': t.pointsEarned,
|
||||
'points_redeemed': t.pointsRedeemed,
|
||||
'payments': [
|
||||
for (final p in t.payments)
|
||||
{
|
||||
'method': p.method.name,
|
||||
'amount': p.amount,
|
||||
'reference': p.reference,
|
||||
},
|
||||
],
|
||||
'items': [
|
||||
for (final l in t.cart.lines)
|
||||
{
|
||||
'product_id': l.product.id,
|
||||
'barcode': l.product.barcode,
|
||||
'name': l.product.name,
|
||||
'quantity': l.quantity,
|
||||
'unit_price': l.product.price,
|
||||
'discount': l.discountAmount,
|
||||
'gst_rate': l.product.gstRate,
|
||||
'tax': l.taxAmount,
|
||||
'line_total': l.payable,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import '../../core/utils/extensions.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../../domain/repositories/transaction_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
|
||||
/// All bill persistence goes straight to SQLite.
|
||||
class TransactionRepositoryImpl implements TransactionRepository {
|
||||
TransactionRepositoryImpl(this._store);
|
||||
|
||||
@@ -10,40 +10,33 @@ class TransactionRepositoryImpl implements TransactionRepository {
|
||||
|
||||
@override
|
||||
Future<SaleTransaction> save(SaleTransaction transaction) async {
|
||||
_store.addTransaction(transaction);
|
||||
// Written with sync_status = 0; the end-of-day upload picks it up.
|
||||
await _store.orders.insertOrder(transaction);
|
||||
await _store.refreshUnsyncedCount();
|
||||
return transaction;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaleTransaction>> history({int limit = 50}) async =>
|
||||
_store.transactions.take(limit).toList();
|
||||
Future<List<SaleTransaction>> history({int limit = 50}) =>
|
||||
_store.orders.recent(limit: limit);
|
||||
|
||||
@override
|
||||
Future<SaleTransaction?> findByInvoice(String invoiceNumber) async =>
|
||||
_store.transactions
|
||||
.firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
|
||||
Future<SaleTransaction?> findByInvoice(String invoiceNumber) =>
|
||||
_store.orders.byInvoice(invoiceNumber);
|
||||
|
||||
@override
|
||||
Future<int> nextInvoiceSequence() async => _store.nextInvoiceSequence();
|
||||
Future<int> nextInvoiceSequence() => _store.catalogue.nextInvoiceSequence();
|
||||
|
||||
@override
|
||||
Future<void> park(ParkedBill bill) async => _store.addParked(bill);
|
||||
Future<void> park(ParkedBill bill) => _store.orders.park(bill);
|
||||
|
||||
@override
|
||||
Future<List<ParkedBill>> parkedBills() async => _store.parked;
|
||||
Future<List<ParkedBill>> parkedBills() => _store.orders.parkedBills();
|
||||
|
||||
@override
|
||||
Future<void> removeParked(String id) async => _store.removeParked(id);
|
||||
Future<void> removeParked(String id) => _store.orders.removeParked(id);
|
||||
|
||||
@override
|
||||
Future<double> salesTotalForDay(DateTime day) async {
|
||||
return _store.transactions
|
||||
.where((t) =>
|
||||
t.status == TransactionStatus.completed &&
|
||||
t.createdAt.year == day.year &&
|
||||
t.createdAt.month == day.month &&
|
||||
t.createdAt.day == day.day)
|
||||
.fold(0.0, (sum, t) => sum + t.total)
|
||||
.asMoney;
|
||||
}
|
||||
Future<double> salesTotalForDay(DateTime day) =>
|
||||
_store.orders.salesTotalForDay(day);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user