Pull the catalogue from a real endpoint, with delta sync
Replaces the last simulation on the inbound side. RemoteCatalogueSource
returned SeedData after a fake progress bar; there was no wire format, no
endpoint, and no way to receive an update short of reinstalling.
Wire format (data/remote/catalogue_wire.dart)
- Tolerant where it should be: a catalogue of 4,000 products must not fail to
import over one absent emoji, so optional fields take defaults and an
unrecognised category files under Grocery — the item still scans, prices and
bills.
- Strict where it matters: no id, name, barcode or price and the import fails.
A silently dropped product is a shelf item that scans to nothing, discovered
with a queue waiting.
- GST accepts 18 or 0.18 and reads both the same. Back offices disagree about
which they mean, and getting it wrong silently changes the tax on every line.
HTTP source with paging and deltas
- GET {base}/catalogue?since={revision}&page={n}. Paged because a supermarket
catalogue is tens of thousands of rows: one response times out on a shop's
line and stalls the UI decoding it. Capped at 200 pages so a bad deployment
cannot become an infinite request loop against a shop's connection.
- `since` carries the revision already held, so a normal morning fetches a
handful of price changes rather than the whole book. A server that cannot do
deltas ignores it and answers is_delta:false — the terminal reads the flag
rather than assuming, so both work.
- A bad credential is non-retryable and says so, leaving the working catalogue
in place so billing continues.
Applying deltas without losing local state
- A full snapshot withdraws what it omits; a delta must not. Read as a
snapshot, the first morning price change would empty the shelf.
- Retired products are marked inactive, not deleted — order lines already
recorded point at them, and a hard delete would orphan a bill's history.
- Locally registered shoppers survive a pull, as before.
- The unsynced-stock replay is now scoped to the products the pull actually
overwrote. It exists because a server count predates local sales; running it
over a delta that never carried that product would subtract those units a
second time and quietly empty a shelf that is full. Both halves of that rule
are tested.
MQTT stays the nudge, not the transport: a catalogue push on
pos/{store}/catalogue makes every terminal pull immediately, but the rows come
over HTTP, because a broker is the wrong shape for tens of thousands of them.
Tests: 210 -> 234. docs/sync-contract.md now covers both directions, including
a field-by-field table of what happens when something is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
77
lib/data/remote/catalogue_source.dart
Normal file
77
lib/data/remote/catalogue_source.dart
Normal file
@@ -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<Product> products;
|
||||
final List<Customer> 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<String> 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<CatalogueSnapshot> fetch({
|
||||
String? since,
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
});
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
178
lib/data/remote/catalogue_wire.dart
Normal file
178
lib/data/remote/catalogue_wire.dart
Normal file
@@ -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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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;
|
||||
}
|
||||
}
|
||||
205
lib/data/remote/http_catalogue_source.dart
Normal file
205
lib/data/remote/http_catalogue_source.dart
Normal file
@@ -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<CatalogueSnapshot> 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 = <Product>[];
|
||||
final customers = <Customer>[];
|
||||
final retired = <String>[];
|
||||
|
||||
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<Object?>? ?? const [])
|
||||
.whereType<String>(),
|
||||
);
|
||||
|
||||
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<Map<String, Object?>> _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<String, Object?>;
|
||||
} 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<T> _decodeList<T>(
|
||||
Object? raw,
|
||||
T Function(Map<String, Object?>) decode,
|
||||
) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(decode)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static String _trim(String body) =>
|
||||
body.length <= 200 ? body : '${body.substring(0, 200)}…';
|
||||
|
||||
@override
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
55
lib/data/remote/simulated_catalogue_source.dart
Normal file
55
lib/data/remote/simulated_catalogue_source.dart
Normal file
@@ -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<CatalogueSnapshot> 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<void>.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() {}
|
||||
}
|
||||
Reference in New Issue
Block a user