added local db

This commit is contained in:
2026-07-29 13:06:39 +05:30
parent d72522e737
commit d9886880cc
38 changed files with 2895 additions and 2261 deletions

View File

@@ -1,136 +1,172 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
import 'seed_data.dart';
import '../local/app_database.dart';
import '../local/catalogue_dao.dart';
import '../local/order_dao.dart';
/// On-terminal storage.
/// Terminal-side storage facade.
///
/// The terminal starts with an **empty catalogue**: nothing can be billed
/// until the cashier imports products. Everything after that — sales, parked
/// bills, queued events — lives here and survives without a connection.
///
/// Swapping this for Hive or SQLite changes nothing above the data layer.
/// 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;
final Map<String, Product> _products = {};
final Map<String, Customer> _customers = {};
final List<SaleTransaction> _transactions = [];
final List<ParkedBill> _parked = [];
final List<SyncEvent> _events = [];
int _invoiceSequence = 0;
DateTime? _lastImportAt;
String? _catalogueRevision;
int _unsyncedOrders = 0;
/// Nothing to seed — the catalogue arrives via import.
Future<void> init() async {}
bool _ready = false;
/// Test helper. Clears everything and optionally loads the demo catalogue
/// so fixtures don't have to run an import first.
bool get isReady => _ready;
/// Opens the database and loads the catalogue into memory.
Future<void> 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);
await hydrate();
_ready = true;
}
/// Re-reads cached state from disk. Called on start and after an import.
Future<void> 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);
_unsyncedOrders = await orders.unsyncedCount();
}
/// Test helper: wipes every table and reloads.
Future<void> reset({bool withCatalogue = false}) async {
_products.clear();
_customers.clear();
_transactions.clear();
_parked.clear();
_events.clear();
_invoiceSequence = 0;
_lastImportAt = null;
_catalogueRevision = null;
if (!_ready) await init(inMemory: true);
await AppDatabase.instance.clear();
if (withCatalogue) {
importCatalogue(
products: SeedData.products(),
customers: SeedData.customers(),
revision: 'seed',
at: DateTime.now(),
// 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 a catalogue has been pulled. The POS refuses to bill until so.
/// 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;
/// Replaces the catalogue wholesale.
///
/// Stock levels already adjusted by local sales are preserved for products
/// that survive the re-import, so importing mid-shift does not resurrect
/// stock that has been sold.
void importCatalogue({
Future<void> importCatalogue({
required List<Product> products,
required List<Customer> customers,
required String revision,
required DateTime at,
}) {
final priorStock = {
for (final p in _products.values) p.id: p.stock,
};
_products
..clear()
..addEntries(products.map((p) {
final held = priorStock[p.id];
return MapEntry(p.id, held == null ? p : p.copyWith(stock: held));
}));
// Locally registered customers must not be wiped by a server pull.
for (final c in customers) {
_customers.putIfAbsent(c.id, () => c);
}
_lastImportAt = at;
_catalogueRevision = revision;
}) async {
await catalogue.replaceCatalogue(products: products, customers: customers);
await catalogue.setMeta(
MetaKeys.lastImportAt,
'${at.millisecondsSinceEpoch}',
);
await catalogue.setMeta(MetaKeys.catalogueRevision, revision);
await hydrate();
}
// --------------------------------------------------------------- Products
List<Product> get products => _products.values.toList(growable: false);
void putProduct(Product p) => _products[p.id] = p;
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<void> putProduct(Product p) async {
await catalogue.upsertProduct(p);
_products[p.id] = p;
}
Future<void> applyStockMovement(Map<String, double> quantities) async {
await catalogue.decrementStock(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<Customer> get customers => _customers.values.toList(growable: false);
void putCustomer(Customer c) => _customers[c.id] = c;
Customer? customerById(String id) => _customers[id];
// ----------------------------------------------------------- Transactions
List<SaleTransaction> get transactions =>
List.unmodifiable(_transactions.reversed);
void addTransaction(SaleTransaction t) => _transactions.add(t);
int nextInvoiceSequence() => ++_invoiceSequence;
// ------------------------------------------------------------ Parked bills
List<ParkedBill> get parked => List.unmodifiable(_parked);
void addParked(ParkedBill b) => _parked.add(b);
void removeParked(String id) => _parked.removeWhere((b) => b.id == id);
// ------------------------------------------------------------ Sync events
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
void addEvent(SyncEvent e) => _events.add(e);
/// Updates in place. Events are never removed, so a failed push stays
/// visible and retryable.
void updateEvent(SyncEvent e) {
final i = _events.indexWhere((x) => x.id == e.id);
if (i >= 0) _events[i] = e;
Future<void> putCustomer(Customer c) async {
await catalogue.upsertCustomer(c);
_customers[c.id] = c;
}
bool get hasUnsyncedEvents =>
_events.any((e) => e.status != SyncStatus.synced);
// ----------------------------------------------------------------- Orders
/// Refreshes the cached unsynced tally after a write or a sync.
Future<int> refreshUnsyncedCount() async {
_unsyncedOrders = await orders.unsyncedCount();
return _unsyncedOrders;
}
// ------------------------------------------------------------- Seed data
// Kept behind these hooks so production code never reaches for them.
static List<Product> Function() _seedProducts = () => const [];
static List<Customer> Function() _seedCustomers = () => const [];
/// Lets the simulated remote source and tests supply demo rows.
static void registerSeed({
required List<Product> Function() products,
required List<Customer> Function() customers,
}) {
_seedProducts = products;
_seedCustomers = customers;
}
}

View File

@@ -1,5 +1,6 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import 'local_store.dart';
import 'seed_data.dart';
/// What one catalogue pull returns.
@@ -35,7 +36,12 @@ class CatalogueSyncException implements Exception {
/// The real implementation would issue an HTTP request; the contract is the
/// same, so only this class changes.
class RemoteCatalogueSource {
RemoteCatalogueSource();
RemoteCatalogueSource() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
}
/// Flipped from Settings to exercise the offline path.
bool simulateOffline = false;
@@ -75,24 +81,28 @@ class RemoteCatalogueSource {
}
}
/// Stands in for the back-office reporting API.
class RemoteReportSink {
RemoteReportSink();
/// Stands in for the back-office order intake API.
class RemoteOrderSink {
RemoteOrderSink();
bool simulateOffline = false;
/// Pushes one payload. Throws on failure so the caller can keep the event
/// queued rather than marking it sent.
Future<String> push(Map<String, Object?> payload) async {
await Future<void>.delayed(const Duration(milliseconds: 900));
/// Uploads a batch of orders and returns the ids the server accepted.
///
/// Throws on transport failure so the caller leaves every row at
/// `sync_status = 0` rather than marking anything sent.
Future<List<String>> pushOrders(List<Map<String, Object?>> orders) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + orders.length * 60),
);
if (simulateOffline) {
throw const CatalogueSyncException(
'Could not reach the reporting server. '
'The report is still saved on this terminal.',
'Could not reach the order server. Every bill is still stored on '
'this terminal and will upload on the next attempt.',
);
}
return 'ACK-${DateTime.now().millisecondsSinceEpoch % 1000000}';
return orders.map((o) => o['id']! as String).toList();
}
}