Upload shopper registrations, and charge GST to the lines that earned it
Two defects that share a shape: a figure landing on the wrong record.
Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.
Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.
Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.
Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.
Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ class AppDatabase {
|
||||
static final AppDatabase instance = AppDatabase._();
|
||||
|
||||
static const String _fileName = 'nearle_pos.db';
|
||||
static const int _version = 7;
|
||||
static const int _version = 8;
|
||||
|
||||
Database? _db;
|
||||
|
||||
@@ -77,6 +77,7 @@ class AppDatabase {
|
||||
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
|
||||
);
|
||||
}
|
||||
if (from < 8) await _upgradeToV8(db);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -188,12 +189,24 @@ class AppDatabase {
|
||||
lifetime_spend REAL NOT NULL DEFAULT 0,
|
||||
visit_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER,
|
||||
last_visit_at INTEGER
|
||||
last_visit_at INTEGER,
|
||||
|
||||
-- Registration outbox. A shopper signed up at the till has to reach
|
||||
-- the back office even if they never buy anything, so this table
|
||||
-- carries the same pending/synced flag the orders table does.
|
||||
--
|
||||
-- Defaults to 1: rows that arrived in a catalogue pull came *from*
|
||||
-- the back office and must not be posted straight back.
|
||||
sync_status INTEGER NOT NULL DEFAULT 1,
|
||||
synced_at INTEGER
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------- orders
|
||||
await db.execute('''
|
||||
@@ -322,6 +335,43 @@ Future<void> _upgradeToV4(Database db, {required int from}) async {
|
||||
await db.execute('DROP TABLE _day_archive_v3');
|
||||
}
|
||||
|
||||
/// Moves the schema to v8 — customers become an outbox.
|
||||
///
|
||||
/// Before this, a shopper registered at the till only ever reached the back
|
||||
/// office as three fields riding along on a bill. Someone who signed up and
|
||||
/// then didn't buy anything, or whose bill was still queued, existed on one
|
||||
/// terminal and nowhere else.
|
||||
///
|
||||
/// Every existing row is marked pending rather than synced. The terminal
|
||||
/// cannot tell which of them came down in a catalogue pull and which were rung
|
||||
/// up locally, and of the two possible mistakes only one loses a shopper. This
|
||||
/// is safe precisely because the customer uplink is specified as
|
||||
/// insert-if-absent on id — re-sending one the back office already holds is a
|
||||
/// no-op, never an overwrite of a profile edited at head office.
|
||||
///
|
||||
/// Ids are deliberately *not* rewritten. New customers are keyed on their
|
||||
/// mobile number (see `Customer.idForMobile`) so terminals agree without
|
||||
/// coordinating, but rows created before this version carry random ids that
|
||||
/// bills already in the back office refer to. Re-keying them here would break
|
||||
/// that link. They stay as they are, and the back office merges them on
|
||||
/// mobile — a one-off for stores that were already trading.
|
||||
Future<void> _upgradeToV8(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE ${Tables.customers} '
|
||||
'ADD COLUMN sync_status INTEGER NOT NULL DEFAULT 1',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE ${Tables.customers} ADD COLUMN synced_at INTEGER',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
|
||||
);
|
||||
|
||||
await db.rawUpdate(
|
||||
'UPDATE ${Tables.customers} SET sync_status = 0, synced_at = NULL',
|
||||
);
|
||||
}
|
||||
|
||||
const String _createSyncLog = '''
|
||||
CREATE TABLE sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user