Build promos for real: engine, storage, editor, and application at the till
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>
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 = 5;
|
||||
static const int _version = 7;
|
||||
|
||||
Database? _db;
|
||||
|
||||
@@ -71,6 +71,12 @@ class AppDatabase {
|
||||
}
|
||||
if (from < 4) await _upgradeToV4(db, from: from);
|
||||
if (from < 5) await db.execute(_createStaff);
|
||||
if (from < 6) await db.execute(_createPromos);
|
||||
if (from < 7) {
|
||||
await db.execute(
|
||||
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -131,6 +137,7 @@ class AppDatabase {
|
||||
Tables.parkedBills,
|
||||
Tables.syncLog,
|
||||
Tables.staff,
|
||||
Tables.promos,
|
||||
Tables.meta,
|
||||
]) {
|
||||
batch.delete(t);
|
||||
@@ -211,6 +218,10 @@ class AppDatabase {
|
||||
points_earned INTEGER NOT NULL DEFAULT 0,
|
||||
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
||||
payments_json TEXT NOT NULL,
|
||||
-- Which campaigns fired, and for how much. Stored as amounts rather
|
||||
-- than ids: a bill read back next year must show what was actually
|
||||
-- given, not what today's rules would give.
|
||||
promos_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
|
||||
-- 0 = held on this terminal, 1 = accepted by the server
|
||||
@@ -268,6 +279,7 @@ class AppDatabase {
|
||||
// ------------------------------------------------------------- archive
|
||||
await db.execute(_createDayArchive);
|
||||
await db.execute(_createStaff);
|
||||
await db.execute(_createPromos);
|
||||
|
||||
// ----------------------------------------------------------------- meta
|
||||
await db.execute('''
|
||||
@@ -351,6 +363,34 @@ const String _createStaff = '''
|
||||
)
|
||||
''';
|
||||
|
||||
/// Campaigns the till applies automatically.
|
||||
///
|
||||
/// Kept local like everything else: a shop mid-promotion with a dead line still
|
||||
/// has to honour the price on the shelf edge.
|
||||
const String _createPromos = '''
|
||||
CREATE TABLE promos (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
value REAL NOT NULL DEFAULT 0,
|
||||
target_id TEXT,
|
||||
target_label TEXT,
|
||||
buy_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
free_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
min_bill_value REAL NOT NULL DEFAULT 0,
|
||||
max_discount REAL,
|
||||
valid_from INTEGER,
|
||||
valid_to INTEGER,
|
||||
-- Comma-separated DateTime.weekday values. Empty means every day.
|
||||
days_of_week TEXT NOT NULL DEFAULT '',
|
||||
stackable INTEGER NOT NULL DEFAULT 0,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''';
|
||||
|
||||
const String _createDayArchive = '''
|
||||
CREATE TABLE day_archive (
|
||||
business_date TEXT NOT NULL,
|
||||
@@ -383,6 +423,7 @@ class Tables {
|
||||
static const String parkedBills = 'parked_bills';
|
||||
static const String syncLog = 'sync_log';
|
||||
static const String staff = 'staff';
|
||||
static const String promos = 'promos';
|
||||
static const String meta = 'app_meta';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user