added product import and billing integration

This commit is contained in:
2026-08-05 18:15:21 +05:30
parent 33b4337933
commit 6c0266c9c7
32 changed files with 889 additions and 457 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,
},

View File

@@ -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;

View File

@@ -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

View File

@@ -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);