added local db
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user