import 'package:equatable/equatable.dart'; import '../../core/utils/extensions.dart'; import 'transaction.dart'; /// Everything the back office needs from a day at this terminal. /// /// Computed from the locally stored transactions, so it can be produced with /// no connection and pushed whenever one is available. class ShiftReport extends Equatable { const ShiftReport({ required this.businessDate, required this.terminalId, required this.cashierName, required this.billCount, required this.itemCount, required this.grossSales, required this.taxCollected, required this.discountGiven, required this.roundOff, required this.paymentBreakdown, required this.loyaltyPointsIssued, required this.loyaltyPointsRedeemed, this.firstBillAt, this.lastBillAt, }); final DateTime businessDate; final String terminalId; final String cashierName; final int billCount; /// Total units sold across every bill. final double itemCount; final double grossSales; final double taxCollected; final double discountGiven; final double roundOff; final Map paymentBreakdown; final int loyaltyPointsIssued; final int loyaltyPointsRedeemed; final DateTime? firstBillAt; final DateTime? lastBillAt; /// A zeroed report for a day with no trading. Not a const — [DateTime] /// cannot appear in a constant expression. factory ShiftReport.blank({ required DateTime businessDate, required String terminalId, required String cashierName, }) => ShiftReport( businessDate: businessDate, terminalId: terminalId, cashierName: cashierName, billCount: 0, itemCount: 0, grossSales: 0, taxCollected: 0, discountGiven: 0, roundOff: 0, paymentBreakdown: const {}, loyaltyPointsIssued: 0, loyaltyPointsRedeemed: 0, ); bool get isEmpty => billCount == 0; double get averageBasket => billCount == 0 ? 0 : (grossSales / billCount).asMoney; double get netOfTax => (grossSales - taxCollected).asMoney; /// Builds the report from the day's transactions. factory ShiftReport.fromTransactions({ required List transactions, required DateTime businessDate, required String terminalId, required String cashierName, }) { final completed = transactions .where((t) => t.status == TransactionStatus.completed && t.createdAt.year == businessDate.year && t.createdAt.month == businessDate.month && t.createdAt.day == businessDate.day,) .toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); final byMethod = {}; for (final t in completed) { for (final p in t.payments) { byMethod[p.method] = ((byMethod[p.method] ?? 0) + p.amount).asMoney; } } return ShiftReport( businessDate: businessDate, terminalId: terminalId, cashierName: cashierName, billCount: completed.length, itemCount: completed.fold(0.0, (s, t) => s + t.cart.totalQuantity), grossSales: completed.fold(0.0, (s, t) => s + t.total).asMoney, taxCollected: completed.fold(0.0, (s, t) => s + t.cart.taxAmount).asMoney, discountGiven: completed .fold(0.0, (s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal,) .asMoney, roundOff: completed.fold(0.0, (s, t) => s + t.cart.roundOff).asMoney, paymentBreakdown: byMethod, loyaltyPointsIssued: completed.fold(0, (s, t) => s + t.pointsEarned), loyaltyPointsRedeemed: completed.fold(0, (s, t) => s + t.pointsRedeemed), firstBillAt: completed.isEmpty ? null : completed.first.createdAt, lastBillAt: completed.isEmpty ? null : completed.last.createdAt, ); } /// Rebuilds a report from an archived day row. factory ShiftReport.fromArchive({ required Map row, required Map payments, required DateTime businessDate, required String terminalId, required String cashierName, }) { DateTime? at(Object? v) => v == null ? null : DateTime.fromMillisecondsSinceEpoch(v as int); return ShiftReport( businessDate: businessDate, terminalId: terminalId, cashierName: cashierName, billCount: (row['bill_count'] as int?) ?? 0, itemCount: (row['item_count'] as num?)?.toDouble() ?? 0, grossSales: (row['gross_sales'] as num?)?.toDouble() ?? 0, taxCollected: (row['tax_collected'] as num?)?.toDouble() ?? 0, discountGiven: (row['discount_given'] as num?)?.toDouble() ?? 0, roundOff: (row['round_off'] as num?)?.toDouble() ?? 0, paymentBreakdown: payments, loyaltyPointsIssued: (row['points_issued'] as int?) ?? 0, loyaltyPointsRedeemed: (row['points_redeemed'] as int?) ?? 0, firstBillAt: at(row['first_bill_at']), lastBillAt: at(row['last_bill_at']), ); } /// Adds two reports for the same day. /// /// Needed because synced bills are deleted from the terminal: the day's true /// figures are the archived totals plus whatever is still held locally. ShiftReport operator +(ShiftReport other) { final payments = {...paymentBreakdown}; other.paymentBreakdown.forEach((k, v) { payments[k] = ((payments[k] ?? 0) + v).asMoney; }); DateTime? earliest(DateTime? a, DateTime? b) { if (a == null) return b; if (b == null) return a; return a.isBefore(b) ? a : b; } DateTime? latest(DateTime? a, DateTime? b) { if (a == null) return b; if (b == null) return a; return a.isAfter(b) ? a : b; } return ShiftReport( businessDate: businessDate, terminalId: terminalId, cashierName: cashierName, billCount: billCount + other.billCount, itemCount: itemCount + other.itemCount, grossSales: (grossSales + other.grossSales).asMoney, taxCollected: (taxCollected + other.taxCollected).asMoney, discountGiven: (discountGiven + other.discountGiven).asMoney, roundOff: (roundOff + other.roundOff).asMoney, paymentBreakdown: payments, loyaltyPointsIssued: loyaltyPointsIssued + other.loyaltyPointsIssued, loyaltyPointsRedeemed: loyaltyPointsRedeemed + other.loyaltyPointsRedeemed, firstBillAt: earliest(firstBillAt, other.firstBillAt), lastBillAt: latest(lastBillAt, other.lastBillAt), ); } /// The JSON body that would be sent to the back office. Map toPayload() => { 'business_date': businessDate.toIso8601String().substring(0, 10), 'terminal_id': terminalId, 'cashier': cashierName, 'bill_count': billCount, 'item_count': itemCount, 'gross_sales': grossSales, 'net_of_tax': netOfTax, 'tax_collected': taxCollected, 'discount_given': discountGiven, 'round_off': roundOff, 'average_basket': averageBasket, 'loyalty_points_issued': loyaltyPointsIssued, 'loyalty_points_redeemed': loyaltyPointsRedeemed, 'first_bill_at': firstBillAt?.toIso8601String(), 'last_bill_at': lastBillAt?.toIso8601String(), 'payments': { for (final e in paymentBreakdown.entries) e.key.name: e.value, }, }; @override List get props => [businessDate, terminalId, billCount, grossSales]; }