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 /// Commits an entire sale in one transaction. /// /// The bill, the stock it consumed and the shopper's loyalty movement have to /// land together or not at all. Applied as three separate writes, a failure /// part-way through left a persisted bill with unapplied loyalty while the /// cashier saw an error and rang the sale again — a duplicate bill and a /// double stock decrement. Future commitSale({ required SaleTransaction transaction, required Map stockMovements, Map? customerRow, }) async { await _db.transaction((txn) async { await _insertOrder(txn, transaction); final batch = txn.batch(); final now = DateTime.now().millisecondsSinceEpoch; stockMovements.forEach((id, qty) { batch.rawUpdate( 'UPDATE ${Tables.products} ' 'SET stock = MAX(0, stock - ?), updated_at = ? WHERE id = ?', [qty, now, id], ); }); if (customerRow != null) { batch.insert( Tables.customers, customerRow, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); }); } Future _insertOrder(DatabaseExecutor txn, SaleTransaction t) async { final cart = t.cart; // A replace of the order row does not reliably cascade to its lines, so // clear them first — otherwise re-saving a bill doubles its items. await txn.delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [t.id]); 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': t.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); /// Bills for a day, optionally narrowed to one operator. /// /// A shift report that is settled against a till has to cover exactly the /// bills that cashier rang, not everything the terminal did that day. Future> forBusinessDate( DateTime day, { String? cashierName, }) => _query( where: cashierName == null ? 'business_date = ?' : 'business_date = ? AND cashier_name = ?', whereArgs: [ businessDateOf(day), if (cashierName != null) cashierName, ], ); /// 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; } /// Stock already consumed by bills that have not yet reached the server. /// /// A re-import overwrites stock with the server's figure, which does not know /// about these sales. Replaying them keeps the shelf count honest when the /// catalogue is pulled again mid-shift. Future> unsyncedStockCommitments() async { final rows = await _db.rawQuery( 'SELECT i.product_id AS pid, SUM(i.quantity) AS q ' 'FROM ${Tables.orderItems} i ' 'JOIN ${Tables.orders} o ON o.id = i.order_id ' 'WHERE o.sync_status = ? GROUP BY i.product_id', [pending], ); return { for (final r in rows) r['pid']! as String: (r['q']! as num).toDouble(), }; } 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 { // Grouped by cashier as well as day: the archive is what a till is // settled against once the bills themselves are gone. final byDate = <({String date, String cashier}), List>{}; for (final o in orders) { final key = (date: businessDateOf(o.createdAt), cashier: o.cashierName); byDate.putIfAbsent(key, () => []).add(o); } for (final entry in byDate.entries) { final date = entry.key.date; final cashier = entry.key.cashier; final batchOrders = entry.value; final prior = await txn.query( Tables.dayArchive, where: 'business_date = ? AND cashier_name = ?', whereArgs: [date, cashier], 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, 'cashier_name': cashier, '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, one row per cashier. /// /// Empty when nothing has synced yet. Pass [cashierName] to scope it to a /// single operator; omit it for the whole terminal. Future>> dayArchive( DateTime day, { String? cashierName, }) => _db.query( Tables.dayArchive, where: cashierName == null ? 'business_date = ?' : 'business_date = ? AND cashier_name = ?', whereArgs: [ businessDateOf(day), if (cashierName != null) cashierName, ], ); /// 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?; // Rebuilt with no lifetime spend on purpose: the tier discount this shopper // earned is already included in the recorded `bill_discount`, so giving the // reconstruction a tier would apply it a second time. 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(); // Bill-level reductions live on the order row, not on the lines, so they // have to be put back explicitly. Without this the rebuilt cart bills at // the undiscounted subtotal and every downstream figure — the upload // payload, the day archive, the shift report — is overstated. final billDiscount = (o['bill_discount']! as num).toDouble(); return SaleTransaction( id: o['id']! as String, invoiceNumber: o['invoice_number']! as String, cart: Cart( lines: lines, customer: customer, billDiscount: billDiscount > 0 ? Discount( type: DiscountType.flat, value: billDiscount, reason: 'Bill discount', ) : Discount.none, pointsRedeemed: (o['points_redeemed'] as int?) ?? 0, ), 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), storedTotal: (o['total']! as num).toDouble(), storedPointsEarned: (o['points_earned'] as int?) ?? 0, ); } 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(), ); } }