Files
nearle_pos/lib/data/datasources/local_store.dart
Suriya af3933092f Fix billing data integrity, sale atomicity and stock safety
Bills were persisted correctly but read back wrong. The read path rebuilt a
cart from its lines alone, dropping bill-level discounts and loyalty, so every
figure derived from a stored bill was overstated: the upload payload, the day
archive and the shift report. A discounted 529 bill read back as 620.

Money and data integrity
- order_dao: restore bill_discount and points_redeemed when rebuilding a cart;
  keep the reconstruction tier-less so the membership discount is not applied
  twice. Trust the recorded total and points via SaleTransaction.storedTotal.
- checkout_sale + order_dao.commitSale: write the bill, its stock movement and
  the loyalty update in one transaction. Previously a failure part-way through
  left a persisted bill the cashier believed had failed, inviting a duplicate.
- checkout_sale: re-check every line against live stock. A parked bill resumed
  after its stock was sold passed validation and oversold.
- catalogue_dao: allocate the invoice sequence in one transaction; the previous
  read-modify-write could hand two sales the same number and fail UNIQUE.
- local_store: replay unsynced sales after a catalogue import, so a mid-shift
  re-import cannot restore stock that has already been sold.
- payment_controller: stamp the signed-in operator on the bill instead of the
  hardcoded seed session, and pass the terminal id through.
- cart: reconcile per-slab GST against the bill total so the parts sum to the
  whole on a tax invoice.

Sync and reporting
- sync_repository: drain unsynced bills in a loop rather than silently capping
  at one page; stop on rejection so rejected rows cannot loop forever.
- sync_log_dao (new): persist the sync history to the sync_log table, which the
  schema already defined but nothing used. It was in memory, so the only record
  that bills had been uploaded died at restart.
- Scope shift reports by cashier. day_archive is re-keyed to
  (business_date, cashier_name) so a till stays settleable after its bills are
  uploaded and deleted. Schema v4 with a migration that carries v3 rows across.

Input and UI
- barcode_service: consume machine-paced keystrokes so a scan cannot also land
  in the focused field, and raise the bar to 60ms/char while a text field has
  focus so typing a mobile number is not read as a scan. Clock and focus check
  injected so the behaviour is testable.
- primary_button: make the label flexible; label plus trailing total overflowed
  the Charge button by up to 131px.
- app_router: redirect instead of null-casting when the receipt route is
  entered without its transaction.
- customer_repository: reduce the search query to digits so a punctuated mobile
  number matches.

Cleanup
- Remove TransactionRepository.save, CustomerRepository.recordSale and
  OrderDao.insertOrder, all superseded by commitSale.
- dart fix across the tree; 251 analyzer issues down to 3 info-level.

Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke
the existing suite (containsAll type argument, reset() needing a catalogue) and
deleted the leftover template test. Added coverage for the order round trip,
the day archive after a real sync, stock safety, checkout atomicity, the v3->v4
migration, scanner-versus-human input, and an app-level smoke test that renders
every module.

Note: bills already uploaded with a discount went up overstated. This stops it
happening again but does not correct historical server data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:34:10 +05:30

210 lines
6.8 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/sync_log_dao.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;
final Map<String, Product> _products = {};
final Map<String, Customer> _customers = {};
final List<SyncEvent> _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<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);
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();
_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();
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,
}) async {
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.
final committed = await orders.unsyncedStockCommitments();
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;
}
/// 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<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;
}
}