Two defects that share a shape: a figure landing on the wrong record.
Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.
Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.
Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.
Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.
Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
9.4 KiB
Dart
279 lines
9.4 KiB
Dart
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<String, Product> _products = {};
|
|
final Map<String, Customer> _customers = {};
|
|
final List<SyncEvent> _syncEvents = [];
|
|
|
|
DateTime? _lastImportAt;
|
|
String? _catalogueRevision;
|
|
int _unsyncedOrders = 0;
|
|
int _unsyncedCustomers = 0;
|
|
|
|
bool _ready = false;
|
|
|
|
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);
|
|
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<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);
|
|
terminal = await identityStore.load();
|
|
_unsyncedOrders = await orders.unsyncedCount();
|
|
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
|
|
|
|
_syncEvents
|
|
..clear()
|
|
..addAll(await syncLog.recent());
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Sync log
|
|
/// Newest first. Backed by the table, so it survives a restart.
|
|
List<SyncEvent> get syncEvents => List.unmodifiable(_syncEvents);
|
|
|
|
Future<void> appendSyncEvent(SyncEvent event) async {
|
|
await syncLog.insert(event);
|
|
await syncLog.trim();
|
|
_syncEvents.insert(0, event);
|
|
}
|
|
|
|
/// Test helper: wipes every table and reloads.
|
|
Future<void> 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<void> importCatalogue({
|
|
required List<Product> products,
|
|
required List<Customer> customers,
|
|
required String revision,
|
|
required DateTime at,
|
|
bool isDelta = false,
|
|
List<String> 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<Product> 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<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);
|
|
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<String, double> 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);
|
|
|
|
Customer? customerById(String id) => _customers[id];
|
|
|
|
Future<void> putCustomer(Customer c) async {
|
|
await catalogue.upsertCustomer(c);
|
|
_customers[c.id] = c;
|
|
await refreshUnsyncedCustomerCount();
|
|
}
|
|
|
|
/// Mirrors a customer already written to disk into the memory cache.
|
|
void cacheCustomer(Customer c) => _customers[c.id] = c;
|
|
|
|
/// Shoppers registered here and not yet uploaded.
|
|
int get unsyncedCustomers => _unsyncedCustomers;
|
|
|
|
Future<int> refreshUnsyncedCustomerCount() async {
|
|
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
|
|
return _unsyncedCustomers;
|
|
}
|
|
|
|
// ----------------------------------------------------------------- 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;
|
|
}
|
|
}
|