diff --git a/docs/sync-contract.md b/docs/sync-contract.md index 23add7d..e2dd3bc 100644 --- a/docs/sync-contract.md +++ b/docs/sync-contract.md @@ -157,6 +157,70 @@ nothing is marked synced, and the batch goes again. response. Carries an `idempotency-key` header that is stable across retries of the same bills. +## Catalogue pull + +The other direction: products and customers coming down. + +``` +GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=… +Authorization: Bearer {apiKey} +``` + +```json +{ + "revision": "rev-8821", + "is_delta": true, + "has_more": false, + "products": [ { "id": "…", "name": "…", "barcode": "…", "price": 62.0, … } ], + "customers": [ { "id": "…", "name": "…", "mobile": "…", … } ], + "retired_product_ids": ["sku-9912"] +} +``` + +**Paged.** A supermarket catalogue is tens of thousands of rows; one response +times out on a shop's line and stalls the UI while it decodes. Answer +`has_more: true` and the terminal asks for the next page, up to 200 — past that +it stops rather than looping against the shop's connection. + +**`since` is the revision the terminal already holds.** Answer with what has +moved and set `is_delta: true`. On a normal morning that is a handful of price +changes rather than the whole book. A server that cannot do deltas ignores the +parameter and answers `is_delta: false`; the terminal reads the flag rather +than assuming, so both work. + +**The flag matters more than it looks.** A full snapshot withdraws every product +it does not mention. A delta must not — read as a snapshot, the first morning +price change would empty the shelf. Withdraw items in a delta with +`retired_product_ids`; the terminal marks them inactive rather than deleting, +because order lines already recorded point at them. + +**Send stock only when you mean it.** Any product in the payload has its count +overwritten by the server's figure, which predates sales this terminal has rung +but not yet uploaded. The terminal replays those sales — but only for products +the payload actually carried. A delta that ships a stale count for an untouched +product will quietly empty a shelf that is full. + +### Field handling + +| Field | Missing | Notes | +|---|---|---| +| `id`, `name`, `barcode`, `price` | **import fails** | A dropped product is a shelf item that scans to nothing | +| `sku` | falls back to `id` | | +| `stock` | `0` | Means "not tracked" | +| `gst_rate` | 18% | Accepts `18` or `0.18` — both read the same | +| `category` | Grocery | An unrecognised one also falls back; the item still sells | +| `unit` | piece | Matched by name or symbol | +| `is_active` | `true` | An omitted flag is not a withdrawn catalogue | + +Dates are ISO 8601 or epoch milliseconds; both are accepted. + +### Pushing a change mid-day + +Publish to `pos/{store}/catalogue` (retained) and every terminal in the shop +pulls immediately instead of waiting for tomorrow morning. The message body is +only a nudge — the catalogue itself still comes over HTTP, because a broker is +the wrong shape for tens of thousands of rows. + ## Retention on the terminal Accepted bills stay for 7 days (`OrderDao.retentionWindow`) so a batch the @@ -181,3 +245,7 @@ overstate the day. - **Historical correction.** Bills already synced by an older build went up with an overstated total. Nothing here fixes that; it needs a server-side reconciliation against `bill_discount`. +- **Pushing customers upward.** A shopper registered at the till stays on that + terminal and rides along on the bills they appear on. There is no + customer-create endpoint yet, so two terminals registering the same mobile + number will each hold their own row until the back office reconciles them. diff --git a/lib/app/providers.dart b/lib/app/providers.dart index 15fd7f2..b176c77 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -7,7 +7,9 @@ import '../core/services/sound_service.dart'; import '../data/datasources/local_store.dart'; import '../data/repositories/customer_repository_impl.dart'; import '../data/repositories/product_repository_impl.dart'; -import '../data/datasources/remote_catalogue_source.dart'; +import '../data/remote/catalogue_source.dart'; +import '../data/remote/http_catalogue_source.dart'; +import '../data/remote/simulated_catalogue_source.dart'; import '../data/remote/http_order_transport.dart'; import '../data/remote/mqtt_order_transport.dart'; import '../data/remote/order_transport.dart'; @@ -48,13 +50,31 @@ final transactionRepositoryProvider = Provider( /// purpose, which reads as a real network fault. final simulateOfflineProvider = StateProvider((ref) => false); -/// Simulated back-office endpoints. Held as singletons so the offline toggle -/// in Settings affects every call. -final remoteCatalogueProvider = Provider( - (ref) => RemoteCatalogueSource( - isOffline: () => ref.read(simulateOfflineProvider), - ), -); +/// Where products and customers come from. +/// +/// Rebuilt when the route changes, and the old one closed, so a re-pointed +/// terminal does not keep a stale client alive. +final catalogueSourceProvider = Provider((ref) { + final config = ref.watch(syncConfigProvider); + + final source = switch (config.transport) { + // MQTT carries the *notification* that the catalogue moved; the catalogue + // itself is a bulk pull, which is an HTTP job. A broker is the wrong shape + // for tens of thousands of rows. + TransportKind.http || TransportKind.mqtt => + config.httpBaseUrl.isEmpty + ? SimulatedCatalogueSource( + isOffline: () => ref.read(simulateOfflineProvider), + ) + : HttpCatalogueSource(config: config), + TransportKind.simulated => SimulatedCatalogueSource( + isOffline: () => ref.read(simulateOfflineProvider), + ), + }; + + ref.onDispose(source.dispose); + return source; +}); // ------------------------------------------------------------------- Sync /// How this terminal reaches the back office. @@ -102,7 +122,7 @@ final orderTransportProvider = Provider((ref) { final syncRepositoryProvider = Provider( (ref) => SyncRepositoryImpl( ref.watch(localStoreProvider), - ref.watch(remoteCatalogueProvider), + ref.watch(catalogueSourceProvider), ref.watch(orderTransportProvider), batchSize: ref.watch(syncConfigProvider).batchSize, ), diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 1b31408..99572bb 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -149,13 +149,41 @@ class LocalStore { required List customers, required String revision, required DateTime at, + bool isDelta = false, + List retiredProductIds = const [], }) async { - await catalogue.replaceCatalogue(products: products, customers: customers); + 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. - final committed = await orders.unsyncedStockCommitments(); + // + // 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); } diff --git a/lib/data/datasources/remote_catalogue_source.dart b/lib/data/datasources/remote_catalogue_source.dart deleted file mode 100644 index c6ff5d2..0000000 --- a/lib/data/datasources/remote_catalogue_source.dart +++ /dev/null @@ -1,86 +0,0 @@ -import '../../domain/entities/customer.dart'; -import '../../domain/entities/product.dart'; -import 'local_store.dart'; -import 'seed_data.dart'; - -/// What one catalogue pull returns. -class CatalogueSnapshot { - const CatalogueSnapshot({ - required this.products, - required this.customers, - required this.fetchedAt, - required this.revision, - }); - - final List products; - final List customers; - final DateTime fetchedAt; - - /// Server-side catalogue version, shown so the cashier can tell whether a - /// re-import actually changed anything. - final String revision; -} - -/// Raised when the catalogue cannot be pulled. -class CatalogueSyncException implements Exception { - const CatalogueSyncException(this.message); - - final String message; - - @override - String toString() => message; -} - -/// Stands in for the back-office catalogue API. -/// -/// The real implementation would issue an HTTP request; the contract is the -/// same, so only this class changes. -class RemoteCatalogueSource { - RemoteCatalogueSource({required this.isOffline}) { - LocalStore.registerSeed( - products: SeedData.products, - customers: SeedData.customers, - ); - } - - /// Reads the Settings switch on every call. - /// - /// Deliberately a callback rather than a stored bool: a copied flag can fall - /// out of step with the switch, which makes the terminal behave as offline - /// while showing that it is not. - final bool Function() isOffline; - - /// Streams progress so the import screen can show a real bar rather than an - /// indeterminate spinner. - Future fetch({ - void Function(double progress, String stage)? onProgress, - }) async { - const stages = [ - (0.15, 'Contacting server…'), - (0.35, 'Authorising terminal…'), - (0.60, 'Downloading products…'), - (0.85, 'Downloading customers…'), - (1.00, 'Writing to local storage…'), - ]; - - for (final (progress, stage) in stages) { - await Future.delayed(const Duration(milliseconds: 320)); - - if (isOffline()) { - throw const CatalogueSyncException( - 'Simulate offline is ON in Settings, so the catalogue pull was ' - 'failed on purpose. Turn it off to import.', - ); - } - - onProgress?.call(progress, stage); - } - - return CatalogueSnapshot( - products: SeedData.products(), - customers: SeedData.customers(), - fetchedAt: DateTime.now(), - revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}', - ); - } -} diff --git a/lib/data/local/catalogue_dao.dart b/lib/data/local/catalogue_dao.dart index 573e4b9..ed131d1 100644 --- a/lib/data/local/catalogue_dao.dart +++ b/lib/data/local/catalogue_dao.dart @@ -173,6 +173,60 @@ class CatalogueDao { }); } + /// Applies a change set, leaving everything it does not mention alone. + /// + /// The counterpart to [replaceCatalogue], and the difference matters: a full + /// snapshot withdraws anything it omits, a delta must not. Reading a delta as + /// if it were a snapshot would empty the shelf on the first morning price + /// change. + /// + /// Stock is deliberately *not* taken from a delta unless the back office + /// sends it. A price change that carried a stale count would undo every sale + /// the terminal has rung since the last pull. + Future applyCatalogueDelta({ + required List products, + required List customers, + List retiredProductIds = const [], + }) async { + await _db.transaction((txn) async { + final batch = txn.batch(); + + for (final id in retiredProductIds) { + // Withdrawn rather than deleted: an order line already recorded points + // at this product, and a hard delete would orphan a bill's history. + batch.update( + Tables.products, + { + 'is_active': 0, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + for (final p in products) { + batch.insert( + Tables.products, + productToRow(p), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + // As in a full pull: locally registered shoppers must survive, so an + // existing row is left alone rather than overwritten. + for (final c in customers) { + batch.insert( + Tables.customers, + customerToRow(c), + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + + await batch.commit(noResult: true); + }); + } + /// Applies stock movement after a sale, clamped at zero. Future decrementStock(Map quantities) async { if (quantities.isEmpty) return; diff --git a/lib/data/remote/catalogue_source.dart b/lib/data/remote/catalogue_source.dart new file mode 100644 index 0000000..7e372ae --- /dev/null +++ b/lib/data/remote/catalogue_source.dart @@ -0,0 +1,77 @@ +import '../../domain/entities/customer.dart'; +import '../../domain/entities/product.dart'; + +/// What one catalogue pull returned. +class CatalogueSnapshot { + const CatalogueSnapshot({ + required this.products, + required this.customers, + required this.fetchedAt, + required this.revision, + this.isDelta = false, + this.retiredProductIds = const [], + }); + + final List products; + final List customers; + final DateTime fetchedAt; + + /// Server-side catalogue version, so the cashier can tell whether a + /// re-import actually changed anything, and so the next pull can ask for + /// only what has moved since. + final String revision; + + /// Whether this is a change set rather than the whole catalogue. + /// + /// The distinction matters at the point of applying it: a full snapshot + /// withdraws anything it does not mention, a delta must not. + final bool isDelta; + + /// Products the back office has withdrawn. Only meaningful on a delta — + /// a full snapshot expresses the same thing by omission. + final List retiredProductIds; + + bool get isEmpty => + products.isEmpty && customers.isEmpty && retiredProductIds.isEmpty; + + int get changeCount => + products.length + customers.length + retiredProductIds.length; +} + +/// Raised when the catalogue cannot be pulled. +class CatalogueSyncException implements Exception { + const CatalogueSyncException(this.message, {this.retryable = true}); + + final String message; + + /// False for a bad credential or an unconfigured endpoint — retrying cannot + /// fix either, and the cashier needs to be told rather than watched to spin. + final bool retryable; + + @override + String toString() => message; +} + +/// Where the terminal gets its products and customers. +/// +/// The counterpart to `OrderTransport`: that one is how bills leave, this is +/// how the catalogue arrives. Same shape, for the same reason — the repository +/// should not know or care whether the answer came from a real endpoint or a +/// local stub. +abstract class CatalogueSource { + /// Shown in the events log, so a cashier reporting a problem can say which + /// route the terminal was using. + String get label; + + /// Pulls the catalogue. + /// + /// Passing [since] asks for only what has changed, and an implementation + /// that cannot do deltas is free to ignore it and return everything — the + /// caller checks [CatalogueSnapshot.isDelta] rather than assuming. + Future fetch({ + String? since, + void Function(double progress, String stage)? onProgress, + }); + + void dispose() {} +} diff --git a/lib/data/remote/catalogue_wire.dart b/lib/data/remote/catalogue_wire.dart new file mode 100644 index 0000000..c5ebca6 --- /dev/null +++ b/lib/data/remote/catalogue_wire.dart @@ -0,0 +1,178 @@ +import '../../core/constants/app_constants.dart'; +import '../../domain/entities/customer.dart'; +import '../../domain/entities/product.dart'; + +/// Raised when the back office sends something this build cannot read. +class CatalogueFormatException implements Exception { + const CatalogueFormatException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Translates between the back office's JSON and the terminal's entities. +/// +/// Deliberately tolerant in one direction and strict in the other. A missing +/// optional field takes a sensible default, because a catalogue of 4,000 +/// products should not fail to import over one absent emoji. A missing +/// *required* field throws, because a product with no price or no barcode +/// cannot be sold and silently dropping it would leave a shelf item that +/// scans to nothing. +class CatalogueWire { + const CatalogueWire._(); + + // ------------------------------------------------------------- Products + static Product productFromJson(Map json) { + final id = _requireString(json, 'id'); + + return Product( + id: id, + name: _requireString(json, 'name', context: id), + barcode: _requireString(json, 'barcode', context: id), + sku: (json['sku'] as String?) ?? id, + category: _category(json['category']), + price: _requireNumber(json, 'price', context: id), + // Absent stock means "not tracked", not "none left" — a terminal that + // read it as zero would refuse to sell the item at all. + stock: (json['stock'] as num?)?.toDouble() ?? 0, + mrp: (json['mrp'] as num?)?.toDouble(), + emoji: (json['emoji'] as String?) ?? '📦', + imageUrl: json['image_url'] as String?, + unit: _unit(json['unit']), + gstRate: _gstRate(json['gst_rate']), + hsnCode: json['hsn_code'] as String?, + brand: json['brand'] as String?, + // Absent means active. A back office that omits the flag is not saying + // its whole catalogue is withdrawn. + isActive: json['is_active'] as bool? ?? true, + ); + } + + static Map productToJson(Product p) => { + 'id': p.id, + 'name': p.name, + 'barcode': p.barcode, + 'sku': p.sku, + 'category': p.category.name, + 'price': p.price, + 'mrp': p.mrp, + 'stock': p.stock, + 'emoji': p.emoji, + 'image_url': p.imageUrl, + 'unit': p.unit.name, + 'gst_rate': p.gstRate, + 'hsn_code': p.hsnCode, + 'brand': p.brand, + 'is_active': p.isActive, + }; + + // ------------------------------------------------------------ Customers + static Customer customerFromJson(Map json) { + final id = _requireString(json, 'id'); + + return Customer( + id: id, + name: _requireString(json, 'name', context: id), + mobile: _requireString(json, 'mobile', context: id), + email: json['email'] as String?, + gender: Gender.values + .where((g) => g.name == json['gender']) + .firstOrNull ?? + Gender.unspecified, + dateOfBirth: _date(json['date_of_birth']), + loyaltyPoints: (json['loyalty_points'] as num?)?.toInt() ?? 0, + lifetimeSpend: (json['lifetime_spend'] as num?)?.toDouble() ?? 0, + visitCount: (json['visit_count'] as num?)?.toInt() ?? 0, + createdAt: _date(json['created_at']), + lastVisitAt: _date(json['last_visit_at']), + ); + } + + static Map customerToJson(Customer c) => { + 'id': c.id, + 'name': c.name, + 'mobile': c.mobile, + 'email': c.email, + 'gender': c.gender.name, + 'date_of_birth': c.dateOfBirth?.toIso8601String(), + 'loyalty_points': c.loyaltyPoints, + 'lifetime_spend': c.lifetimeSpend, + 'visit_count': c.visitCount, + 'created_at': c.createdAt?.toIso8601String(), + 'last_visit_at': c.lastVisitAt?.toIso8601String(), + }; + + // ------------------------------------------------------------ Internals + static String _requireString( + Map json, + String key, { + String? context, + }) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + + throw CatalogueFormatException( + 'Product or customer${context == null ? '' : ' $context'} has no "$key". ' + 'The terminal cannot sell or identify a record without it.', + ); + } + + static double _requireNumber( + Map json, + String key, { + String? context, + }) { + final value = json[key]; + if (value is num) return value.toDouble(); + + throw CatalogueFormatException( + 'Product${context == null ? '' : ' $context'} has no numeric "$key".', + ); + } + + /// Falls back rather than throwing. + /// + /// A category the terminal does not recognise is a display problem — the + /// item still scans, still prices, still bills. Refusing the whole import + /// over one would be a far worse outcome than filing it under Grocery. + static ProductCategory _category(Object? raw) { + if (raw is! String) return ProductCategory.grocery; + final needle = raw.trim().toLowerCase(); + + return ProductCategory.values.firstWhere( + (c) => c.name.toLowerCase() == needle || c.label.toLowerCase() == needle, + orElse: () => ProductCategory.grocery, + ); + } + + static UnitOfMeasure _unit(Object? raw) { + if (raw is! String) return UnitOfMeasure.piece; + final needle = raw.trim().toLowerCase(); + + return UnitOfMeasure.values.firstWhere( + (u) => + u.name.toLowerCase() == needle || + u.symbol.toLowerCase() == needle, + orElse: () => UnitOfMeasure.piece, + ); + } + + /// Accepts a fraction (0.18) or a percentage (18), because back offices + /// disagree about which they mean and getting it wrong silently changes the + /// tax on every line. + static double _gstRate(Object? raw) { + if (raw is! num) return AppConstants.defaultGstRate; + final value = raw.toDouble(); + if (value < 0) return AppConstants.defaultGstRate; + return value > 1 ? value / 100 : value; + } + + static DateTime? _date(Object? raw) { + if (raw == null) return null; + if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt()); + if (raw is String) return DateTime.tryParse(raw); + return null; + } +} diff --git a/lib/data/remote/http_catalogue_source.dart b/lib/data/remote/http_catalogue_source.dart new file mode 100644 index 0000000..4618934 --- /dev/null +++ b/lib/data/remote/http_catalogue_source.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../core/config/sync_config.dart'; +import '../../domain/entities/customer.dart'; +import '../../domain/entities/product.dart'; +import 'catalogue_source.dart'; +import 'catalogue_wire.dart'; + +/// Pulls the catalogue from the back office over HTTP. +/// +/// ``` +/// GET {base}/catalogue?since={revision}&page={n} +/// Authorization: Bearer {apiKey} +/// ``` +/// +/// ```json +/// { +/// "revision": "rev-8821", +/// "is_delta": true, +/// "has_more": false, +/// "products": [ … ], +/// "customers": [ … ], +/// "retired_product_ids": ["sku-9912"] +/// } +/// ``` +/// +/// ### Why paged +/// +/// A supermarket catalogue is tens of thousands of rows. Asking for it in one +/// response means a request that times out on a shop's line and a JSON decode +/// that stalls the UI thread for seconds. Pages arrive, get counted, and the +/// progress bar moves — which is also the difference between a cashier waiting +/// and a cashier restarting the app. +/// +/// ### Why deltas +/// +/// `since` carries the revision the terminal already holds. The back office +/// answers with what has moved since, which on a normal morning is a handful of +/// price changes rather than the whole book. A server that cannot do deltas +/// ignores the parameter and answers `is_delta: false`; the terminal reads the +/// flag rather than assuming, so both work. +class HttpCatalogueSource implements CatalogueSource { + HttpCatalogueSource({required this.config, http.Client? client}) + : _client = client ?? http.Client(); + + final SyncConfig config; + final http.Client _client; + + /// Guards against a server that always answers `has_more: true`. Without it + /// a bad deployment turns into an infinite request loop against a shop's + /// connection. + static const int maxPages = 200; + + static const Duration _timeout = Duration(seconds: 30); + + @override + String get label => 'HTTP ${config.httpBaseUrl}'; + + @override + Future fetch({ + String? since, + void Function(double progress, String stage)? onProgress, + }) async { + if (config.httpBaseUrl.isEmpty) { + throw const CatalogueSyncException( + 'No back-office URL is configured for this terminal. Set one in ' + 'Settings → Connectivity → Configure.', + retryable: false, + ); + } + + final products = []; + final customers = []; + final retired = []; + + var revision = since ?? ''; + var isDelta = false; + var page = 1; + + onProgress?.call(0.05, 'Contacting the back office…'); + + while (page <= maxPages) { + final body = await _fetchPage(since: since, page: page); + + revision = (body['revision'] as String?) ?? revision; + isDelta = body['is_delta'] as bool? ?? false; + + products.addAll( + _decodeList(body['products'], CatalogueWire.productFromJson), + ); + customers.addAll( + _decodeList(body['customers'], CatalogueWire.customerFromJson), + ); + retired.addAll( + (body['retired_product_ids'] as List? ?? const []) + .whereType(), + ); + + final hasMore = body['has_more'] as bool? ?? false; + if (!hasMore) break; + + page++; + // The total is unknown until the last page, so this walks towards 0.9 + // instead of pretending to know how far along it is. + onProgress?.call( + (0.1 + page * 0.05).clamp(0.1, 0.9), + '${products.length} products…', + ); + } + + if (page > maxPages) { + throw const CatalogueSyncException( + 'The back office kept asking for another page past $maxPages. ' + 'Stopping rather than looping — nothing was changed on this terminal.', + ); + } + + onProgress?.call(1, 'Writing to local storage…'); + + return CatalogueSnapshot( + products: products, + customers: customers, + fetchedAt: DateTime.now(), + revision: revision.isEmpty ? 'rev-unknown' : revision, + isDelta: isDelta, + retiredProductIds: retired, + ); + } + + Future> _fetchPage({ + required String? since, + required int page, + }) async { + final uri = Uri.parse('${config.httpBaseUrl}/catalogue').replace( + queryParameters: { + if (since != null && since.isNotEmpty) 'since': since, + 'page': '$page', + 'store_id': config.storeId, + 'terminal_id': config.terminalId, + }, + ); + + http.Response response; + try { + response = await _client.get( + uri, + headers: { + 'accept': 'application/json', + if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}', + }, + ).timeout(_timeout); + } on Exception catch (e) { + throw CatalogueSyncException('Could not reach the back office: $e'); + } + + if (response.statusCode == 401 || response.statusCode == 403) { + throw CatalogueSyncException( + 'The back office rejected this terminal\'s credentials ' + '(${response.statusCode}). The catalogue already on this terminal is ' + 'untouched, so billing continues.', + retryable: false, + ); + } + + if (response.statusCode >= 300) { + throw CatalogueSyncException( + 'Back office returned ${response.statusCode}: ${_trim(response.body)}', + ); + } + + try { + return jsonDecode(response.body) as Map; + } on Object { + throw CatalogueSyncException( + 'The back office answered with something this terminal could not ' + 'read: ${_trim(response.body)}', + ); + } + } + + /// Decodes a list, letting one bad record fail the import rather than + /// silently vanish. + /// + /// A dropped product is a shelf item that scans to nothing, which a cashier + /// discovers with a queue waiting. Better to refuse the import and leave the + /// working catalogue in place. + static List _decodeList( + Object? raw, + T Function(Map) decode, + ) { + if (raw is! List) return const []; + return raw + .whereType>() + .map(decode) + .toList(growable: false); + } + + static String _trim(String body) => + body.length <= 200 ? body : '${body.substring(0, 200)}…'; + + @override + void dispose() => _client.close(); +} diff --git a/lib/data/remote/simulated_catalogue_source.dart b/lib/data/remote/simulated_catalogue_source.dart new file mode 100644 index 0000000..7262dae --- /dev/null +++ b/lib/data/remote/simulated_catalogue_source.dart @@ -0,0 +1,55 @@ +import '../datasources/seed_data.dart'; +import 'catalogue_source.dart'; + +/// Stands in for the back office when no endpoint is configured. +/// +/// Keeps a fresh install demonstrable: products exist, a shift can be rehearsed +/// end to end, and the offline switch in Settings fails the pull exactly the +/// way a dead line would. +class SimulatedCatalogueSource implements CatalogueSource { + const SimulatedCatalogueSource({required this.isOffline}); + + /// Read on every call rather than copied, so the Settings switch takes effect + /// immediately instead of at the next restart. + final bool Function() isOffline; + + @override + String get label => 'Simulated'; + + @override + Future fetch({ + String? since, + void Function(double progress, String stage)? onProgress, + }) async { + const stages = [ + (0.15, 'Contacting server…'), + (0.35, 'Authorising terminal…'), + (0.60, 'Downloading products…'), + (0.85, 'Downloading customers…'), + (1.00, 'Writing to local storage…'), + ]; + + for (final (progress, stage) in stages) { + await Future.delayed(const Duration(milliseconds: 320)); + + if (isOffline()) { + throw const CatalogueSyncException( + 'Simulate offline is ON in Settings, so the catalogue pull was ' + 'failed on purpose. Turn it off to import.', + ); + } + + onProgress?.call(progress, stage); + } + + return CatalogueSnapshot( + products: SeedData.products(), + customers: SeedData.customers(), + fetchedAt: DateTime.now(), + revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}', + ); + } + + @override + void dispose() {} +} diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index 0a19da9..8259f7f 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -8,8 +8,8 @@ import '../../domain/entities/sync_event.dart'; import '../../domain/entities/transaction.dart'; import '../../domain/repositories/sync_repository.dart'; import '../datasources/local_store.dart'; -import '../datasources/remote_catalogue_source.dart'; import '../local/order_dao.dart'; +import '../remote/catalogue_source.dart'; import '../remote/order_transport.dart'; class SyncRepositoryImpl implements SyncRepository { @@ -21,7 +21,7 @@ class SyncRepositoryImpl implements SyncRepository { }); final LocalStore _store; - final RemoteCatalogueSource _catalogue; + final CatalogueSource _catalogue; final OrderTransport _transport; /// Bills per publish. Kept modest because an MQTT broker will refuse an @@ -57,13 +57,21 @@ class SyncRepositoryImpl implements SyncRepository { final started = DateTime.now(); try { - final snapshot = await _catalogue.fetch(onProgress: onProgress); + // Carries the revision this terminal already holds, so the back office + // can answer with just what has moved. On a normal morning that is a + // handful of price changes rather than the whole book. + final snapshot = await _catalogue.fetch( + since: _store.catalogueRevision, + onProgress: onProgress, + ); await _store.importCatalogue( products: snapshot.products, customers: snapshot.customers, revision: snapshot.revision, at: snapshot.fetchedAt, + isDelta: snapshot.isDelta, + retiredProductIds: snapshot.retiredProductIds, ); final event = SyncEvent( @@ -72,12 +80,20 @@ class SyncRepositoryImpl implements SyncRepository { status: SyncStatus.synced, createdAt: started, syncedAt: DateTime.now(), - summary: '${snapshot.products.length} products saved to SQLite ' - '· ${snapshot.revision}', + summary: snapshot.isDelta + ? (snapshot.isEmpty + ? 'Already up to date · ${snapshot.revision}' + : '${snapshot.changeCount} changes applied ' + '· ${snapshot.revision}') + : '${snapshot.products.length} products saved to SQLite ' + '· ${snapshot.revision}', payload: { 'products': snapshot.products.length, 'customers': snapshot.customers.length, + 'retired': snapshot.retiredProductIds.length, + 'delta': snapshot.isDelta, 'revision': snapshot.revision, + 'via': _catalogue.label, }, attempts: 1, ); diff --git a/test/unit/catalogue_sync_test.dart b/test/unit/catalogue_sync_test.dart new file mode 100644 index 0000000..be54c39 --- /dev/null +++ b/test/unit/catalogue_sync_test.dart @@ -0,0 +1,533 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:nearle_pos/core/config/sync_config.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/data/local/app_database.dart'; +import 'package:nearle_pos/data/remote/catalogue_source.dart'; +import 'package:nearle_pos/data/remote/catalogue_wire.dart'; +import 'package:nearle_pos/data/remote/http_catalogue_source.dart'; +import 'package:nearle_pos/data/repositories/product_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/customer_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart'; +import 'package:nearle_pos/domain/entities/cart.dart'; +import 'package:nearle_pos/domain/entities/customer.dart'; +import 'package:nearle_pos/domain/entities/product.dart'; +import 'package:nearle_pos/domain/entities/transaction.dart'; +import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; + +const _config = SyncConfig( + transport: TransportKind.http, + httpBaseUrl: 'https://api.example.com', + apiKey: 'k', + storeId: 'store-01', + terminalId: 'T4A9', +); + +Map _product(String id, {double price = 50}) => { + 'id': id, + 'name': 'Product $id', + 'barcode': 'bc-$id', + 'sku': 'sku-$id', + 'category': 'grocery', + 'price': price, + 'stock': 20, + 'gst_rate': 0.05, + }; + +void main() { + group('wire format', () { + test('a full record round-trips', () { + final product = CatalogueWire.productFromJson({ + ..._product('p1'), + 'mrp': 60.0, + 'emoji': '🍞', + 'unit': 'kilogram', + 'hsn_code': '1905', + 'brand': 'Nearle', + 'is_active': false, + }); + + expect(product.id, 'p1'); + expect(product.mrp, 60); + expect(product.unit, UnitOfMeasure.kilogram); + expect(product.hsnCode, '1905'); + expect(product.isActive, isFalse); + + final json = CatalogueWire.productToJson(product); + expect(CatalogueWire.productFromJson(json), product); + }); + + test('missing optional fields take sensible defaults', () { + // A catalogue of 4,000 products must not fail to import over one absent + // emoji. + final product = CatalogueWire.productFromJson({ + 'id': 'p1', + 'name': 'Bread', + 'barcode': '890', + 'price': 40.0, + }); + + expect(product.sku, 'p1', reason: 'falls back to the id'); + expect(product.emoji, '📦'); + expect(product.unit, UnitOfMeasure.piece); + expect(product.isActive, isTrue, + reason: 'an omitted flag is not a withdrawn catalogue',); + }); + + test('a record with no price or barcode is refused, not dropped', () { + // A silently dropped product is a shelf item that scans to nothing, + // discovered with a queue waiting. + expect( + () => CatalogueWire.productFromJson({'id': 'p1', 'name': 'X'}), + throwsA(isA()), + ); + expect( + () => CatalogueWire.productFromJson({ + 'id': 'p1', + 'name': 'X', + 'barcode': '890', + }), + throwsA(isA()), + ); + }); + + test('a GST rate is read the same whether sent as 18 or 0.18', () { + // Back offices disagree about which they mean, and getting it wrong + // silently changes the tax on every line. + double rate(Object? raw) => CatalogueWire.productFromJson({ + ..._product('p1'), + 'gst_rate': raw, + }).gstRate; + + expect(rate(18), 0.18); + expect(rate(0.18), 0.18); + expect(rate(5), 0.05); + expect(rate(0), 0); + }); + + test('an unknown category files under Grocery rather than failing', () { + // The item still scans, still prices, still bills. Refusing the whole + // import over a display detail would be far worse. + final product = CatalogueWire.productFromJson({ + ..._product('p1'), + 'category': 'frozen-desserts', + }); + + expect(product.category, ProductCategory.grocery); + }); + + test('a category is matched by name or by label', () { + ProductCategory of(String raw) => CatalogueWire.productFromJson({ + ..._product('p1'), + 'category': raw, + }).category; + + expect(of('personalCare'), ProductCategory.personalCare); + expect(of('Personal Care'), ProductCategory.personalCare); + expect(of('BEVERAGES'), ProductCategory.beverages); + }); + + test('customers carry their loyalty balance across', () { + final customer = CatalogueWire.customerFromJson({ + 'id': 'c1', + 'name': 'Meena', + 'mobile': '9840000001', + 'loyalty_points': 420, + 'lifetime_spend': 61000.0, + 'last_visit_at': '2026-07-30T10:00:00.000Z', + }); + + expect(customer.loyaltyPoints, 420); + expect(customer.tier, MembershipTier.gold); + expect(customer.lastVisitAt, isNotNull); + }); + + test('a date is accepted as ISO text or epoch milliseconds', () { + DateTime? born(Object? raw) => CatalogueWire.customerFromJson({ + 'id': 'c1', + 'name': 'M', + 'mobile': '98', + 'date_of_birth': raw, + }).dateOfBirth; + + expect(born('1990-05-02'), DateTime(1990, 5, 2)); + expect(born(641606400000), isNotNull); + expect(born('not a date'), isNull); + expect(born(null), isNull); + }); + }); + + group('http source', () { + HttpCatalogueSource sourceReturning( + List> pages, { + List? record, + }) { + var index = 0; + return HttpCatalogueSource( + config: _config, + client: _FakeClient((request) { + record?.add(request.url); + final body = pages[index < pages.length ? index : pages.length - 1]; + index++; + return http.Response(jsonEncode(body), 200); + }), + ); + } + + test('a single page is read straight through', () async { + final source = sourceReturning([ + { + 'revision': 'rev-1', + 'is_delta': false, + 'has_more': false, + 'products': [_product('p1'), _product('p2')], + 'customers': [ + {'id': 'c1', 'name': 'Meena', 'mobile': '9840000001'}, + ], + }, + ]); + + final snapshot = await source.fetch(); + + expect(snapshot.products, hasLength(2)); + expect(snapshot.customers, hasLength(1)); + expect(snapshot.revision, 'rev-1'); + expect(snapshot.isDelta, isFalse); + }); + + test('every page is followed until has_more stops', () async { + // A supermarket catalogue is tens of thousands of rows; a single response + // times out on a shop's line. + final source = sourceReturning([ + { + 'revision': 'rev-2', + 'has_more': true, + 'products': [_product('p1')], + }, + { + 'revision': 'rev-2', + 'has_more': true, + 'products': [_product('p2')], + }, + { + 'revision': 'rev-2', + 'has_more': false, + 'products': [_product('p3')], + }, + ]); + + final snapshot = await source.fetch(); + + expect(snapshot.products.map((p) => p.id), ['p1', 'p2', 'p3']); + }); + + test('the revision already held is sent as `since`', () async { + final urls = []; + final source = sourceReturning( + [ + {'revision': 'rev-9', 'is_delta': true, 'has_more': false}, + ], + record: urls, + ); + + await source.fetch(since: 'rev-8'); + + expect(urls.single.queryParameters['since'], 'rev-8'); + expect(urls.single.queryParameters['terminal_id'], 'T4A9'); + }); + + test('a delta is flagged as one, and carries its withdrawals', () async { + final source = sourceReturning([ + { + 'revision': 'rev-9', + 'is_delta': true, + 'has_more': false, + 'products': [_product('p1', price: 55)], + 'retired_product_ids': ['p7', 'p8'], + }, + ]); + + final snapshot = await source.fetch(since: 'rev-8'); + + expect(snapshot.isDelta, isTrue); + expect(snapshot.retiredProductIds, ['p7', 'p8']); + expect(snapshot.changeCount, 3); + }); + + test('an empty delta means already up to date', () async { + final source = sourceReturning([ + {'revision': 'rev-8', 'is_delta': true, 'has_more': false}, + ]); + + final snapshot = await source.fetch(since: 'rev-8'); + expect(snapshot.isEmpty, isTrue); + }); + + test('a bad credential is not retryable', () async { + final source = HttpCatalogueSource( + config: _config, + client: _FakeClient((_) => http.Response('nope', 401)), + ); + + await expectLater( + source.fetch(), + throwsA(isA() + .having((e) => e.retryable, 'retryable', isFalse),), + ); + }); + + test('a server error is retryable', () async { + final source = HttpCatalogueSource( + config: _config, + client: _FakeClient((_) => http.Response('boom', 503)), + ); + + await expectLater( + source.fetch(), + throwsA(isA() + .having((e) => e.retryable, 'retryable', isTrue),), + ); + }); + + test('an unconfigured endpoint fails fast', () async { + final source = HttpCatalogueSource( + config: const SyncConfig(transport: TransportKind.http), + client: _FakeClient((_) => http.Response('{}', 200)), + ); + + await expectLater( + source.fetch(), + throwsA(isA() + .having((e) => e.retryable, 'retryable', isFalse),), + ); + }); + + test('a server that never stops paging is cut off', () async { + // A bad deployment must not become an infinite request loop against a + // shop's connection. + var calls = 0; + final source = HttpCatalogueSource( + config: _config, + client: _FakeClient((_) { + calls++; + return http.Response( + jsonEncode({'revision': 'r', 'has_more': true, 'products': []}), + 200, + ); + }), + ); + + await expectLater(source.fetch(), throwsA(isA())); + expect(calls, lessThanOrEqualTo(HttpCatalogueSource.maxPages + 1)); + }); + + test('one malformed product fails the import rather than vanishing', + () async { + final source = sourceReturning([ + { + 'revision': 'rev-1', + 'has_more': false, + 'products': [ + _product('p1'), + {'id': 'p2', 'name': 'No barcode'}, + ], + }, + ]); + + await expectLater( + source.fetch(), + throwsA(isA()), + ); + }); + }); + + group('applying to the terminal', () { + late LocalStore store; + + setUpAll(() { + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + store = LocalStore.instance; + await store.reset(withCatalogue: true); + }); + + test('a delta leaves everything it does not mention alone', () async { + // Applied as a full snapshot, the first morning price change would empty + // the shelf. + final before = store.products.length; + final milk = store.products.firstWhere((p) => p.barcode == '8901234500011'); + + await store.importCatalogue( + products: [milk.copyWith(price: 71)], + customers: const [], + revision: 'rev-2', + at: DateTime.now(), + isDelta: true, + ); + + expect(store.products, hasLength(before)); + expect(store.productById(milk.id)!.price, 71); + }); + + test('a full snapshot withdraws what it omits', () async { + final milk = store.products.firstWhere((p) => p.barcode == '8901234500011'); + + await store.importCatalogue( + products: [milk], + customers: const [], + revision: 'rev-3', + at: DateTime.now(), + ); + + expect(store.products, hasLength(1)); + }); + + test('a retired product is withdrawn, not deleted', () async { + // An order line already recorded points at it; a hard delete would orphan + // a bill's history. + final milk = store.products.firstWhere((p) => p.barcode == '8901234500011'); + + await store.importCatalogue( + products: const [], + customers: const [], + revision: 'rev-4', + at: DateTime.now(), + isDelta: true, + retiredProductIds: [milk.id], + ); + + expect(store.productByBarcode('8901234500011'), isNull, + reason: 'a withdrawn product must not scan',); + + // The cache holds only what is sellable, so the surviving row has to be + // checked on disk — which is the point: an order line already recorded + // still resolves to a real product. + final rows = await AppDatabase.instance.db.query( + 'products', + where: 'id = ?', + whereArgs: [milk.id], + ); + expect(rows, hasLength(1)); + expect(rows.single['is_active'], 0); + }); + + test('a locally added shopper survives a pull', () async { + final customers = CustomerRepositoryImpl(store); + final walkIn = await customers.create(const Customer( + id: 'ignored', + name: 'Local Only', + mobile: '9000000077', + ),); + + await store.importCatalogue( + products: const [], + customers: const [], + revision: 'rev-5', + at: DateTime.now(), + isDelta: true, + ); + + expect(await customers.findById(walkIn.id), isNotNull); + }); + + test('a delta does not subtract already-sold stock a second time', + () async { + // The replay exists because a *full* pull overwrites stock with a server + // figure that predates local sales. Running it over a delta that never + // carried that product would quietly empty a shelf that is full. + final products = ProductRepositoryImpl(store); + final checkout = CheckoutSale( + productRepository: products, + customerRepository: CustomerRepositoryImpl(store), + transactionRepository: TransactionRepositoryImpl(store), + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final opening = milk.stock; + + await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: 4)]), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250), + ], + cashierName: 'Divya', + terminalId: 'T0TEST', + ); + + expect((await products.findById(milk.id))!.stock, opening - 4); + + // A delta about a *different* product must not touch the milk count. + final other = store.products.firstWhere((p) => p.id != milk.id); + await store.importCatalogue( + products: [other.copyWith(price: other.price + 1)], + customers: const [], + revision: 'rev-6', + at: DateTime.now(), + isDelta: true, + ); + + expect((await products.findById(milk.id))!.stock, opening - 4, + reason: 'the sold units must not be subtracted again',); + }); + + test('a delta carrying the sold product replays its committed stock', + () async { + // The other half of the same rule: when the server *does* send a fresh + // count for a product, that figure predates the local sale and the units + // would otherwise reappear on the shelf. + final products = ProductRepositoryImpl(store); + final checkout = CheckoutSale( + productRepository: products, + customerRepository: CustomerRepositoryImpl(store), + transactionRepository: TransactionRepositoryImpl(store), + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final opening = milk.stock; + + await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: 4)]), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250), + ], + cashierName: 'Divya', + terminalId: 'T0TEST', + ); + + await store.importCatalogue( + products: [milk], + customers: const [], + revision: 'rev-7', + at: DateTime.now(), + isDelta: true, + ); + + expect((await products.findById(milk.id))!.stock, opening - 4, + reason: 'the server count is stale by exactly the unsynced sales',); + }); + }); +} + +class _FakeClient extends http.BaseClient { + _FakeClient(this.respond); + + final http.Response Function(http.BaseRequest) respond; + + @override + Future send(http.BaseRequest request) async { + final response = respond(request); + return http.StreamedResponse( + Stream.value(utf8.encode(response.body)), + response.statusCode, + request: request, + ); + } +} diff --git a/test/unit/persistence_test.dart b/test/unit/persistence_test.dart index 4561f6b..e49d442 100644 --- a/test/unit/persistence_test.dart +++ b/test/unit/persistence_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; -import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart'; +import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/remote/simulated_order_transport.dart'; import 'package:nearle_pos/data/repositories/customer_repository_impl.dart'; @@ -276,7 +276,7 @@ void main() { test('the archived day totals match what was charged', () async { final sync = SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => false), + SimulatedCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); @@ -321,7 +321,7 @@ void main() { () async { final sync = SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => false), + SimulatedCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); @@ -351,7 +351,7 @@ void main() { test('a failed import is recorded with its error', () async { final sync = SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => true), + SimulatedCatalogueSource(isOffline: () => true), SimulatedOrderTransport(isOffline: () => true), ); @@ -380,7 +380,7 @@ void main() { test('a cashier settles their own till, not the terminal', () async { final sync = SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => false), + SimulatedCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); @@ -406,7 +406,7 @@ void main() { test('scoping holds after the bills are uploaded and deleted', () async { final sync = SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => false), + SimulatedCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); diff --git a/test/unit/retention_test.dart b/test/unit/retention_test.dart index 06527d1..f9c16b9 100644 --- a/test/unit/retention_test.dart +++ b/test/unit/retention_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; -import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart'; +import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/local/app_database.dart'; import 'package:nearle_pos/data/local/order_dao.dart'; @@ -84,7 +84,7 @@ void main() { SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => false), + SimulatedCatalogueSource(isOffline: () => false), transport, ); @@ -342,7 +342,7 @@ void main() { final sync = SyncRepositoryImpl( store, - RemoteCatalogueSource(isOffline: () => false), + SimulatedCatalogueSource(isOffline: () => false), transport, batchSize: 2, );