import 'dart:convert'; import 'package:sqflite/sqflite.dart'; import '../../domain/entities/cart.dart'; import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; import '../../domain/entities/transaction.dart'; import 'app_database.dart'; import 'catalogue_dao.dart'; /// Persists bills. /// /// Every completed sale lands here with `sync_status = 0`. The end-of-day /// upload selects those rows, sends them, and flips the accepted ones to 1. /// Nothing is ever deleted as part of syncing. class OrderDao { const OrderDao(this._db); final Database _db; static const int pending = 0; static const int synced = 1; static String businessDateOf(DateTime dt) => '${dt.year.toString().padLeft(4, '0')}-' '${dt.month.toString().padLeft(2, '0')}-' '${dt.day.toString().padLeft(2, '0')}'; // ----------------------------------------------------------------- Write /// Writes the bill and its lines atomically. Future insertOrder(SaleTransaction t) async { final cart = t.cart; await _db.transaction((txn) async { await txn.insert( Tables.orders, { 'id': t.id, 'invoice_number': t.invoiceNumber, 'created_at': t.createdAt.millisecondsSinceEpoch, 'business_date': businessDateOf(t.createdAt), 'cashier_name': t.cashierName, 'terminal_id': t.terminalId, 'customer_id': cart.customer?.id, 'customer_mobile': cart.customer?.mobile, 'customer_name': cart.customer?.name, 'subtotal': cart.subtotal, 'line_discount': cart.lineDiscountTotal, 'bill_discount': cart.billDiscountTotal, 'loyalty_value': cart.loyaltyRedemptionValue, 'taxable_amount': cart.taxableAmount, 'tax_amount': cart.taxAmount, 'round_off': cart.roundOff, 'total': t.total, 'points_earned': cart.pointsEarned, 'points_redeemed': cart.pointsRedeemed, 'payments_json': jsonEncode([ for (final p in t.payments) { 'method': p.method.name, 'amount': p.amount, 'tendered': p.tendered, 'reference': p.reference, }, ]), 'status': t.status.name, 'sync_status': pending, 'sync_attempts': 0, }, conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); for (final line in cart.lines) { batch.insert(Tables.orderItems, { 'order_id': t.id, 'product_id': line.product.id, 'name': line.product.name, 'barcode': line.product.barcode, 'sku': line.product.sku, 'unit': line.product.unit.name, 'unit_price': line.product.price, 'quantity': line.quantity, 'discount': line.discountAmount, 'gst_rate': line.product.gstRate, 'tax_amount': line.taxAmount, 'line_total': line.payable, }); } await batch.commit(noResult: true); }); } // ------------------------------------------------------------------ Read Future> recent({int limit = 100}) => _query(orderBy: 'created_at DESC', limit: limit); Future> forBusinessDate(DateTime day) => _query(where: 'business_date = ?', whereArgs: [businessDateOf(day)]); /// The end-of-day upload set. Future> unsynced({int limit = 500}) => _query( where: 'sync_status = ?', whereArgs: [pending], orderBy: 'created_at ASC', limit: limit, ); Future byInvoice(String invoiceNumber) async { final rows = await _query( where: 'invoice_number = ?', whereArgs: [invoiceNumber], limit: 1, ); return rows.isEmpty ? null : rows.first; } Future unsyncedCount() async { final r = await _db.rawQuery( 'SELECT COUNT(*) AS c FROM ${Tables.orders} WHERE sync_status = ?', [pending], ); return r.first['c']! as int; } Future salesTotalForDay(DateTime day) async { final r = await _db.rawQuery( 'SELECT COALESCE(SUM(total), 0) AS t FROM ${Tables.orders} ' "WHERE business_date = ? AND status = 'completed'", [businessDateOf(day)], ); return (r.first['t']! as num).toDouble(); } /// Sync state of an order, for display in the events log. Future?> syncRow(String orderId) async { final rows = await _db.query( Tables.orders, columns: [ 'id', 'invoice_number', 'total', 'created_at', 'sync_status', 'synced_at', 'sync_attempts', 'sync_error', ], where: 'id = ?', whereArgs: [orderId], limit: 1, ); return rows.isEmpty ? null : rows.first; } Future>> syncRows({int limit = 200}) => _db.query( Tables.orders, columns: [ 'id', 'invoice_number', 'total', 'created_at', 'sync_status', 'synced_at', 'sync_attempts', 'sync_error', ], orderBy: 'created_at DESC', limit: limit, ); // ----------------------------------------------------------------- Sync /// Folds accepted orders into the day archive, then deletes them. /// /// Once the server holds a bill the terminal has no reason to keep it, so /// the rows go. Their figures are added to [Tables.dayArchive] first, so the /// shift totals a cashier sees do not collapse after a mid-shift sync. /// Both steps run in one transaction: if the delete fails the archive is /// rolled back with it, and nothing is counted twice. Future archiveAndDelete(List orders) async { if (orders.isEmpty) return; await _db.transaction((txn) async { final byDate = >{}; for (final o in orders) { byDate.putIfAbsent(businessDateOf(o.createdAt), () => []).add(o); } for (final entry in byDate.entries) { final date = entry.key; final batchOrders = entry.value; final prior = await txn.query( Tables.dayArchive, where: 'business_date = ?', whereArgs: [date], limit: 1, ); final existing = prior.isEmpty ? null : prior.first; final priorPayments = existing == null ? {} : (jsonDecode(existing['payments_json']! as String) as Map) .map((k, v) => MapEntry(k, (v! as num).toDouble())); for (final o in batchOrders) { for (final p in o.payments) { priorPayments[p.method.name] = (priorPayments[p.method.name] ?? 0) + p.amount; } } final firsts = batchOrders .map((o) => o.createdAt.millisecondsSinceEpoch) .toList() ..sort(); await txn.insert( Tables.dayArchive, { 'business_date': date, 'bill_count': ((existing?['bill_count'] as int?) ?? 0) + batchOrders.length, 'item_count': ((existing?['item_count'] as num?)?.toDouble() ?? 0) + batchOrders.fold(0.0, (s, o) => s + o.cart.totalQuantity), 'gross_sales': ((existing?['gross_sales'] as num?)?.toDouble() ?? 0) + batchOrders.fold(0.0, (s, o) => s + o.total), 'tax_collected': ((existing?['tax_collected'] as num?)?.toDouble() ?? 0) + batchOrders.fold(0.0, (s, o) => s + o.cart.taxAmount), 'discount_given': ((existing?['discount_given'] as num?)?.toDouble() ?? 0) + batchOrders.fold( 0.0, (s, o) => s + o.cart.billDiscountTotal + o.cart.lineDiscountTotal, ), 'round_off': ((existing?['round_off'] as num?)?.toDouble() ?? 0) + batchOrders.fold(0.0, (s, o) => s + o.cart.roundOff), 'points_issued': ((existing?['points_issued'] as int?) ?? 0) + batchOrders.fold(0, (s, o) => s + o.pointsEarned), 'points_redeemed': ((existing?['points_redeemed'] as int?) ?? 0) + batchOrders.fold(0, (s, o) => s + o.pointsRedeemed), 'payments_json': jsonEncode(priorPayments), 'first_bill_at': existing?['first_bill_at'] ?? firsts.first, 'last_bill_at': firsts.last, 'synced_bills': ((existing?['synced_bills'] as int?) ?? 0) + batchOrders.length, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } // order_items goes with it via ON DELETE CASCADE. final ids = orders.map((o) => o.id).toList(); final placeholders = List.filled(ids.length, '?').join(','); await txn.delete( Tables.orders, where: 'id IN ($placeholders)', whereArgs: ids, ); }); } /// Archived figures for a business day, or null if nothing has synced yet. Future?> dayArchive(DateTime day) async { final rows = await _db.query( Tables.dayArchive, where: 'business_date = ?', whereArgs: [businessDateOf(day)], limit: 1, ); return rows.isEmpty ? null : rows.first; } /// Records a failed attempt. The rows stay at `sync_status = 0`. Future markFailed(List orderIds, String error) async { if (orderIds.isEmpty) return; final placeholders = List.filled(orderIds.length, '?').join(','); await _db.rawUpdate( 'UPDATE ${Tables.orders} SET sync_attempts = sync_attempts + 1, ' 'sync_error = ? WHERE id IN ($placeholders)', [error, ...orderIds], ); } // ---------------------------------------------------------- Parked bills Future park(ParkedBill bill) async { await _db.insert( Tables.parkedBills, { 'id': bill.id, 'label': bill.label, 'parked_at': bill.parkedAt.millisecondsSinceEpoch, 'cart_json': jsonEncode(_cartToJson(bill.cart)), }, conflictAlgorithm: ConflictAlgorithm.replace, ); } Future> parkedBills() async { final rows = await _db.query(Tables.parkedBills, orderBy: 'parked_at DESC'); return rows .map((r) => ParkedBill( id: r['id']! as String, label: r['label'] as String?, parkedAt: DateTime.fromMillisecondsSinceEpoch(r['parked_at']! as int), cart: _cartFromJson( jsonDecode(r['cart_json']! as String) as Map, ), )) .toList(); } Future removeParked(String id) async { await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]); } // -------------------------------------------------------------- Internals Future> _query({ String? where, List? whereArgs, String orderBy = 'created_at DESC', int? limit, }) async { final orders = await _db.query( Tables.orders, where: where, whereArgs: whereArgs, orderBy: orderBy, limit: limit, ); if (orders.isEmpty) return const []; final ids = orders.map((o) => o['id']! as String).toList(); final placeholders = List.filled(ids.length, '?').join(','); final items = await _db.query( Tables.orderItems, where: 'order_id IN ($placeholders)', whereArgs: ids, ); final byOrder = >>{}; for (final i in items) { byOrder.putIfAbsent(i['order_id']! as String, () => []).add(i); } return orders .map((o) => _transactionFromRows(o, byOrder[o['id']] ?? const [])) .toList(); } SaleTransaction _transactionFromRows( Map o, List> items, ) { // Lines are rebuilt from the snapshot taken at sale time, not from the // current catalogue, so a reprint always shows what was actually charged. final lines = items.map((i) { final product = Product( id: i['product_id']! as String, name: i['name']! as String, barcode: i['barcode']! as String, sku: i['sku']! as String, category: ProductCategory.grocery, price: (i['unit_price']! as num).toDouble(), stock: 0, unit: UnitOfMeasure.values.byName(i['unit']! as String), gstRate: (i['gst_rate']! as num).toDouble(), ); return CartLine( product: product, quantity: (i['quantity']! as num).toDouble(), discount: (i['discount']! as num) > 0 ? Discount( type: DiscountType.flat, value: (i['discount']! as num).toDouble(), ) : Discount.none, ); }).toList(); final customerId = o['customer_id'] as String?; final customer = customerId == null ? null : Customer( id: customerId, name: (o['customer_name'] as String?) ?? 'Customer', mobile: (o['customer_mobile'] as String?) ?? '', ); final payments = (jsonDecode(o['payments_json']! as String) as List) .cast>() .map((p) => PaymentSplit( method: PaymentMethod.values.byName(p['method']! as String), amount: (p['amount']! as num).toDouble(), tendered: (p['tendered'] as num?)?.toDouble(), reference: p['reference'] as String?, )) .toList(); return SaleTransaction( id: o['id']! as String, invoiceNumber: o['invoice_number']! as String, cart: Cart(lines: lines, customer: customer), payments: payments, createdAt: DateTime.fromMillisecondsSinceEpoch(o['created_at']! as int), cashierName: o['cashier_name']! as String, terminalId: o['terminal_id']! as String, status: TransactionStatus.values.byName(o['status']! as String), ); } Map _cartToJson(Cart cart) => { 'customer': cart.customer == null ? null : CatalogueDao.customerToRow(cart.customer!), 'points_redeemed': cart.pointsRedeemed, 'note': cart.note, 'lines': [ for (final l in cart.lines) { 'product': CatalogueDao.productToRow(l.product), 'quantity': l.quantity, 'discount_type': l.discount.type.name, 'discount_value': l.discount.value, }, ], }; Cart _cartFromJson(Map j) { final customerRow = j['customer'] as Map?; return Cart( customer: customerRow == null ? null : CatalogueDao.customerFromRow(customerRow), pointsRedeemed: (j['points_redeemed'] as int?) ?? 0, note: j['note'] as String?, lines: (j['lines'] as List).cast>().map((l) { return CartLine( product: CatalogueDao.productFromRow( l['product']! as Map, ), quantity: (l['quantity']! as num).toDouble(), discount: Discount( type: DiscountType.values.byName(l['discount_type']! as String), value: (l['discount_value']! as num).toDouble(), ), ); }).toList(), ); } }