import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; import '../../domain/entities/sync_event.dart'; import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; import '../local/promo_dao.dart'; import '../local/staff_dao.dart'; import '../local/sync_config_store.dart'; import '../local/sync_log_dao.dart'; import '../local/terminal_identity.dart'; /// Terminal-side storage facade. /// /// SQLite is the source of truth. The catalogue is additionally held in memory /// because barcode resolution happens on every scan and the product grid reads /// it constantly — but every write goes to disk first, so nothing depends on /// the process staying alive. class LocalStore { LocalStore._(); static final LocalStore instance = LocalStore._(); late CatalogueDao catalogue; late OrderDao orders; late SyncLogDao syncLog; late StaffDao staff; late PromoDao promos; late SyncConfigStore syncConfig; late TerminalIdentityStore identityStore; /// Who this till is. Minted on first run, then stable forever. late TerminalIdentity terminal; final Map _products = {}; final Map _customers = {}; final List _syncEvents = []; DateTime? _lastImportAt; String? _catalogueRevision; int _unsyncedOrders = 0; bool _ready = false; bool get isReady => _ready; /// Opens the database and loads the catalogue into memory. Future init({String? databasePath, bool inMemory = false}) async { if (inMemory) { await AppDatabase.instance.openInMemory(); } else { await AppDatabase.instance.open(overridePath: databasePath); } catalogue = CatalogueDao(AppDatabase.instance.db); orders = OrderDao(AppDatabase.instance.db); syncLog = SyncLogDao(AppDatabase.instance.db); staff = StaffDao(AppDatabase.instance.db); promos = PromoDao(AppDatabase.instance.db); syncConfig = SyncConfigStore(catalogue); identityStore = TerminalIdentityStore(catalogue); // A terminal with no staff cannot be signed into at all, so this runs // before anything else can ask who is on shift. await staff.seedIfEmpty(); // Before anything can be written or published: a bill stamped with the // wrong terminal cannot be traced back to the till that rang it. terminal = await identityStore.load(); await hydrate(); _ready = true; } /// Re-reads cached state from disk. Called on start and after an import. Future hydrate() async { _products ..clear() ..addEntries( (await catalogue.allProducts()).map((p) => MapEntry(p.id, p)), ); _customers ..clear() ..addEntries( (await catalogue.allCustomers()).map((c) => MapEntry(c.id, c)), ); final stamp = await catalogue.meta(MetaKeys.lastImportAt); _lastImportAt = stamp == null ? null : DateTime.fromMillisecondsSinceEpoch(int.parse(stamp)); _catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision); terminal = await identityStore.load(); _unsyncedOrders = await orders.unsyncedCount(); _syncEvents ..clear() ..addAll(await syncLog.recent()); } // ---------------------------------------------------------------- Sync log /// Newest first. Backed by the table, so it survives a restart. List get syncEvents => List.unmodifiable(_syncEvents); Future appendSyncEvent(SyncEvent event) async { await syncLog.insert(event); await syncLog.trim(); _syncEvents.insert(0, event); } /// Test helper: wipes every table and reloads. Future reset({bool withCatalogue = false}) async { if (!_ready) await init(inMemory: true); await AppDatabase.instance.clear(); // Staff and identity are cleared with everything else, and a terminal // without them cannot be signed into or stamp a bill. Re-minted here so a // reset leaves a usable till rather than a half-built one. await staff.seedIfEmpty(); terminal = await identityStore.load(); if (withCatalogue) { // Imported here rather than at the top of the file so the seed data is // only pulled in by tests and the simulated remote source. await catalogue.replaceCatalogue( products: _seedProducts(), customers: _seedCustomers(), ); await catalogue.setMeta( MetaKeys.lastImportAt, '${DateTime.now().millisecondsSinceEpoch}', ); await catalogue.setMeta(MetaKeys.catalogueRevision, 'seed'); } await hydrate(); } // -------------------------------------------------------------- Catalogue /// True once products exist on this terminal. Billing is gated on it. bool get hasCatalogue => _products.isNotEmpty; DateTime? get lastImportAt => _lastImportAt; String? get catalogueRevision => _catalogueRevision; int get unsyncedOrders => _unsyncedOrders; Future importCatalogue({ required List products, required List customers, required String revision, required DateTime at, bool isDelta = false, List retiredProductIds = const [], }) async { if (isDelta) { // A change set must not withdraw what it does not mention. Applied as a // full snapshot, the first morning price change would empty the shelf. await catalogue.applyCatalogueDelta( products: products, customers: customers, retiredProductIds: retiredProductIds, ); } else { await catalogue.replaceCatalogue( products: products, customers: customers, ); } // The server's stock figure predates any sale this terminal has made but // not yet uploaded, so those units would reappear on the shelf. Replay them // before anyone can bill against the inflated count. // // Scoped to the products the pull actually overwrote. A full snapshot // rewrites every row, so the replay covers everything; a delta rewrites // only what it carried, and replaying the rest would subtract those units a // second time from a count that was never reset — quietly emptying a shelf // that is full. var committed = await orders.unsyncedStockCommitments(); if (isDelta) { final touched = products.map((p) => p.id).toSet(); committed = { for (final entry in committed.entries) if (touched.contains(entry.key)) entry.key: entry.value, }; } if (committed.isNotEmpty) { await catalogue.decrementStock(committed); } await catalogue.setMeta( MetaKeys.lastImportAt, '${at.millisecondsSinceEpoch}', ); await catalogue.setMeta(MetaKeys.catalogueRevision, revision); await hydrate(); } // --------------------------------------------------------------- Products List get products => _products.values.toList(growable: false); Product? productById(String id) => _products[id]; Product? productByBarcode(String barcode) { final needle = barcode.trim(); for (final p in _products.values) { if (p.barcode == needle && p.isActive) return p; } return null; } /// Write-through: disk first, then the cache. Future putProduct(Product p) async { await catalogue.upsertProduct(p); _products[p.id] = p; } Future applyStockMovement(Map quantities) async { await catalogue.decrementStock(quantities); cacheStockMovement(quantities); } /// Mirrors a stock decrement already written to disk into the memory cache. /// Used after a sale is committed as part of a larger transaction. void cacheStockMovement(Map quantities) { quantities.forEach((id, qty) { final p = _products[id]; if (p == null) return; _products[id] = p.copyWith(stock: (p.stock - qty).clamp(0, double.infinity)); }); } // -------------------------------------------------------------- Customers List get customers => _customers.values.toList(growable: false); Customer? customerById(String id) => _customers[id]; Future putCustomer(Customer c) async { await catalogue.upsertCustomer(c); _customers[c.id] = c; } /// Mirrors a customer already written to disk into the memory cache. void cacheCustomer(Customer c) => _customers[c.id] = c; // ----------------------------------------------------------------- Orders /// Refreshes the cached unsynced tally after a write or a sync. Future refreshUnsyncedCount() async { _unsyncedOrders = await orders.unsyncedCount(); return _unsyncedOrders; } // ------------------------------------------------------------- Seed data // Kept behind these hooks so production code never reaches for them. static List Function() _seedProducts = () => const []; static List Function() _seedCustomers = () => const []; /// Lets the simulated remote source and tests supply demo rows. static void registerSeed({ required List Function() products, required List Function() customers, }) { _seedProducts = products; _seedCustomers = customers; } }