added product import and billing integration
This commit is contained in:
@@ -146,6 +146,18 @@ class LocalStore {
|
||||
String? get catalogueRevision => _catalogueRevision;
|
||||
int get unsyncedOrders => _unsyncedOrders;
|
||||
|
||||
/// Drops the imported catalogue from disk and from the in-memory cache.
|
||||
///
|
||||
/// Called at sign-out so the next shift never bills against a copy left
|
||||
/// over from this one — [hasCatalogue] goes back to false, and the only way
|
||||
/// to sell again is a fresh pull from the back office.
|
||||
Future<void> clearCatalogue() async {
|
||||
await catalogue.clearCatalogue();
|
||||
_products.clear();
|
||||
_lastImportAt = null;
|
||||
_catalogueRevision = null;
|
||||
}
|
||||
|
||||
Future<void> importCatalogue({
|
||||
required List<Product> products,
|
||||
required List<Customer> customers,
|
||||
|
||||
@@ -239,6 +239,24 @@ class CatalogueDao {
|
||||
});
|
||||
}
|
||||
|
||||
/// Drops every product and forgets when the catalogue was last imported.
|
||||
///
|
||||
/// Used at sign-out. Leaves customers, staff, orders and every other table
|
||||
/// untouched — this is about the shelf, not the terminal's history — so the
|
||||
/// next session starts with nothing to sell until it pulls a fresh copy from
|
||||
/// the back office rather than carrying over whatever this session ended
|
||||
/// with.
|
||||
Future<void> clearCatalogue() async {
|
||||
await _db.transaction((txn) async {
|
||||
await txn.delete(Tables.products);
|
||||
await txn.delete(
|
||||
Tables.meta,
|
||||
where: 'key IN (?, ?)',
|
||||
whereArgs: [MetaKeys.lastImportAt, MetaKeys.catalogueRevision],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies stock movement after a sale, clamped at zero.
|
||||
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||
if (quantities.isEmpty) return;
|
||||
|
||||
@@ -85,6 +85,46 @@ class OrderDao {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<void> voidSale({
|
||||
required String orderId,
|
||||
required Map<String, double> stockMovements,
|
||||
Map<String, Object?>? 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<void> _insertOrder(DatabaseExecutor txn, SaleTransaction t) async {
|
||||
final cart = t.cart;
|
||||
|
||||
|
||||
@@ -53,7 +53,13 @@ class TerminalIdentityStore {
|
||||
///
|
||||
/// The mint is idempotent: an existing device id is never replaced, so a
|
||||
/// terminal cannot silently change identity and orphan its own history.
|
||||
Future<TerminalIdentity> load({String defaultStoreId = 'store-01'}) async {
|
||||
///
|
||||
/// [defaultStoreId] matches the store this build's default HTTP endpoint
|
||||
/// serves — see `syncConfigProvider` — so a fresh terminal's first import
|
||||
/// pulls that store's real catalogue without anyone visiting Settings
|
||||
/// first. Settings → Connectivity & sync → Configure changes it per
|
||||
/// terminal from there.
|
||||
Future<TerminalIdentity> load({String defaultStoreId = '1135'}) async {
|
||||
var deviceId = await _catalogue.meta(MetaKeys.deviceId);
|
||||
var code = await _catalogue.meta(MetaKeys.terminalCode);
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ import 'catalogue_wire.dart';
|
||||
/// Pulls the catalogue from the back office over HTTP.
|
||||
///
|
||||
/// ```
|
||||
/// GET {base}/catalogue?since={revision}&page={n}
|
||||
/// GET {base}/catalogue?since={revision}&page={n}&page_size={pageSize}&store_id={storeId}
|
||||
/// Authorization: Bearer {apiKey}
|
||||
/// ```
|
||||
///
|
||||
/// Pages are 0-indexed — the first page requested is `page=0` — matching the
|
||||
/// back office's own convention rather than the more common 1-indexed one.
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "revision": "rev-8821",
|
||||
@@ -53,6 +56,10 @@ class HttpCatalogueSource implements CatalogueSource {
|
||||
/// connection.
|
||||
static const int maxPages = 200;
|
||||
|
||||
/// Rows requested per page. Sent as `page_size` on every request so the
|
||||
/// back office doesn't fall back to its own (smaller) default.
|
||||
static const int pageSize = 500;
|
||||
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
|
||||
@override
|
||||
@@ -77,7 +84,7 @@ class HttpCatalogueSource implements CatalogueSource {
|
||||
|
||||
var revision = since ?? '';
|
||||
var isDelta = false;
|
||||
var page = 1;
|
||||
var page = 0;
|
||||
|
||||
onProgress?.call(0.05, 'Contacting the back office…');
|
||||
|
||||
@@ -137,6 +144,7 @@ class HttpCatalogueSource implements CatalogueSource {
|
||||
queryParameters: {
|
||||
if (since != null && since.isNotEmpty) 'since': since,
|
||||
'page': '$page',
|
||||
'page_size': '$pageSize',
|
||||
'store_id': config.storeId,
|
||||
'terminal_id': config.terminalId,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../core/config/sync_config.dart';
|
||||
import 'order_transport.dart';
|
||||
@@ -13,6 +14,12 @@ import 'order_transport.dart';
|
||||
/// having as the route to bring up first, and as the fallback when a broker is
|
||||
/// unreachable but the internet is not.
|
||||
///
|
||||
/// ```
|
||||
/// POST {base}/orders
|
||||
/// { "schema": 1, "batch_id": "…", "store_id": "…", "terminal_id": "…",
|
||||
/// "orders": [ … ] }
|
||||
/// ```
|
||||
///
|
||||
/// The endpoint must answer with the ids it committed:
|
||||
///
|
||||
/// ```json
|
||||
@@ -29,6 +36,8 @@ class HttpOrderTransport implements OrderTransport {
|
||||
final SyncConfig config;
|
||||
final http.Client _client;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
final _connection = StreamController<bool>.broadcast();
|
||||
|
||||
bool _reachable = true;
|
||||
@@ -73,6 +82,16 @@ class HttpOrderTransport implements OrderTransport {
|
||||
|
||||
final uri = Uri.parse('${config.httpBaseUrl}/$path');
|
||||
|
||||
// Deterministic from the set of ids in this batch — not a fresh random
|
||||
// id per attempt — so a retry after a timeout (the same rows, because
|
||||
// nothing was marked sent) carries the exact same batch_id as the
|
||||
// attempt that may already have landed. That is what lets the back
|
||||
// office collapse a retried batch server-side instead of re-billing it.
|
||||
final batchId = _uuid.v5(
|
||||
Uuid.NAMESPACE_URL,
|
||||
items.map((o) => o['id']).join('|'),
|
||||
);
|
||||
|
||||
http.Response response;
|
||||
try {
|
||||
response = await _client
|
||||
@@ -82,15 +101,13 @@ class HttpOrderTransport implements OrderTransport {
|
||||
'content-type': 'application/json',
|
||||
if (config.apiKey != null)
|
||||
'authorization': 'Bearer ${config.apiKey}',
|
||||
// Lets the endpoint collapse a retried batch server-side rather
|
||||
// than relying on every order id being checked individually.
|
||||
'idempotency-key': _batchKey(items),
|
||||
'idempotency-key': batchId,
|
||||
},
|
||||
body: jsonEncode({
|
||||
'schema': 1,
|
||||
'batch_id': batchId,
|
||||
'store_id': config.storeId,
|
||||
'terminal_id': config.terminalId,
|
||||
'sent_at': DateTime.now().toIso8601String(),
|
||||
key: items,
|
||||
}),
|
||||
)
|
||||
@@ -143,11 +160,6 @@ class HttpOrderTransport implements OrderTransport {
|
||||
return PushReceipt(accepted: accepted, rejected: rejected);
|
||||
}
|
||||
|
||||
/// Stable for a given set of records, so a retry after a timeout carries the
|
||||
/// same key as the attempt that may already have landed.
|
||||
String _batchKey(List<Map<String, Object?>> items) =>
|
||||
items.map((o) => o['id']).join('|').hashCode.toRadixString(16);
|
||||
|
||||
void _setReachable(bool value) {
|
||||
if (_reachable == value) return;
|
||||
_reachable = value;
|
||||
|
||||
@@ -437,12 +437,12 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
'registered_by_terminal': _store.terminal.code,
|
||||
};
|
||||
|
||||
/// The JSON body sent per order.
|
||||
/// The JSON body sent per order. Matches the back office's `/orders`
|
||||
/// schema field for field — nothing added beyond it.
|
||||
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
||||
'id': t.id,
|
||||
'invoice_number': t.invoiceNumber,
|
||||
'created_at': t.createdAt.toIso8601String(),
|
||||
'terminal_id': t.terminalId,
|
||||
'cashier': t.cashierName,
|
||||
'customer': t.customer == null
|
||||
? null
|
||||
@@ -453,15 +453,6 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
},
|
||||
'subtotal': t.cart.subtotal,
|
||||
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
|
||||
'promos': [
|
||||
for (final applied in t.cart.appliedPromos)
|
||||
{
|
||||
'id': applied.promo.id,
|
||||
'name': applied.promo.name,
|
||||
'type': applied.promo.type.name,
|
||||
'amount': applied.amount,
|
||||
},
|
||||
],
|
||||
'tax': t.cart.taxAmount,
|
||||
// GST per slab, as printed on the invoice. Sent as well as the total
|
||||
// because a compliant tax return is filed per slab, and recomputing the
|
||||
|
||||
@@ -30,6 +30,35 @@ class TransactionRepositoryImpl implements TransactionRepository {
|
||||
await _store.refreshUnsyncedCount();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> voidSale({
|
||||
required SaleTransaction transaction,
|
||||
required Map<String, double> stockMovements,
|
||||
}) async {
|
||||
// The customer attached to the cart is the pre-sale snapshot — commitSale
|
||||
// never mutates it, only the separate `updatedCustomer` it computed — so
|
||||
// restoring exactly this row undoes the loyalty movement precisely,
|
||||
// rather than trying to reconstruct it from a delta.
|
||||
final preSaleCustomer = transaction.cart.customer;
|
||||
|
||||
await _store.orders.voidSale(
|
||||
orderId: transaction.id,
|
||||
stockMovements: stockMovements,
|
||||
customerRow: preSaleCustomer == null
|
||||
? null
|
||||
: CatalogueDao.customerToRow(preSaleCustomer),
|
||||
);
|
||||
|
||||
// Disk is reverted; bring the read caches back in line with it. Negating
|
||||
// the same map reuses cacheStockMovement's "subtract" semantics to add
|
||||
// the stock back instead.
|
||||
_store.cacheStockMovement(
|
||||
stockMovements.map((id, qty) => MapEntry(id, -qty)),
|
||||
);
|
||||
if (preSaleCustomer != null) _store.cacheCustomer(preSaleCustomer);
|
||||
await _store.refreshUnsyncedCount();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaleTransaction>> history({int limit = 50}) =>
|
||||
_store.orders.recent(limit: limit);
|
||||
|
||||
Reference in New Issue
Block a user