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/promo.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`, which makes this /// table the terminal's outbox: the drain engine selects those rows, sends /// them, and flips the ones the back office confirmed to 1. /// /// Confirmed rows are kept for [retentionWindow] so a batch the server later /// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing /// itself never deletes anything. class OrderDao { const OrderDao(this._db); final Database _db; static const int pending = 0; static const int synced = 1; /// How long an accepted bill stays re-sendable on the terminal. static const Duration retentionWindow = Duration(days: 7); 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) { // A targeted UPDATE, not an upsert. `ConflictAlgorithm.replace` is a // DELETE followed by an INSERT, so every column absent from the row // silently reverts to its schema default — which would reset // `sync_status` to 1 and strand a shopper who had never been uploaded. // // Restricting it to the four figures a sale actually moves also stops // a bill overwriting a name or number corrected at head office between // the shopper being added to the cart and the cashier taking payment. batch.update( Tables.customers, { 'loyalty_points': customerRow['loyalty_points'], 'lifetime_spend': customerRow['lifetime_spend'], 'visit_count': customerRow['visit_count'], 'last_visit_at': customerRow['last_visit_at'], }, where: 'id = ?', whereArgs: [customerRow['id']], ); } await batch.commit(noResult: true); }); } /// Reverses [commitSale]: deletes the order and its lines, adds the stock /// back, and restores an attached customer's row exactly as passed in /// (the caller supplies the pre-sale row — reconstructing it from deltas /// here would get `last_visit_at` wrong). Future voidSale({ required String orderId, required Map stockMovements, Map? customerRow, }) async { await _db.transaction((txn) async { await txn .delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [orderId]); await txn.delete(Tables.orders, where: 'id = ?', whereArgs: [orderId]); final batch = txn.batch(); final now = DateTime.now().millisecondsSinceEpoch; stockMovements.forEach((id, qty) { batch.rawUpdate( 'UPDATE ${Tables.products} SET stock = stock + ?, updated_at = ? ' 'WHERE id = ?', [qty, now, id], ); }); if (customerRow != null) { batch.update( Tables.customers, { 'loyalty_points': customerRow['loyalty_points'], 'lifetime_spend': customerRow['lifetime_spend'], 'visit_count': customerRow['visit_count'], 'last_visit_at': customerRow['last_visit_at'], }, where: 'id = ?', whereArgs: [customerRow['id']], ); } 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, 'promos_json': cart.appliedPromos.isEmpty ? null : jsonEncode([ for (final applied in cart.appliedPromos) { 'id': applied.promo.id, 'name': applied.promo.name, 'type': applied.promo.type.name, 'amount': applied.amount, }, ]), '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 that have *not* yet been accepted by the server. /// /// 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. /// /// Restricted to pending rows on purpose. The moment a bill is accepted its /// figures are folded into [Tables.dayArchive], and the report adds the two /// together — so an accepted row still sitting here during its retention /// window would be counted twice and inflate the day's takings. Future> forBusinessDate( DateTime day, { String? cashierName, }) => _query( where: cashierName == null ? 'business_date = ? AND sync_status = ?' : 'business_date = ? AND sync_status = ? AND cashier_name = ?', whereArgs: [ businessDateOf(day), pending, 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 and marks them synced. /// /// Their figures are added to [Tables.dayArchive] so the shift totals a /// cashier sees do not collapse after a mid-shift sync, and the rows /// themselves are kept — at `sync_status = 1` — until [purgeSyncedBefore] /// retires them. Keeping them buys a recovery window: if the back office /// loses a batch, the full bills are still on the terminal and can be sent /// again. Once deleted only the archived totals survive, and a lost bill's /// line items are gone for good. /// /// Both steps run in one transaction: if the status flip fails the archive /// rolls back with it, and nothing is counted twice. Future archiveAccepted(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, ); } final ids = orders.map((o) => o.id).toList(); final placeholders = List.filled(ids.length, '?').join(','); await txn.rawUpdate( 'UPDATE ${Tables.orders} ' 'SET sync_status = ?, synced_at = ?, sync_error = NULL ' 'WHERE id IN ($placeholders)', [synced, DateTime.now().millisecondsSinceEpoch, ...ids], ); }); } /// Retires bills the server took delivery of more than [retention] ago. /// /// Their archived totals are untouched and stay forever — this drops only the /// re-sendable copy once the recovery window has closed, so a terminal that /// trades for years does not carry every bill it has ever rung. /// /// Returns how many rows went. `order_items` follows via ON DELETE CASCADE. Future purgeSyncedBefore(DateTime cutoff) => _db.delete( Tables.orders, where: 'sync_status = ? AND synced_at IS NOT NULL AND synced_at < ?', whereArgs: [synced, cutoff.millisecondsSinceEpoch], ); /// 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]); } /// Rebuilds the campaigns recorded against a bill. /// /// The stored rows carry the promo's name and amount rather than a live /// lookup, so a campaign that has since been edited or deleted still prints /// on a reissued receipt exactly as it was given. static List _promosFromRow(String? json) { if (json == null || json.isEmpty) return const []; try { return (jsonDecode(json) as List) .cast>() .map((p) => AppliedPromo( promo: Promo( id: (p['id'] as String?) ?? '', name: (p['name'] as String?) ?? 'Promotion', type: PromoType.values .where((t) => t.name == p['type']) .firstOrNull ?? PromoType.flatOffBill, ), amount: (p['amount'] as num?)?.toDouble() ?? 0, ),) .toList(); } on Object { // A bill that cannot name its campaigns is still a valid bill; the total // is on the row itself and does not depend on this. return const []; } } // -------------------------------------------------------------- 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(); // Campaigns are restored so a reprinted receipt still names what the // shopper was given. Their amounts are then *subtracted* from the manual // discount, because `bill_discount` already contains them — restoring both // at full value would discount the bill twice on the way back in. final promos = _promosFromRow(o['promos_json'] as String?); final promoTotal = promos.fold(0, (sum, p) => sum + p.amount); final manualDiscount = (billDiscount - promoTotal).clamp(0, billDiscount); return SaleTransaction( id: o['id']! as String, invoiceNumber: o['invoice_number']! as String, cart: Cart( lines: lines, customer: customer, billDiscount: manualDiscount > 0 ? Discount( type: DiscountType.flat, value: manualDiscount.toDouble(), reason: 'Bill discount', ) : Discount.none, appliedPromos: promos, 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(), ); } }