import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; import '../../domain/entities/promo.dart'; import 'app_database.dart'; /// Raised when a campaign would be saved in a state the till cannot apply. class PromoException implements Exception { const PromoException(this.message); final String message; @override String toString() => message; } /// Stores campaigns on the terminal. /// /// Local like everything else the till needs: a shop mid-promotion with a dead /// line still has to honour the price on the shelf edge. class PromoDao { const PromoDao(this._db); final Database _db; static const _uuid = Uuid(); Future> all({bool activeOnly = false}) async { final rows = await _db.query( Tables.promos, where: activeOnly ? 'is_active = 1' : null, orderBy: 'priority ASC, created_at ASC', ); return rows.map(_fromRow).toList(); } Future findById(String id) async { final rows = await _db.query( Tables.promos, where: 'id = ?', whereArgs: [id], limit: 1, ); return rows.isEmpty ? null : _fromRow(rows.first); } /// Inserts or updates. Returns the stored campaign, with its id. Future save(Promo promo) async { _validate(promo); final id = promo.id.isEmpty ? _uuid.v4() : promo.id; final now = DateTime.now().millisecondsSinceEpoch; final existing = await findById(id); await _db.insert( Tables.promos, { 'id': id, 'name': promo.name.trim(), 'type': promo.type.name, 'value': promo.value, 'target_id': promo.targetId, 'target_label': promo.targetLabel, 'buy_quantity': promo.buyQuantity, 'free_quantity': promo.freeQuantity, 'min_bill_value': promo.minBillValue, 'max_discount': promo.maxDiscount, 'valid_from': promo.validFrom?.millisecondsSinceEpoch, 'valid_to': promo.validTo?.millisecondsSinceEpoch, 'days_of_week': (promo.daysOfWeek.toList()..sort()).join(','), 'stackable': promo.stackable ? 1 : 0, 'priority': promo.priority, 'is_active': promo.isActive ? 1 : 0, // Preserved on update so the list keeps a stable order. 'created_at': existing == null ? now : await _createdAt(id) ?? now, 'updated_at': now, }, conflictAlgorithm: ConflictAlgorithm.replace, ); // Read back rather than echoing the input, so the caller gets exactly what // the database holds — including the id minted for a new campaign. return (await findById(id))!; } Future _createdAt(String id) async { final rows = await _db.query( Tables.promos, columns: ['created_at'], where: 'id = ?', whereArgs: [id], limit: 1, ); return rows.isEmpty ? null : rows.first['created_at'] as int?; } Future setActive(String id, {required bool active}) async { await _db.update( Tables.promos, { 'is_active': active ? 1 : 0, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [id], ); } /// Hard delete. Unlike staff, nothing already recorded points at a promo row /// — a bill stores the amount it was given, not a reference to the campaign, /// so deleting one cannot change a past total. Future delete(String id) async { await _db.delete(Tables.promos, where: 'id = ?', whereArgs: [id]); } // ------------------------------------------------------------- Internals static void _validate(Promo promo) { if (promo.name.trim().isEmpty) { throw const PromoException('A campaign needs a name.'); } if (promo.type.needsTarget && (promo.targetId == null || promo.targetId!.isEmpty)) { throw const PromoException( 'This campaign needs a product or category to apply to.', ); } if (promo.type == PromoType.buyXGetY) { if (promo.buyQuantity < 1 || promo.freeQuantity < 1) { throw const PromoException( 'Buy and free quantities must both be at least one.', ); } } else if (promo.value <= 0) { throw const PromoException('A campaign must give something away.'); } if (promo.type.isPercentage && promo.value > 100) { // Over 100% is a refund with extra steps. throw const PromoException('A percentage cannot exceed 100.'); } final from = promo.validFrom; final to = promo.validTo; if (from != null && to != null && to.isBefore(from)) { throw const PromoException('The end date is before the start date.'); } if (promo.daysOfWeek.any((d) => d < 1 || d > 7)) { throw const PromoException('Days of the week must be 1 (Mon) to 7 (Sun).'); } } static Promo _fromRow(Map row) { final days = (row['days_of_week'] as String? ?? '') .split(',') .where((s) => s.isNotEmpty) .map(int.parse) .toSet(); return Promo( id: row['id']! as String, name: row['name']! as String, type: PromoType.values.byName(row['type']! as String), value: (row['value'] as num? ?? 0).toDouble(), targetId: row['target_id'] as String?, targetLabel: row['target_label'] as String?, buyQuantity: (row['buy_quantity'] as int?) ?? 0, freeQuantity: (row['free_quantity'] as int?) ?? 0, minBillValue: (row['min_bill_value'] as num? ?? 0).toDouble(), maxDiscount: (row['max_discount'] as num?)?.toDouble(), validFrom: row['valid_from'] == null ? null : DateTime.fromMillisecondsSinceEpoch(row['valid_from']! as int), validTo: row['valid_to'] == null ? null : DateTime.fromMillisecondsSinceEpoch(row['valid_to']! as int), daysOfWeek: days, stackable: (row['stackable'] as int? ?? 0) == 1, priority: (row['priority'] as int?) ?? 100, isActive: (row['is_active'] as int? ?? 1) == 1, ); } }