The Promo module was a mockup. Three hardcoded rows, a toggle that changed nothing, and no promo code anywhere in lib/domain or lib/data. A cashier looking at it would reasonably conclude promotions were running. Engine (domain/services/promo_engine.dart) - Five campaign types: percent or flat off the bill, percent off a category or a product, and buy-X-get-Y. - Conditions: date range (inclusive of the closing day), days of the week, minimum bill value, and a cap on what a percentage can take off — without one an unusually large trolley gives away more than the campaign was costed for. - Stacking is conservative by default. All stackable campaigns apply together; of the exclusive ones only the single best does, chosen by what it is worth to the shopper with priority breaking ties. Two percentages compounding produce a discount nobody signed off, and the shop finds out at the end of the month. - The total is capped at the subtotal, so no combination of campaign, tier and manual discount can turn a sale into a payout. - buy-X-get-Y counts whole groups only, and prices the free unit at what is actually being charged — a line already carrying a manual discount must not refund more than it took. Kept out of Cart deliberately: Cart owns arithmetic that must never be wrong, this owns policy a shop changes weekly. Storage (schema v6, plus promos_json on orders at v7) - Campaigns persist locally, because a shop mid-promotion with a dead line still has to honour the price on the shelf edge. - A bill records the campaign name and the amount given, not a link to the row. A campaign edited or deleted later cannot change what a past sale shows, and a reprinted receipt still names what the shopper was given. - On read-back the promo amounts are subtracted from the manual discount, because bill_discount already contains them. Restoring both at full value would discount the bill twice — the same shape as the bug that used to overstate synced totals. At the till - Every cart mutation re-evaluates, so a promo cannot survive the line that earned it being removed. - A resumed parked bill is re-evaluated rather than restored: a campaign that has since ended must not be honoured because the bill was parked while it was running. - Campaigns are named individually on the billing panel and the printed receipt, so a shopper who came in for an advertised offer can see it applied. Editor - Full CRUD, admin-only, with validation for the cases that would save happily and then silently never fire — a targeted campaign with no target, a percentage over 100, an end date before the start. Tests: 199 -> 210. Covers each campaign type, the eligibility conditions, the stacking rules, the impossible-to-go-negative guarantee, GST recomputation against the reduced total, round-tripping, and the double-count guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
8.0 KiB
Dart
240 lines
8.0 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;
|
|
|
|
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();
|
|
|
|
_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,
|
|
}) 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;
|
|
}
|
|
}
|