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:
@@ -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<void> applyCatalogueDelta({
|
||||
required List<Product> products,
|
||||
required List<Customer> customers,
|
||||
List<String> 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<void> decrementStock(Map<String, double> quantities) async {
|
||||
if (quantities.isEmpty) return;
|
||||
|
||||
Reference in New Issue
Block a user