Two defects that share a shape: a figure landing on the wrong record.
Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.
Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.
Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.
Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.
Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
663 lines
24 KiB
Dart
663 lines
24 KiB
Dart
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<void> commitSale({
|
|
required SaleTransaction transaction,
|
|
required Map<String, double> stockMovements,
|
|
Map<String, Object?>? 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);
|
|
});
|
|
}
|
|
|
|
Future<void> _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<List<SaleTransaction>> 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<List<SaleTransaction>> 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<List<SaleTransaction>> unsynced({int limit = 500}) => _query(
|
|
where: 'sync_status = ?',
|
|
whereArgs: [pending],
|
|
orderBy: 'created_at ASC',
|
|
limit: limit,
|
|
);
|
|
|
|
Future<SaleTransaction?> byInvoice(String invoiceNumber) async {
|
|
final rows = await _query(
|
|
where: 'invoice_number = ?',
|
|
whereArgs: [invoiceNumber],
|
|
limit: 1,
|
|
);
|
|
return rows.isEmpty ? null : rows.first;
|
|
}
|
|
|
|
Future<int> 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<Map<String, double>> 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<double> 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<Map<String, Object?>?> 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<List<Map<String, Object?>>> 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<void> archiveAccepted(List<SaleTransaction> 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<SaleTransaction>>{};
|
|
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
|
|
? <String, double>{}
|
|
: (jsonDecode(existing['payments_json']! as String)
|
|
as Map<String, Object?>)
|
|
.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<int> 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<List<Map<String, Object?>>> 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<void> markFailed(List<String> 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<void> 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<List<ParkedBill>> 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<String, Object?>,
|
|
),
|
|
),)
|
|
.toList();
|
|
}
|
|
|
|
Future<void> 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<AppliedPromo> _promosFromRow(String? json) {
|
|
if (json == null || json.isEmpty) return const [];
|
|
|
|
try {
|
|
return (jsonDecode(json) as List)
|
|
.cast<Map<String, Object?>>()
|
|
.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<List<SaleTransaction>> _query({
|
|
String? where,
|
|
List<Object?>? 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 = <String, List<Map<String, Object?>>>{};
|
|
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<String, Object?> o,
|
|
List<Map<String, Object?>> 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<String, Object?>>()
|
|
.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<double>(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<String, Object?> _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<String, Object?> j) {
|
|
final customerRow = j['customer'] as Map<String, Object?>?;
|
|
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<String, Object?>>().map((l) {
|
|
return CartLine(
|
|
product: CatalogueDao.productFromRow(
|
|
l['product']! as Map<String, Object?>,
|
|
),
|
|
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(),
|
|
);
|
|
}
|
|
}
|