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>
346 lines
10 KiB
Dart
346 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../../../app/providers.dart';
|
|
import '../../../core/constants/app_constants.dart';
|
|
import '../../../core/services/sound_service.dart';
|
|
import '../../../domain/entities/cart.dart';
|
|
import '../../../domain/entities/customer.dart';
|
|
import '../../../domain/entities/product.dart';
|
|
import '../../../domain/entities/promo.dart';
|
|
import '../../../domain/entities/transaction.dart';
|
|
import '../../../domain/repositories/product_repository.dart';
|
|
import '../../../domain/repositories/transaction_repository.dart';
|
|
import '../../../domain/services/promo_engine.dart';
|
|
|
|
/// Transient feedback for the scan toast — never a blocking dialog.
|
|
enum ScanOutcome { added, incremented, notFound, outOfStock }
|
|
|
|
class ScanFeedback {
|
|
const ScanFeedback({
|
|
required this.outcome,
|
|
required this.stamp,
|
|
this.product,
|
|
this.message,
|
|
});
|
|
|
|
final ScanOutcome outcome;
|
|
final DateTime stamp;
|
|
final Product? product;
|
|
final String? message;
|
|
|
|
bool get isSuccess =>
|
|
outcome == ScanOutcome.added || outcome == ScanOutcome.incremented;
|
|
}
|
|
|
|
/// Owns the live bill.
|
|
///
|
|
/// All mutations funnel through here so that scanner input, product taps and
|
|
/// keyboard shortcuts share one code path and one set of guarantees.
|
|
class CartController extends StateNotifier<Cart> {
|
|
CartController({
|
|
required ProductRepository products,
|
|
required TransactionRepository transactions,
|
|
required SoundService sound,
|
|
required this.onFeedback,
|
|
List<Promo> promos = const [],
|
|
DateTime Function()? clock,
|
|
}) : _products = products,
|
|
_transactions = transactions,
|
|
_sound = sound,
|
|
_promos = promos,
|
|
_now = clock ?? DateTime.now,
|
|
super(Cart.empty);
|
|
|
|
final ProductRepository _products;
|
|
final TransactionRepository _transactions;
|
|
final SoundService _sound;
|
|
final void Function(ScanFeedback) onFeedback;
|
|
|
|
/// Campaigns live right now. Re-evaluated after every change to the bill,
|
|
/// because whether one fires depends on what is in it.
|
|
final List<Promo> _promos;
|
|
final DateTime Function() _now;
|
|
|
|
static const _uuid = Uuid();
|
|
|
|
/// Applies the campaign rules to [next] and stores the result.
|
|
///
|
|
/// Every mutation goes through here rather than assigning `state` directly,
|
|
/// so a promo cannot be left applied after the line that earned it is
|
|
/// removed — which is how a shopper gets a discount for an item they put
|
|
/// back.
|
|
void _commit(Cart next) {
|
|
state = next.copyWith(
|
|
appliedPromos: PromoEngine.evaluate(
|
|
cart: next,
|
|
promos: _promos,
|
|
at: _now(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Snapshots for undo — capped so memory can't grow unbounded on a terminal
|
|
/// that runs for days.
|
|
final List<Cart> _undoStack = [];
|
|
static const int _maxUndo = 25;
|
|
|
|
bool get canUndo => _undoStack.isNotEmpty;
|
|
|
|
void _push() {
|
|
_undoStack.add(state);
|
|
if (_undoStack.length > _maxUndo) _undoStack.removeAt(0);
|
|
}
|
|
|
|
void undo() {
|
|
if (_undoStack.isEmpty) return;
|
|
state = _undoStack.removeLast();
|
|
}
|
|
|
|
// ------------------------------------------------------------ Line items
|
|
/// Adds a product, merging into the existing line when already present.
|
|
void addProduct(Product product, {double quantity = 1}) {
|
|
if (product.isOutOfStock) {
|
|
_sound.scanError();
|
|
onFeedback(ScanFeedback(
|
|
outcome: ScanOutcome.outOfStock,
|
|
stamp: DateTime.now(),
|
|
product: product,
|
|
message: '${product.name} is out of stock',
|
|
),);
|
|
return;
|
|
}
|
|
|
|
_push();
|
|
|
|
final existing = state.lineFor(product.id);
|
|
final requested = (existing?.quantity ?? 0) + quantity;
|
|
|
|
if (requested > product.stock) {
|
|
_undoStack.removeLast();
|
|
_sound.scanError();
|
|
onFeedback(ScanFeedback(
|
|
outcome: ScanOutcome.outOfStock,
|
|
stamp: DateTime.now(),
|
|
product: product,
|
|
message: 'Only ${product.stock.toStringAsFixed(0)} '
|
|
'${product.unit.symbol} left',
|
|
),);
|
|
return;
|
|
}
|
|
|
|
if (existing == null) {
|
|
_commit(state.copyWith(lines: [
|
|
...state.lines,
|
|
CartLine(
|
|
product: product,
|
|
quantity: quantity,
|
|
addedAt: DateTime.now(),
|
|
),
|
|
],),);
|
|
} else {
|
|
_commit(state.copyWith(
|
|
lines: _replace(existing.copyWith(quantity: requested)),
|
|
),);
|
|
}
|
|
|
|
_clampRedemption();
|
|
_sound.scanSuccess();
|
|
onFeedback(ScanFeedback(
|
|
outcome: existing == null ? ScanOutcome.added : ScanOutcome.incremented,
|
|
stamp: DateTime.now(),
|
|
product: product,
|
|
),);
|
|
}
|
|
|
|
/// Scanner entry point. Resolves the barcode and adds it with no dialogs.
|
|
Future<void> scanBarcode(String code) async {
|
|
final product = await _products.findByBarcode(code);
|
|
|
|
if (product == null) {
|
|
// Deliberately not awaited — the beep must never delay the next scan.
|
|
unawaited(_sound.scanError());
|
|
onFeedback(ScanFeedback(
|
|
outcome: ScanOutcome.notFound,
|
|
stamp: DateTime.now(),
|
|
message: 'No product for barcode $code',
|
|
),);
|
|
return;
|
|
}
|
|
|
|
addProduct(product);
|
|
}
|
|
|
|
void setQuantity(String productId, double quantity) {
|
|
final line = state.lineFor(productId);
|
|
if (line == null) return;
|
|
|
|
if (quantity <= 0) {
|
|
removeLine(productId);
|
|
return;
|
|
}
|
|
|
|
final capped = quantity
|
|
.clamp(0, AppConstants.maxCartQuantityPerLine.toDouble())
|
|
.toDouble();
|
|
|
|
if (capped > line.product.stock) {
|
|
_sound.scanError();
|
|
onFeedback(ScanFeedback(
|
|
outcome: ScanOutcome.outOfStock,
|
|
stamp: DateTime.now(),
|
|
product: line.product,
|
|
message: 'Only ${line.product.stock.toStringAsFixed(0)} in stock',
|
|
),);
|
|
return;
|
|
}
|
|
|
|
_push();
|
|
_commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped))));
|
|
_clampRedemption();
|
|
}
|
|
|
|
void increment(String productId, {double by = 1}) {
|
|
final line = state.lineFor(productId);
|
|
if (line == null) return;
|
|
setQuantity(productId, line.quantity + by);
|
|
}
|
|
|
|
void decrement(String productId, {double by = 1}) {
|
|
final line = state.lineFor(productId);
|
|
if (line == null) return;
|
|
setQuantity(productId, line.quantity - by);
|
|
}
|
|
|
|
void removeLine(String productId) {
|
|
if (!state.contains(productId)) return;
|
|
_push();
|
|
_commit(state.copyWith(
|
|
lines: state.lines.where((l) => l.product.id != productId).toList(),
|
|
),);
|
|
_clampRedemption();
|
|
}
|
|
|
|
void applyLineDiscount(String productId, Discount discount) {
|
|
final line = state.lineFor(productId);
|
|
if (line == null) return;
|
|
_push();
|
|
_commit(state.copyWith(lines: _replace(line.copyWith(discount: discount))));
|
|
_clampRedemption();
|
|
}
|
|
|
|
// ------------------------------------------------------------ Bill level
|
|
void applyBillDiscount(Discount discount) {
|
|
_push();
|
|
_commit(state.copyWith(billDiscount: discount));
|
|
_clampRedemption();
|
|
}
|
|
|
|
void clearBillDiscount() => applyBillDiscount(Discount.none);
|
|
|
|
void attachCustomer(Customer? customer) {
|
|
_push();
|
|
_commit(
|
|
customer == null
|
|
? state.copyWith(clearCustomer: true, pointsRedeemed: 0)
|
|
: state.copyWith(customer: customer),
|
|
);
|
|
_clampRedemption();
|
|
}
|
|
|
|
void redeemPoints(int points) {
|
|
final max = state.maxRedeemablePoints;
|
|
_push();
|
|
state = state.copyWith(pointsRedeemed: points.clamp(0, max));
|
|
}
|
|
|
|
void redeemAllPoints() => redeemPoints(state.maxRedeemablePoints);
|
|
|
|
void clearRedemption() => redeemPoints(0);
|
|
|
|
void setNote(String? note) => state = state.copyWith(note: note);
|
|
|
|
/// Keeps redemption legal after the bill shrinks below the redeemed value.
|
|
void _clampRedemption() {
|
|
if (state.pointsRedeemed == 0) return;
|
|
final max = state.maxRedeemablePoints;
|
|
if (state.pointsRedeemed > max) {
|
|
state = state.copyWith(pointsRedeemed: max);
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------- Session
|
|
void clear() {
|
|
_push();
|
|
state = Cart.empty;
|
|
}
|
|
|
|
/// Starts a brand new sale, dropping undo history and the customer.
|
|
void reset() {
|
|
_undoStack.clear();
|
|
state = Cart.empty;
|
|
}
|
|
|
|
/// Keeps the customer attached for a follow-up bill.
|
|
void resetKeepingCustomer() {
|
|
_undoStack.clear();
|
|
state = Cart(customer: state.customer);
|
|
}
|
|
|
|
// ---------------------------------------------------------- Parked bills
|
|
Future<void> park({String? label}) async {
|
|
if (state.isEmpty) return;
|
|
await _transactions.park(ParkedBill(
|
|
id: _uuid.v4(),
|
|
cart: state,
|
|
parkedAt: DateTime.now(),
|
|
label: label,
|
|
),);
|
|
reset();
|
|
}
|
|
|
|
Future<void> resume(ParkedBill bill) async {
|
|
await _transactions.removeParked(bill.id);
|
|
_undoStack.clear();
|
|
// Re-evaluated rather than restored: a campaign that has since ended must
|
|
// not be honoured just because the bill was parked while it was running.
|
|
_commit(bill.cart);
|
|
}
|
|
|
|
List<CartLine> _replace(CartLine updated) => [
|
|
for (final l in state.lines)
|
|
if (l.product.id == updated.product.id) updated else l,
|
|
];
|
|
}
|
|
|
|
// ----------------------------------------------------------------- Providers
|
|
final scanFeedbackProvider = StateProvider<ScanFeedback?>((ref) => null);
|
|
|
|
final cartControllerProvider =
|
|
StateNotifierProvider<CartController, Cart>((ref) {
|
|
return CartController(
|
|
products: ref.watch(productRepositoryProvider),
|
|
transactions: ref.watch(transactionRepositoryProvider),
|
|
sound: ref.watch(soundServiceProvider),
|
|
// Watched, so editing a campaign in Settings takes effect at the till
|
|
// without a restart. An empty list until they load is correct — no promo
|
|
// is safer than a stale one.
|
|
promos: ref.watch(activePromosProvider).value ?? const [],
|
|
onFeedback: (feedback) =>
|
|
ref.read(scanFeedbackProvider.notifier).state = feedback,
|
|
);
|
|
});
|
|
|
|
/// Convenience selectors — each rebuilds only the widget that needs it.
|
|
final cartTotalProvider =
|
|
Provider<double>((ref) => ref.watch(cartControllerProvider).grandTotal);
|
|
|
|
final cartItemCountProvider =
|
|
Provider<int>((ref) => ref.watch(cartControllerProvider).lineCount);
|
|
|
|
final parkedBillsProvider = FutureProvider<List<ParkedBill>>(
|
|
(ref) => ref.watch(transactionRepositoryProvider).parkedBills(),
|
|
);
|