feat: make server own products
This commit is contained in:
@@ -63,16 +63,19 @@ acknowledge and ignore. Acknowledge it like any other event; it doesn't appear i
|
||||
The tablet sends `stock_set` when a product is created (unless untracked), when the default products are seeded, and
|
||||
when its stock or tracking is changed by hand.
|
||||
|
||||
### 3. Catalog (`products` in the request)
|
||||
### 3. Catalog: managed on the dashboard
|
||||
|
||||
- Keep upserting `name`, `category` and `priceInCents` per reported product.
|
||||
- **Ignore `stock`** in the report once the server owns the stock (keep accepting the field; older servers use it).
|
||||
- A product the server sees for the first time is created with `stock = null` (untracked). Its starting stock arrives
|
||||
as a `stock_set` event in the same request.
|
||||
- **Stop removing products that one tablet doesn't report**: with several tablets, tablet B not knowing a product of
|
||||
tablet A must not delete A's stock. Deleting or hiding products becomes a dashboard action (out of scope here).
|
||||
|
||||
Process `products` **before** the events of the same request, so a `stock_set` for a new product finds it.
|
||||
- The dashboard adds, changes and archives products (archived = deleted for the tablets, kept for tabs and history).
|
||||
A product added with a starting count is tracked from that moment.
|
||||
- The tablet's `products` report is only used on the first sync of the venue, to take over its catalog and stock once.
|
||||
After that it is ignored, also its `stock`.
|
||||
- The sync response sends `catalog`: every product of the venue,
|
||||
`[{ id, name, category, priceInCents, lowStockThreshold, archived }]`. The tablet adds and updates its products from
|
||||
it, and hides archived ones and ones the catalog doesn't list (such as the default products of a newly paired
|
||||
tablet). Leaving the key out means "the tablet manages its own catalog".
|
||||
- A paired tablet can't add, rename, reprice or delete products; it still counts stock (`stock_set`) and keeps its own
|
||||
product pictures until pictures are managed on the dashboard.
|
||||
- A `stock_set` for a product the venue doesn't have is acknowledged and ignored.
|
||||
|
||||
### 4. Dashboard stock adjustments
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../models/remote_command.dart';
|
||||
import 'app_database.dart';
|
||||
|
||||
/// The product catalog as the website manages it.
|
||||
extension CatalogQueries on AppDatabase {
|
||||
/// Makes the local products match [catalog]: new products are added,
|
||||
/// changed ones updated, archived ones and products the server doesn't
|
||||
/// know (such as the default products of a new tablet) are hidden. Hidden,
|
||||
/// not deleted: open tabs and the history refer to them.
|
||||
///
|
||||
/// The stock is not touched here: the stock rebase that follows sets it, a
|
||||
/// new product starts as untracked until then. Local images are kept.
|
||||
Future<void> applyServerCatalog(List<ServerProduct> catalog) {
|
||||
return transaction(() async {
|
||||
final local = {
|
||||
for (final row in await select(products).get()) row.id: row,
|
||||
};
|
||||
|
||||
for (final product in catalog) {
|
||||
final category = product.category ?? '';
|
||||
final lowStockThreshold = product.lowStockThreshold ?? 0;
|
||||
final existing = local.remove(product.id);
|
||||
|
||||
if (existing == null) {
|
||||
await into(products).insert(
|
||||
ProductsCompanion.insert(
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
category: category,
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: const Value(true),
|
||||
priceInCents: product.priceInCents,
|
||||
active: Value(!product.archived),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.name == product.name &&
|
||||
existing.category == category &&
|
||||
existing.priceInCents == product.priceInCents &&
|
||||
existing.lowStockThreshold == lowStockThreshold &&
|
||||
existing.active == !product.archived) {
|
||||
continue;
|
||||
}
|
||||
await (update(
|
||||
products,
|
||||
)..where((row) => row.id.equals(product.id))).write(
|
||||
ProductsCompanion(
|
||||
name: Value(product.name),
|
||||
category: Value(category),
|
||||
priceInCents: Value(product.priceInCents),
|
||||
lowStockThreshold: Value(lowStockThreshold),
|
||||
active: Value(!product.archived),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final unknown = [
|
||||
for (final row in local.values)
|
||||
if (row.active) row.id,
|
||||
];
|
||||
if (unknown.isNotEmpty) {
|
||||
await (update(products)..where((row) => row.id.isIn(unknown))).write(
|
||||
const ProductsCompanion(active: Value(false)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"barTabs": "Bar Tabs",
|
||||
"products": "Products",
|
||||
"addProduct": "Add product",
|
||||
"productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.",
|
||||
"noProductsYet": "No products yet.",
|
||||
"addFirstProduct": "Add your first product to start selling.",
|
||||
"manageProducts": "Manage products",
|
||||
@@ -276,6 +277,10 @@
|
||||
"simulatedUpdateNotes": "Bug fixes and performance improvements.",
|
||||
"remoteActivity": "Remote activity",
|
||||
"remoteActivityHistory": "Recent remote activity",
|
||||
"offline": "Offline",
|
||||
"offlineDescription": "No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.",
|
||||
"syncProblem": "Sync problem",
|
||||
"syncProblemDescription": "Changes from the website don't reach this tablet. Error:",
|
||||
"syncNow": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"lastSync": "Last sync: {time}",
|
||||
|
||||
@@ -122,6 +122,12 @@ abstract class AppLocalizations {
|
||||
/// **'Add product'**
|
||||
String get addProduct;
|
||||
|
||||
/// No description provided for @productsManagedOnWebsite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Products are managed on the website. Here you can change the stock and the picture.'**
|
||||
String get productsManagedOnWebsite;
|
||||
|
||||
/// No description provided for @noProductsYet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1640,6 +1646,30 @@ abstract class AppLocalizations {
|
||||
/// **'Recent remote activity'**
|
||||
String get remoteActivityHistory;
|
||||
|
||||
/// No description provided for @offline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline'**
|
||||
String get offline;
|
||||
|
||||
/// No description provided for @offlineDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.'**
|
||||
String get offlineDescription;
|
||||
|
||||
/// No description provided for @syncProblem.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync problem'**
|
||||
String get syncProblem;
|
||||
|
||||
/// No description provided for @syncProblemDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Changes from the website don\'t reach this tablet. Error:'**
|
||||
String get syncProblemDescription;
|
||||
|
||||
/// No description provided for @syncNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -20,6 +20,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get addProduct => 'Add product';
|
||||
|
||||
@override
|
||||
String get productsManagedOnWebsite =>
|
||||
'Products are managed on the website. Here you can change the stock and the picture.';
|
||||
|
||||
@override
|
||||
String get noProductsYet => 'No products yet.';
|
||||
|
||||
@@ -846,6 +850,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get remoteActivityHistory => 'Recent remote activity';
|
||||
|
||||
@override
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get offlineDescription =>
|
||||
'No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.';
|
||||
|
||||
@override
|
||||
String get syncProblem => 'Sync problem';
|
||||
|
||||
@override
|
||||
String get syncProblemDescription =>
|
||||
'Changes from the website don\'t reach this tablet. Error:';
|
||||
|
||||
@override
|
||||
String get syncNow => 'Sync now';
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get addProduct => 'Product toevoegen';
|
||||
|
||||
@override
|
||||
String get productsManagedOnWebsite =>
|
||||
'Producten beheer je op de website. Hier pas je de voorraad en de foto aan.';
|
||||
|
||||
@override
|
||||
String get noProductsYet => 'Nog geen producten.';
|
||||
|
||||
@@ -858,6 +862,20 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get remoteActivityHistory => 'Recente externe activiteit';
|
||||
|
||||
@override
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get offlineDescription =>
|
||||
'Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.';
|
||||
|
||||
@override
|
||||
String get syncProblem => 'Synchronisatieprobleem';
|
||||
|
||||
@override
|
||||
String get syncProblemDescription =>
|
||||
'Wijzigingen van de website komen niet op deze tablet aan. Fout:';
|
||||
|
||||
@override
|
||||
String get syncNow => 'Nu synchroniseren';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"barTabs": "Poefs",
|
||||
"products": "Producten",
|
||||
"addProduct": "Product toevoegen",
|
||||
"productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.",
|
||||
"noProductsYet": "Nog geen producten.",
|
||||
"addFirstProduct": "Voeg je eerste product toe om te beginnen.",
|
||||
"manageProducts": "Producten beheren",
|
||||
@@ -276,6 +277,10 @@
|
||||
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.",
|
||||
"remoteActivity": "Externe activiteit",
|
||||
"remoteActivityHistory": "Recente externe activiteit",
|
||||
"offline": "Offline",
|
||||
"offlineDescription": "Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.",
|
||||
"syncProblem": "Synchronisatieprobleem",
|
||||
"syncProblemDescription": "Wijzigingen van de website komen niet op deze tablet aan. Fout:",
|
||||
"syncNow": "Nu synchroniseren",
|
||||
"syncing": "Bezig met synchroniseren…",
|
||||
"lastSync": "Laatste sync: {time}",
|
||||
|
||||
@@ -157,6 +157,40 @@ class ServerStock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A product as the website manages it.
|
||||
class ServerProduct {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? category;
|
||||
final int priceInCents;
|
||||
|
||||
/// Null: no low stock warning.
|
||||
final int? lowStockThreshold;
|
||||
|
||||
/// Deleted on the website: hidden, kept for open tabs and the history.
|
||||
final bool archived;
|
||||
|
||||
const ServerProduct({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.category,
|
||||
required this.priceInCents,
|
||||
this.lowStockThreshold,
|
||||
this.archived = false,
|
||||
});
|
||||
|
||||
factory ServerProduct.fromJson(Map<String, dynamic> json) {
|
||||
return ServerProduct(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
category: json['category'] as String?,
|
||||
priceInCents: json['priceInCents'] as int,
|
||||
lowStockThreshold: json['lowStockThreshold'] as int?,
|
||||
archived: json['archived'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncResponse {
|
||||
final List<RemoteCommand> commands;
|
||||
final String? cursor;
|
||||
@@ -183,6 +217,10 @@ class SyncResponse {
|
||||
/// sales and applies [stockAdjustments] itself.
|
||||
final List<ServerStock>? stock;
|
||||
|
||||
/// Every product of the venue, managed on the website. Null from a server
|
||||
/// that doesn't manage the catalog; the tablet then manages its own.
|
||||
final List<ServerProduct>? catalog;
|
||||
|
||||
const SyncResponse({
|
||||
required this.commands,
|
||||
this.cursor,
|
||||
@@ -196,6 +234,7 @@ class SyncResponse {
|
||||
this.settlements = const [],
|
||||
this.settlementsCursor,
|
||||
this.stock,
|
||||
this.catalog,
|
||||
});
|
||||
|
||||
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||
@@ -251,6 +290,13 @@ class SyncResponse {
|
||||
],
|
||||
_ => null,
|
||||
},
|
||||
catalog: switch (json['catalog']) {
|
||||
final List<dynamic> catalog => [
|
||||
for (final product in catalog)
|
||||
ServerProduct.fromJson(product as Map<String, dynamic>),
|
||||
],
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,10 +437,15 @@ class RemoteSyncEvent {
|
||||
final String? requestedBy;
|
||||
final String? message;
|
||||
|
||||
/// A failed sync that only lacked a connection: the bar keeps working and
|
||||
/// everything is sent once it is back.
|
||||
final bool offline;
|
||||
|
||||
const RemoteSyncEvent({
|
||||
required this.type,
|
||||
this.tabCustomerName,
|
||||
this.requestedBy,
|
||||
this.message,
|
||||
this.offline = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ class TabEventFactory {
|
||||
customerName: customerName,
|
||||
fields: {
|
||||
'lineId': lineId,
|
||||
'productId': productId,
|
||||
// Lines taken over from a tablet's own tabs have no product: the
|
||||
// server then matches them by name and price, and refuses an empty id
|
||||
if (productId.isNotEmpty) 'productId': productId,
|
||||
'productName': productName,
|
||||
'unitPriceInCents': unitPriceInCents,
|
||||
'quantity': quantity,
|
||||
|
||||
@@ -461,68 +461,87 @@ class DriftBarTabService implements BarTabService {
|
||||
final tabRow = await _getTabRow(tabId);
|
||||
if (tabRow == null) throw TabNotFoundException(tabId);
|
||||
|
||||
// A merge on the server can leave two lines of one product at
|
||||
// different prices; new drinks go on the oldest
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabId) &
|
||||
item.productId.equals(product.id),
|
||||
)
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (existingItem != null) {
|
||||
await (database.update(
|
||||
database.tabItems,
|
||||
)..where((item) => item.id.equals(existingItem.id))).write(
|
||||
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
||||
);
|
||||
await _recordPurchase(existingItem.id);
|
||||
// The line keeps the price it was first added at
|
||||
await _recordLineChange(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: existingItem.id,
|
||||
productId: existingItem.productId,
|
||||
productName: existingItem.productName,
|
||||
unitPriceInCents: existingItem.unitPriceInCents,
|
||||
delta: 1,
|
||||
);
|
||||
} else {
|
||||
final tabItemId = _uuid.v4();
|
||||
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: tabItemId,
|
||||
tabId: tabId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: 1,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await _recordPurchase(tabItemId);
|
||||
await _recordLineChange(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: tabItemId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
delta: 1,
|
||||
);
|
||||
}
|
||||
|
||||
await _addDrinks(
|
||||
tabRow,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
quantity: 1,
|
||||
);
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Puts [quantity] drinks on the tab, charged [unitPriceInCents]: on the
|
||||
/// line of that product, name and price, or a new line when there is none.
|
||||
/// Every new drink goes through here, so it is always charged the current
|
||||
/// price: after a price or name change on the website the drinks already
|
||||
/// on the tab keep what they were rung up at, and new ones start a new
|
||||
/// line. Of several matching lines (a merge on the server) the oldest gets
|
||||
/// them.
|
||||
Future<void> _addDrinks(
|
||||
BarTabRow tabRow, {
|
||||
required String productId,
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabRow.id) &
|
||||
item.productId.equals(productId) &
|
||||
item.unitPriceInCents.equals(unitPriceInCents) &
|
||||
item.productName.equals(productName),
|
||||
)
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
final String lineId;
|
||||
if (existingItem != null) {
|
||||
lineId = existingItem.id;
|
||||
await (database.update(
|
||||
database.tabItems,
|
||||
)..where((item) => item.id.equals(lineId))).write(
|
||||
TabItemsCompanion(quantity: Value(existingItem.quantity + quantity)),
|
||||
);
|
||||
} else {
|
||||
lineId = _uuid.v4();
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: lineId,
|
||||
tabId: tabRow.id,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
quantity: quantity,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (var index = 0; index < quantity; index++) {
|
||||
await _recordPurchase(lineId);
|
||||
}
|
||||
await _recordLineChange(
|
||||
tabId: tabRow.id,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
delta: quantity,
|
||||
);
|
||||
}
|
||||
|
||||
/// Taking drinks off changes [tabItemId] itself. Adding is ringing up new
|
||||
/// drinks of its product, at the current price: on this line while its
|
||||
/// price and name are still current, else on the line at the current price
|
||||
/// (see [_addDrinks]). Returns how many were added (positive) or taken off.
|
||||
@override
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
@@ -537,19 +556,31 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
if (item == null) return 0;
|
||||
|
||||
if (delta > 0) {
|
||||
final tabRow = await _getTabRow(item.tabId);
|
||||
if (tabRow == null) return 0;
|
||||
|
||||
// Hidden products still count: the drink is on the tab already
|
||||
final product = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(item.productId))).getSingleOrNull();
|
||||
await _addDrinks(
|
||||
tabRow,
|
||||
productId: item.productId,
|
||||
productName: product?.name ?? item.productName,
|
||||
unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents,
|
||||
quantity: delta,
|
||||
);
|
||||
await _incrementTabVersion(item.tabId);
|
||||
return delta;
|
||||
}
|
||||
|
||||
final newQuantity = max(item.quantity + delta, 0);
|
||||
final actualDelta = newQuantity - item.quantity;
|
||||
|
||||
if (actualDelta == 0) return 0;
|
||||
|
||||
if (actualDelta > 0) {
|
||||
for (var index = 0; index < actualDelta; index++) {
|
||||
await _recordPurchase(item.id);
|
||||
}
|
||||
} else {
|
||||
await _removeLatestPurchases(item.id, -actualDelta);
|
||||
}
|
||||
|
||||
await _removeLatestPurchases(item.id, -actualDelta);
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/catalog_queries.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/payment_method.dart';
|
||||
@@ -19,6 +20,16 @@ import 'kooltap_api_client.dart';
|
||||
|
||||
typedef _Credentials = ({String deviceId, String token});
|
||||
|
||||
/// Parts of a sync response the tablet could not apply; the rest was applied.
|
||||
class SyncApplyException implements Exception {
|
||||
final List<String> failures;
|
||||
|
||||
const SyncApplyException(this.failures);
|
||||
|
||||
@override
|
||||
String toString() => failures.join('\n');
|
||||
}
|
||||
|
||||
typedef _CommandOutcome = ({CommandResult result, String? message});
|
||||
|
||||
class RemoteSyncService {
|
||||
@@ -225,7 +236,8 @@ class RemoteSyncService {
|
||||
} on RateLimitedException catch (e) {
|
||||
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
|
||||
} catch (e, stack) {
|
||||
if (!_isConnectivityError(e)) {
|
||||
// A failed part was reported when it failed
|
||||
if (!_isConnectivityError(e) && e is! SyncApplyException) {
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
}
|
||||
nextDelay = await _recordFailure(e);
|
||||
@@ -252,7 +264,13 @@ class RemoteSyncService {
|
||||
debugPrint('RemoteSyncService: sync failed: $error');
|
||||
_consecutiveFailures++;
|
||||
await _updateSyncState(success: false);
|
||||
_emit(RemoteSyncEventType.syncFailed, message: error.toString());
|
||||
_eventController.add(
|
||||
RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.syncFailed,
|
||||
message: error.toString(),
|
||||
offline: _isConnectivityError(error),
|
||||
),
|
||||
);
|
||||
|
||||
return retryAfter ?? _backoff();
|
||||
}
|
||||
@@ -293,18 +311,46 @@ class RemoteSyncService {
|
||||
|
||||
await _clearSentEvents(response);
|
||||
await _markStockResultsReceived(response);
|
||||
await barTabService.recordSettlements(response.settlements);
|
||||
|
||||
// Each part is applied on its own: one that fails (a tab or settlement
|
||||
// the tablet can't store) must not keep prices and stock from arriving
|
||||
final failures = <String>[];
|
||||
final settlementsApplied = await _applyPart(
|
||||
'settlements',
|
||||
failures,
|
||||
() => barTabService.recordSettlements(response.settlements),
|
||||
);
|
||||
|
||||
final serverTabs = response.tabs;
|
||||
if (serverTabs != null) {
|
||||
// After the acked events left the outbox: what is left still has to
|
||||
// reach the server and is replayed on top
|
||||
await barTabService.applyServerState(serverTabs);
|
||||
await _applyPart(
|
||||
'tabs',
|
||||
failures,
|
||||
() => barTabService.applyServerState(serverTabs),
|
||||
);
|
||||
}
|
||||
|
||||
// The website manages the products; first, so new ones get their stock
|
||||
final catalog = response.catalog;
|
||||
if (catalog != null) {
|
||||
await _applyPart(
|
||||
'products',
|
||||
failures,
|
||||
() => database.applyServerCatalog(catalog),
|
||||
);
|
||||
}
|
||||
|
||||
// Same rebase for the stock: the server counts every tablet's sales
|
||||
final serverStock = response.stock;
|
||||
if (serverStock != null) await database.rebaseStock(serverStock);
|
||||
if (serverStock != null) {
|
||||
await _applyPart(
|
||||
'stock',
|
||||
failures,
|
||||
() => database.rebaseStock(serverStock),
|
||||
);
|
||||
}
|
||||
|
||||
for (final command in response.commands) {
|
||||
await _processCommand(command, credentials);
|
||||
@@ -329,15 +375,35 @@ class RemoteSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements that weren't stored are asked for again next time
|
||||
await _updateSyncState(
|
||||
success: true,
|
||||
success: failures.isEmpty,
|
||||
cursor: response.cursor,
|
||||
settlementsCursor: response.settlementsCursor,
|
||||
settlementsCursor: settlementsApplied ? response.settlementsCursor : null,
|
||||
);
|
||||
if (failures.isNotEmpty) throw SyncApplyException(failures);
|
||||
|
||||
return appliedStock;
|
||||
}
|
||||
|
||||
/// Runs one part of applying a sync response. Returns whether it worked; a
|
||||
/// failure is reported and added to [failures] instead of thrown.
|
||||
Future<bool> _applyPart(
|
||||
String part,
|
||||
List<String> failures,
|
||||
Future<void> Function() apply,
|
||||
) async {
|
||||
try {
|
||||
await apply();
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
debugPrint('RemoteSyncService: applying $part failed: $e');
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
failures.add('$part: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TabEventRow>> _pendingEvents() {
|
||||
return (database.select(database.tabEvents)
|
||||
..orderBy([
|
||||
|
||||
@@ -35,6 +35,11 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
String? _lastError;
|
||||
String? get lastError => _lastError;
|
||||
|
||||
bool _isOffline = false;
|
||||
|
||||
/// The last sync failed for lack of a connection, not because of an error.
|
||||
bool get isOffline => _isOffline;
|
||||
|
||||
String? _venueId;
|
||||
String? get venueId => _venueId;
|
||||
|
||||
@@ -109,8 +114,10 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
case RemoteSyncEventType.syncCompleted:
|
||||
_lastSyncAt = DateTime.now();
|
||||
_lastError = null;
|
||||
_isOffline = false;
|
||||
case RemoteSyncEventType.syncFailed:
|
||||
_lastError = event.message;
|
||||
_isOffline = event.offline;
|
||||
case RemoteSyncEventType.unauthorized:
|
||||
_isConfigured = false;
|
||||
_wasRevoked = true;
|
||||
|
||||
@@ -90,6 +90,23 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showSyncProblem({required String title, required String description}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: SelectableText(description),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(l10n.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _logout() {
|
||||
context.read<PinLockViewModel>().lock();
|
||||
context.read<AdminPinViewModel>().lock();
|
||||
@@ -142,6 +159,32 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
icon: const Icon(Icons.cloud_off_rounded),
|
||||
),
|
||||
],
|
||||
// A failing sync is otherwise invisible: the bar keeps working
|
||||
// offline while nothing from the website arrives
|
||||
if (context.watch<RemoteSyncViewModel>().lastError
|
||||
case final error?) ...[
|
||||
const SizedBox(width: 6),
|
||||
// Offline is normal for a bar; anything else needs attention
|
||||
if (context.watch<RemoteSyncViewModel>().isOffline)
|
||||
IconButton(
|
||||
tooltip: l10n.offline,
|
||||
onPressed: () => _showSyncProblem(
|
||||
title: l10n.offline,
|
||||
description: l10n.offlineDescription,
|
||||
),
|
||||
icon: const Icon(Icons.wifi_off_rounded),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: '${l10n.syncProblem}: $error',
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: () => _showSyncProblem(
|
||||
title: l10n.syncProblem,
|
||||
description: '${l10n.syncProblemDescription}\n\n$error',
|
||||
),
|
||||
icon: const Icon(Icons.sync_problem_rounded),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.refresh,
|
||||
|
||||
@@ -12,6 +12,8 @@ import '../models/product.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'widgets/managed_on_website_notice.dart';
|
||||
|
||||
class ProductFormView extends StatefulWidget {
|
||||
final String? productId;
|
||||
@@ -313,6 +315,9 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final title = widget.isEditing ? l10n.editProduct : l10n.addProduct;
|
||||
// A paired tablet gets its products from the website: only the stock and
|
||||
// the picture are changed here
|
||||
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -322,7 +327,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
onPressed: _leaveProductForm,
|
||||
),
|
||||
actions: [
|
||||
if (widget.isEditing)
|
||||
if (widget.isEditing && !managedOnWebsite)
|
||||
IconButton(
|
||||
onPressed: _delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
@@ -342,10 +347,15 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
if (managedOnWebsite) ...[
|
||||
const ManagedOnWebsiteNotice(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildImagePicker(context),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
enabled: !managedOnWebsite,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.productName,
|
||||
border: OutlineInputBorder(),
|
||||
@@ -366,6 +376,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
builder: (context, constraints) {
|
||||
return DropdownMenu<String>(
|
||||
width: constraints.maxWidth,
|
||||
enabled: !managedOnWebsite,
|
||||
initialSelection: _selectedCategory,
|
||||
enableFilter: true,
|
||||
enableSearch: true,
|
||||
@@ -395,6 +406,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _priceController,
|
||||
enabled: !managedOnWebsite,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.price,
|
||||
prefixText: '€ ',
|
||||
@@ -455,6 +467,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _lowStockController,
|
||||
enabled: !managedOnWebsite,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.lowStockThreshold,
|
||||
border: OutlineInputBorder(),
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../l10n/app_localizations_helpers.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'widgets/managed_on_website_notice.dart';
|
||||
|
||||
class ProductListView extends StatefulWidget {
|
||||
const ProductListView({super.key});
|
||||
@@ -31,6 +33,8 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
Widget build(BuildContext context) {
|
||||
final viewModel = context.watch<ProductListViewModel>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// A paired tablet gets its products from the website
|
||||
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -41,11 +45,13 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
title: Text(l10n.products),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/products/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l10n.addProduct),
|
||||
),
|
||||
floatingActionButton: managedOnWebsite
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/products/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l10n.addProduct),
|
||||
),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
if (viewModel.isLoading) {
|
||||
@@ -56,16 +62,19 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
return Center(child: Text(l10n.errorText(viewModel.loadError)));
|
||||
}
|
||||
|
||||
if (viewModel.products.isEmpty) {
|
||||
if (viewModel.products.isEmpty && !managedOnWebsite) {
|
||||
return Center(child: Text(l10n.noProductsYet));
|
||||
}
|
||||
|
||||
// The notice takes the first row
|
||||
final offset = managedOnWebsite ? 1 : 0;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: viewModel.products.length,
|
||||
itemCount: viewModel.products.length + offset,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final product = viewModel.products[index];
|
||||
if (index < offset) return const ManagedOnWebsiteNotice();
|
||||
final product = viewModel.products[index - offset];
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// Tells that the products of a paired tablet are managed on the website.
|
||||
class ManagedOnWebsiteNotice extends StatelessWidget {
|
||||
const ManagedOnWebsiteNotice({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
color: colors.secondaryContainer,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.language, color: colors.onSecondaryContainer),
|
||||
title: Text(
|
||||
AppLocalizations.of(context).productsManagedOnWebsite,
|
||||
style: TextStyle(color: colors.onSecondaryContainer),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: kooltap
|
||||
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.1+0
|
||||
version: 1.3.4+0
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@@ -245,6 +245,355 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// The website changes prices: drinks already on a tab keep theirs, the
|
||||
// next one is charged the new price
|
||||
group('addProductToTab after a price or name change', () {
|
||||
test(
|
||||
'a new price starts a new line; earlier drinks keep theirs',
|
||||
() async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final item in items) (item.unitPriceInCents, item.quantity),
|
||||
], unorderedEquals([(400, 2), (450, 1)]));
|
||||
expect((await service.getTabById(tab.id))!.totalInCents, 1250);
|
||||
},
|
||||
);
|
||||
|
||||
test('more drinks at the new price go on the new line', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
}
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect(items, hasLength(2));
|
||||
expect(
|
||||
items.firstWhere((item) => item.unitPriceInCents == 450).quantity,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('a price going back joins the line at that price again', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 300),
|
||||
);
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final item in items) (item.unitPriceInCents, item.quantity),
|
||||
], unorderedEquals([(400, 2), (300, 1)]));
|
||||
});
|
||||
|
||||
test('a renamed product starts a new line under its new name', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(name: 'Pils'),
|
||||
);
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final item in items) item.productName,
|
||||
], unorderedEquals(['Test Beer', 'Pils']));
|
||||
});
|
||||
|
||||
test('both lines count down the stock of the one product', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
final stored = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||
expect(stored.stockQuantity, 98);
|
||||
});
|
||||
|
||||
test('settling charges each line at its own price', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
await service.closeTab(tab.id);
|
||||
|
||||
final closed = (await service.getClosedTabs()).single;
|
||||
expect(closed.totalInCents, 850);
|
||||
expect([
|
||||
for (final item in closed.items) item.unitPriceInCents,
|
||||
], unorderedEquals([400, 450]));
|
||||
});
|
||||
});
|
||||
|
||||
// Foo has 2 at the old price; the website changes the price; staff tap +
|
||||
// on Foo's line. The 2 stay at the old price, every new one is charged
|
||||
// the new price.
|
||||
group('+ on a line after a price change', () {
|
||||
Future<void> setPrice(int priceInCents, {String? name}) async {
|
||||
await (database.update(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).write(
|
||||
ProductsCompanion(
|
||||
priceInCents: Value(priceInCents),
|
||||
name: name == null ? const Value.absent() : Value(name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> fooWithTwoAtOldPrice() async {
|
||||
final tab = await service.createTab(customerName: 'Foo');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await setPrice(450);
|
||||
return tab.id;
|
||||
}
|
||||
|
||||
Future<List<(int, int)>> lines(String tabId) async {
|
||||
return [
|
||||
for (final item in (await service.getTabById(tabId))!.items)
|
||||
(item.unitPriceInCents, item.quantity),
|
||||
];
|
||||
}
|
||||
|
||||
Future<String> lineAt(String tabId, int priceInCents) async {
|
||||
return (await service.getTabById(tabId))!.items
|
||||
.firstWhere((item) => item.unitPriceInCents == priceInCents)
|
||||
.id;
|
||||
}
|
||||
|
||||
test(
|
||||
'the old drinks keep their price, the new one gets the new price',
|
||||
() async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
final added = await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(added, 1);
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)]));
|
||||
expect((await service.getTabById(tabId))!.totalInCents, 1250);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'tapping + on the old line again keeps adding at the new price',
|
||||
() async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
final oldLine = await lineAt(tabId, 400);
|
||||
|
||||
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
|
||||
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
|
||||
},
|
||||
);
|
||||
|
||||
test('+ on the line at the new price adds there', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 450),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
|
||||
});
|
||||
|
||||
test('the tile and + end up on the same new line', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tabId,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
|
||||
});
|
||||
|
||||
test('+ several at once adds them all at the new price', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
final added = await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 3,
|
||||
);
|
||||
|
||||
expect(added, 3);
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 3)]));
|
||||
});
|
||||
|
||||
test('− on the old line takes off a drink at the old price', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: -1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 1), (450, 1)]));
|
||||
});
|
||||
|
||||
test('a price going back makes + add to the old line again', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await setPrice(400);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), [(400, 3)]);
|
||||
});
|
||||
|
||||
test('a renamed product gets a new line under its new name', () async {
|
||||
final tab = await service.createTab(customerName: 'Foo');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await setPrice(400, name: 'Pils');
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: (await service.getTabById(tab.id))!.items.single.id,
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
final names = [
|
||||
for (final item in (await service.getTabById(tab.id))!.items)
|
||||
(item.productName, item.quantity),
|
||||
];
|
||||
expect(names, unorderedEquals([('Test Beer', 1), ('Pils', 1)]));
|
||||
});
|
||||
|
||||
test(
|
||||
'a hidden (archived) product is still charged its latest price',
|
||||
() async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals('prod-1')))
|
||||
.write(const ProductsCompanion(active: Value(false)));
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)]));
|
||||
},
|
||||
);
|
||||
|
||||
test('a line whose product is gone keeps its own price', () async {
|
||||
final tab = await service.createTab(customerName: 'Foo');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await database.delete(database.products).go();
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: (await service.getTabById(tab.id))!.items.single.id,
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tab.id), [(400, 2)]);
|
||||
});
|
||||
|
||||
test('another tab without the product gets the new price', () async {
|
||||
await fooWithTwoAtOldPrice();
|
||||
final bar = await service.createTab(customerName: 'Bar');
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: bar.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
expect(await lines(bar.id), [(450, 1)]);
|
||||
});
|
||||
|
||||
test('every drink counts down the stock of the one product', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 2,
|
||||
);
|
||||
|
||||
final stored = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||
expect(stored.stockQuantity, 96);
|
||||
});
|
||||
});
|
||||
|
||||
group('adjustTabItemQuantity', () {
|
||||
test('updates item quantity', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// End to end against a running KoolTap server: two tablets pair, the website
|
||||
// adds and changes products, the tablets sell and sync over real HTTP.
|
||||
//
|
||||
// Skipped unless the server is given. Use a scratch database: this pairs two
|
||||
// tablets and adds products to the admin's venue.
|
||||
//
|
||||
// KOOLTAP_E2E_API=http://localhost:3101/api/v1 \
|
||||
// KOOLTAP_E2E_EMAIL=admin@example.com KOOLTAP_E2E_PASSWORD=... \
|
||||
// flutter test test/e2e
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:kooltap/database/app_database.dart';
|
||||
import 'package:kooltap/models/product.dart';
|
||||
import 'package:kooltap/models/remote_command.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/services/kooltap_api_client.dart';
|
||||
import 'package:kooltap/services/product_service.dart';
|
||||
import 'package:kooltap/services/remote_sync_service.dart';
|
||||
|
||||
import '../helpers/memory_secure_storage.dart';
|
||||
|
||||
final _api = Platform.environment['KOOLTAP_E2E_API'];
|
||||
final _skip = _api == null
|
||||
? 'Set KOOLTAP_E2E_API to run against a server'
|
||||
: false;
|
||||
|
||||
/// The website, through the same API the dashboard uses.
|
||||
class _Website {
|
||||
final String token;
|
||||
final String venueId;
|
||||
|
||||
_Website(this.token, this.venueId);
|
||||
|
||||
static Future<_Website> login() async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_api/auth/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'email': Platform.environment['KOOLTAP_E2E_EMAIL'],
|
||||
'password': Platform.environment['KOOLTAP_E2E_PASSWORD'],
|
||||
}),
|
||||
);
|
||||
final body = _decode(response) as Map<String, dynamic>;
|
||||
final user = body['user'] as Map<String, dynamic>;
|
||||
return _Website(body['accessToken'] as String, user['venueId'] as String);
|
||||
}
|
||||
|
||||
Future<Object?> call(String method, String path, [Object? body]) async {
|
||||
final request = http.Request(method, Uri.parse('$_api$path'))
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..headers['Content-Type'] = 'application/json';
|
||||
if (body != null) request.body = jsonEncode(body);
|
||||
return _decode(await http.Response.fromStream(await request.send()));
|
||||
}
|
||||
|
||||
Future<String> registrationCode() async {
|
||||
final body = await call('GET', '/devices/generation-code');
|
||||
return (body as Map<String, dynamic>)['code'] as String;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createProduct(
|
||||
Map<String, Object?> product,
|
||||
) async {
|
||||
return await call('POST', '/venues/$venueId/stock/products', product)
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> changeProduct(String id, Map<String, Object?> changes) {
|
||||
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
|
||||
}
|
||||
|
||||
Future<List<dynamic>> tabs() async {
|
||||
final body = await call('GET', '/venues/$venueId/tabs');
|
||||
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
|
||||
}
|
||||
|
||||
static Object? _decode(http.Response response) {
|
||||
if (response.statusCode >= 300) {
|
||||
throw StateError('${response.statusCode}: ${response.body}');
|
||||
}
|
||||
return response.body.isEmpty ? null : jsonDecode(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tablet with its own database, paired like the app pairs.
|
||||
class _Tablet {
|
||||
final AppDatabase database;
|
||||
final DriftBarTabService tabs;
|
||||
final DriftProductService products;
|
||||
final RemoteSyncService sync;
|
||||
final List<RemoteSyncEvent> _events = [];
|
||||
|
||||
_Tablet._(this.database, this.tabs, this.products, this.sync) {
|
||||
sync.events.listen(_events.add);
|
||||
}
|
||||
|
||||
static Future<_Tablet> pair(_Website website, String name) async {
|
||||
final database = AppDatabase(NativeDatabase.memory());
|
||||
final tabs = DriftBarTabService(database: database);
|
||||
final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!);
|
||||
final sync = RemoteSyncService(
|
||||
database: database,
|
||||
barTabService: tabs,
|
||||
apiClient: api,
|
||||
secureStorage: MemorySecureStorage(),
|
||||
);
|
||||
final credentials = await api.registerDevice(
|
||||
venueId: website.venueId,
|
||||
deviceName: name,
|
||||
registrationCode: await website.registrationCode(),
|
||||
);
|
||||
await sync.setCredentials(
|
||||
deviceId: credentials.deviceId,
|
||||
token: credentials.token,
|
||||
venueId: website.venueId,
|
||||
);
|
||||
return _Tablet._(
|
||||
database,
|
||||
tabs,
|
||||
DriftProductService(database: database),
|
||||
sync,
|
||||
);
|
||||
}
|
||||
|
||||
/// Syncs and fails the test when the sync failed.
|
||||
Future<void> syncNow() async {
|
||||
_events.clear();
|
||||
await sync.sync();
|
||||
// Sync events are delivered asynchronously
|
||||
await pumpEventQueue();
|
||||
final types = _events.map((e) => e.type).toList();
|
||||
expect(
|
||||
types,
|
||||
contains(RemoteSyncEventType.syncCompleted),
|
||||
reason: '$types ${_events.map((e) => e.message)}',
|
||||
);
|
||||
}
|
||||
|
||||
/// What the bar screen shows.
|
||||
Future<Product> product(String name) async {
|
||||
return (await products.getProducts()).singleWhere((p) => p.name == name);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
sync.dispose();
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _Website website;
|
||||
late _Tablet a;
|
||||
late _Tablet b;
|
||||
|
||||
setUpAll(() async {
|
||||
if (_api == null) return;
|
||||
// Two tablets, each with its own database, on purpose
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
website = await _Website.login();
|
||||
a = await _Tablet.pair(website, 'E2E tablet A');
|
||||
b = await _Tablet.pair(website, 'E2E tablet B');
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (_api == null) return;
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
|
||||
// One story: the steps depend on each other and run in order
|
||||
test(
|
||||
'products managed on the website, two tablets selling',
|
||||
() async {
|
||||
final suffix = DateTime.now().millisecondsSinceEpoch;
|
||||
final colaName = 'E2E Cola $suffix';
|
||||
|
||||
// 1. The website adds a product with its stock: both tablets get it
|
||||
final cola = await website.createProduct({
|
||||
'name': colaName,
|
||||
'category': 'Frisdrank',
|
||||
'priceInCents': 250,
|
||||
'stock': 24,
|
||||
'lowStockThreshold': 5,
|
||||
});
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
for (final tablet in [a, b]) {
|
||||
final product = await tablet.product(colaName);
|
||||
expect(product.id, cola['productId']);
|
||||
expect(product.priceInCents, 250);
|
||||
expect(product.stockQuantity, 24);
|
||||
expect(product.lowStockThreshold, 5);
|
||||
}
|
||||
|
||||
// 2. Tablet A sells one: tablet B sees the tab and the stock
|
||||
final alice = await a.tabs.createTab(customerName: 'E2E Alice $suffix');
|
||||
await a.tabs.addProductToTab(
|
||||
tabId: alice.id,
|
||||
product: await a.product(colaName),
|
||||
);
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
expect((await b.tabs.getTabById(alice.id))!.items.single.quantity, 1);
|
||||
expect((await b.product(colaName)).stockQuantity, 23);
|
||||
|
||||
// 3. The website changes the price: both tablets show it
|
||||
await website.changeProduct(cola['productId'] as String, {
|
||||
'priceInCents': 270,
|
||||
});
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
expect((await a.product(colaName)).priceInCents, 270);
|
||||
expect((await b.product(colaName)).priceInCents, 270);
|
||||
|
||||
// 4. The next drink is charged the new price; the first keeps its price
|
||||
await b.tabs.addProductToTab(
|
||||
tabId: alice.id,
|
||||
product: await b.product(colaName),
|
||||
);
|
||||
await b.syncNow();
|
||||
await a.syncNow();
|
||||
for (final tablet in [a, b]) {
|
||||
final lines = (await tablet.tabs.getTabById(alice.id))!.items;
|
||||
expect([
|
||||
for (final line in lines) (line.unitPriceInCents, line.quantity),
|
||||
], unorderedEquals([(250, 1), (270, 1)]));
|
||||
expect((await tablet.product(colaName)).stockQuantity, 22);
|
||||
}
|
||||
|
||||
// 4b. Tablet A taps + on Alice's line at the old price: that drink is
|
||||
// charged the new price too, the first one keeps the old price
|
||||
final oldLine = (await a.tabs.getTabById(
|
||||
alice.id,
|
||||
))!.items.firstWhere((line) => line.unitPriceInCents == 250);
|
||||
await a.tabs.adjustTabItemQuantity(tabItemId: oldLine.id, delta: 1);
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
for (final tablet in [a, b]) {
|
||||
final lines = (await tablet.tabs.getTabById(alice.id))!.items;
|
||||
expect([
|
||||
for (final line in lines) (line.unitPriceInCents, line.quantity),
|
||||
], unorderedEquals([(250, 1), (270, 2)]));
|
||||
expect((await tablet.product(colaName)).stockQuantity, 21);
|
||||
}
|
||||
final onWebsite = (await website.tabs()).singleWhere(
|
||||
(t) => t['tabId'] == alice.id,
|
||||
);
|
||||
expect(onWebsite['totalInCents'], 790);
|
||||
|
||||
// 5. A rename reaches the tablets under the same product
|
||||
final renamed = 'E2E Coca-Cola $suffix';
|
||||
await website.changeProduct(cola['productId'] as String, {
|
||||
'name': renamed,
|
||||
});
|
||||
await a.syncNow();
|
||||
expect((await a.product(renamed)).id, cola['productId']);
|
||||
|
||||
// 6. Archived: gone from the bar screen, still on Alice's tab
|
||||
await website.changeProduct(cola['productId'] as String, {
|
||||
'archived': true,
|
||||
});
|
||||
await a.syncNow();
|
||||
expect(
|
||||
(await a.products.getProducts()).where(
|
||||
(p) => p.id == cola['productId'],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect((await a.tabs.getTabById(alice.id))!.totalInCents, 790);
|
||||
|
||||
// 7. The website settles Alice: both tablets show it in the history once
|
||||
await website.call('POST', '/venues/${website.venueId}/commands', {
|
||||
'tabId': alice.id,
|
||||
'type': 'close_tab',
|
||||
'expectedVersion': onWebsite['version'],
|
||||
'paymentMethod': 'cash',
|
||||
});
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
await b.syncNow();
|
||||
for (final tablet in [a, b]) {
|
||||
expect((await tablet.tabs.getTabById(alice.id))!.items, isEmpty);
|
||||
final settled = (await tablet.tabs.getClosedTabs())
|
||||
.where((t) => t.originalTabId == alice.id)
|
||||
.toList();
|
||||
expect(settled, hasLength(1));
|
||||
expect(settled.single.totalInCents, 790);
|
||||
}
|
||||
|
||||
// 8. Restored, it is back on the bar screen with the stock it had
|
||||
await website.changeProduct(cola['productId'] as String, {
|
||||
'archived': false,
|
||||
});
|
||||
await b.syncNow();
|
||||
expect((await b.product(renamed)).stockQuantity, 21);
|
||||
},
|
||||
skip: _skip,
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,35 @@ void main() {
|
||||
];
|
||||
|
||||
group('sync', () {
|
||||
test(
|
||||
'a lost connection is reported as offline, a server error is not',
|
||||
() async {
|
||||
final offline = RemoteSyncService(
|
||||
database: database,
|
||||
barTabService: barTabService,
|
||||
apiClient: KoolTapApiClient(
|
||||
client: MockClient(
|
||||
(_) async => throw http.ClientException('Failed host lookup'),
|
||||
),
|
||||
baseUrl: _baseUrl,
|
||||
),
|
||||
secureStorage: storage,
|
||||
);
|
||||
final offlineEvents = <RemoteSyncEvent>[];
|
||||
offline.events.listen(offlineEvents.add);
|
||||
server.syncResponse = http.Response('oops', 500);
|
||||
|
||||
await offline.sync();
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(offlineEvents.single.type, RemoteSyncEventType.syncFailed);
|
||||
expect(offlineEvents.single.offline, isTrue);
|
||||
expect(events.single.offline, isFalse);
|
||||
offline.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('sends a snapshot of the open tabs and stores the cursor', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
@@ -1363,6 +1392,478 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('website manages the catalog', () {
|
||||
Map<String, dynamic> product(
|
||||
String id, {
|
||||
String name = 'Cola',
|
||||
String? category = 'Frisdrank',
|
||||
int price = 250,
|
||||
int? lowStockThreshold,
|
||||
bool archived = false,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'category': category,
|
||||
'priceInCents': price,
|
||||
'lowStockThreshold': lowStockThreshold,
|
||||
'archived': archived,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, ProductRow>> rows() async {
|
||||
return {
|
||||
for (final row in await database.select(database.products).get())
|
||||
row.id: row,
|
||||
};
|
||||
}
|
||||
|
||||
test('adds, changes and hides products like the website says', () async {
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals('prod-1')))
|
||||
.write(const ProductsCompanion(imagePath: Value('/beer.png')));
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: 'seeded',
|
||||
name: 'Seeded on this tablet',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 24,
|
||||
lowStockThreshold: 0,
|
||||
priceInCents: 100,
|
||||
),
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [
|
||||
product(
|
||||
'prod-1',
|
||||
name: 'Beer',
|
||||
category: null,
|
||||
price: 450,
|
||||
lowStockThreshold: 6,
|
||||
),
|
||||
product('cola'),
|
||||
product('old', name: 'Old', archived: true),
|
||||
],
|
||||
'stock': [
|
||||
{'productId': 'cola', 'stock': 24, 'countedAt': null},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
final products = await rows();
|
||||
expect(
|
||||
products['prod-1'],
|
||||
isA<ProductRow>()
|
||||
.having((p) => p.priceInCents, 'price', 450)
|
||||
.having((p) => p.category, 'category', '')
|
||||
.having((p) => p.lowStockThreshold, 'threshold', 6)
|
||||
.having((p) => p.imagePath, 'image', '/beer.png')
|
||||
.having((p) => p.active, 'active', isTrue),
|
||||
);
|
||||
// New, and its stock arrives in the same sync
|
||||
expect(
|
||||
products['cola'],
|
||||
isA<ProductRow>()
|
||||
.having((p) => p.name, 'name', 'Cola')
|
||||
.having((p) => p.stockQuantity, 'stock', 24)
|
||||
.having((p) => p.infiniteStock, 'untracked', isFalse),
|
||||
);
|
||||
expect(products['old']!.active, isFalse);
|
||||
expect(products['seeded']!.active, isFalse);
|
||||
});
|
||||
|
||||
test('a product that comes back is shown again', () async {
|
||||
server.respondWith({
|
||||
'catalog': [product('prod-1', archived: true)],
|
||||
});
|
||||
await service.sync();
|
||||
expect((await rows())['prod-1']!.active, isFalse);
|
||||
|
||||
server.respondWith({
|
||||
'catalog': [product('prod-1')],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
expect((await rows())['prod-1']!.active, isTrue);
|
||||
});
|
||||
|
||||
test('a server without a catalog leaves the products alone', () async {
|
||||
await service.sync();
|
||||
|
||||
expect(
|
||||
(await rows())['prod-1'],
|
||||
isA<ProductRow>()
|
||||
.having((p) => p.name, 'name', 'Beer')
|
||||
.having((p) => p.active, 'active', isTrue),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('website catalog changes reach the bar', () {
|
||||
late DriftProductService products;
|
||||
|
||||
setUp(() {
|
||||
products = DriftProductService(database: database);
|
||||
});
|
||||
|
||||
Map<String, dynamic> beer({
|
||||
int price = 400,
|
||||
String name = 'Beer',
|
||||
bool archived = false,
|
||||
int? lowStockThreshold = 10,
|
||||
}) {
|
||||
return {
|
||||
'id': 'prod-1',
|
||||
'name': name,
|
||||
'category': 'Drinks',
|
||||
'priceInCents': price,
|
||||
'lowStockThreshold': lowStockThreshold,
|
||||
'archived': archived,
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> sentLines(int request) => [
|
||||
for (final event in server.syncRequests[request]['events'] as List)
|
||||
if (event['type'] == 'line_added') event as Map<String, dynamic>,
|
||||
];
|
||||
|
||||
test(
|
||||
'the next drink after a price change is charged the new price',
|
||||
() async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: (await products.getProductById('prod-1'))!,
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
// What the bar screen hands to the tab: the product as stored now
|
||||
final shown = (await products.getProducts()).single;
|
||||
expect(shown.priceInCents, 450);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: shown);
|
||||
server.respondWith({});
|
||||
await service.sync();
|
||||
|
||||
final lines = (await barTabService.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final line in lines) (line.unitPriceInCents, line.quantity),
|
||||
], unorderedEquals([(400, 1), (450, 1)]));
|
||||
// Unacknowledged, so both drinks are sent again, each at its own price
|
||||
expect(
|
||||
[for (final line in sentLines(1)) line['unitPriceInCents']],
|
||||
[400, 450],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('Foo keeps 2 at the old price, + charges the new price', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Foo');
|
||||
final beerNow = (await products.getProductById('prod-1'))!;
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: beerNow);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: beerNow);
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
// Staff tap + on Foo's line
|
||||
final oldLine = (await barTabService.getTabById(tab.id))!.items.single;
|
||||
await barTabService.adjustTabItemQuantity(
|
||||
tabItemId: oldLine.id,
|
||||
delta: 1,
|
||||
);
|
||||
server.respondWith({});
|
||||
await service.sync();
|
||||
|
||||
final lines = (await barTabService.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final line in lines) (line.unitPriceInCents, line.quantity),
|
||||
], unorderedEquals([(400, 2), (450, 1)]));
|
||||
expect(sentLines(1).last['unitPriceInCents'], 450);
|
||||
});
|
||||
|
||||
test(
|
||||
'the products the bar screen watches change without a restart',
|
||||
() async {
|
||||
final emitted = <List<int>>[];
|
||||
final subscription = products.watchProducts().listen(
|
||||
(list) => emitted.add([for (final p in list) p.priceInCents]),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
});
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(emitted.last, [450]);
|
||||
await subscription.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'an unchanged catalog writes nothing, so the screen does not redraw',
|
||||
() async {
|
||||
server.respondWith({
|
||||
'catalog': [beer()],
|
||||
});
|
||||
await service.sync();
|
||||
var emissions = 0;
|
||||
final subscription = products.watchProducts().listen(
|
||||
(_) => emissions++,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
await service.sync();
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
// Only the first emission: the current list
|
||||
expect(emissions, 1);
|
||||
await subscription.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a renamed product is shown under its new name, old drinks keep theirs',
|
||||
() async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: (await products.getProductById('prod-1'))!,
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [beer(name: 'Pils')],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
final shown = (await products.getProducts()).single;
|
||||
expect(shown.name, 'Pils');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: shown);
|
||||
|
||||
final names = [
|
||||
for (final line in (await barTabService.getTabById(tab.id))!.items)
|
||||
line.productName,
|
||||
];
|
||||
expect(names, unorderedEquals(['Beer', 'Pils']));
|
||||
},
|
||||
);
|
||||
|
||||
test('a product added on the website can be sold right away', () async {
|
||||
server.respondWith({
|
||||
'catalog': [
|
||||
beer(),
|
||||
{
|
||||
'id': 'web-1',
|
||||
'name': 'Ice Tea',
|
||||
'category': 'Frisdrank',
|
||||
'priceInCents': 280,
|
||||
'lowStockThreshold': 5,
|
||||
'archived': false,
|
||||
},
|
||||
],
|
||||
'stock': [
|
||||
{'productId': 'web-1', 'stock': 24, 'countedAt': null},
|
||||
],
|
||||
});
|
||||
await service.sync();
|
||||
final iceTea = (await products.getProductById('web-1'))!;
|
||||
expect(iceTea.stockQuantity, 24);
|
||||
expect(iceTea.infiniteStock, isFalse);
|
||||
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: iceTea);
|
||||
server.respondWith({});
|
||||
await service.sync();
|
||||
|
||||
expect(sentLines(1).single, containsPair('productId', 'web-1'));
|
||||
expect(sentLines(1).single, containsPair('unitPriceInCents', 280));
|
||||
expect((await products.getProductById('web-1'))!.stockQuantity, 23);
|
||||
});
|
||||
|
||||
test(
|
||||
'an archived product leaves the bar screen but stays on open tabs',
|
||||
() async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: (await products.getProductById('prod-1'))!,
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [beer(archived: true)],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await products.getProducts(), isEmpty);
|
||||
expect(
|
||||
(await barTabService.getTabById(tab.id))!.items.single.quantity,
|
||||
1,
|
||||
);
|
||||
// The product form can still open it, e.g. from the history
|
||||
expect(await products.getProductById('prod-1'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('no warning level on the website is 0 on the tablet', () async {
|
||||
server.respondWith({
|
||||
'catalog': [beer(lowStockThreshold: null)],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect((await products.getProductById('prod-1'))!.lowStockThreshold, 0);
|
||||
});
|
||||
|
||||
test('a price change leaves the lines of the server tabs alone', () async {
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
'tabs': [
|
||||
{
|
||||
'id': 'tab-1',
|
||||
'customerName': 'Alice',
|
||||
'openedAt': '2026-09-24T20:00:00.000Z',
|
||||
'version': 2,
|
||||
'lines': [
|
||||
{
|
||||
'id': 'line-1',
|
||||
'productId': 'prod-1',
|
||||
'productName': 'Beer',
|
||||
'unitPriceInCents': 400,
|
||||
'quantity': 2,
|
||||
'units': [
|
||||
{
|
||||
'id': 'u1',
|
||||
'addedAt': '2026-09-24T20:00:00.000Z',
|
||||
'removedAt': null,
|
||||
},
|
||||
{
|
||||
'id': 'u2',
|
||||
'addedAt': '2026-09-24T20:01:00.000Z',
|
||||
'removedAt': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
final tab = (await barTabService.getTabById('tab-1'))!;
|
||||
expect(tab.items.single.unitPriceInCents, 400);
|
||||
expect(tab.totalInCents, 800);
|
||||
});
|
||||
});
|
||||
|
||||
group('a part of the response that fails', () {
|
||||
Map<String, dynamic> brokenTab() {
|
||||
// Two lines claiming the same unit: the database refuses the second
|
||||
Map<String, dynamic> line(String id) => {
|
||||
'id': id,
|
||||
'productId': 'prod-1',
|
||||
'productName': 'Beer',
|
||||
'unitPriceInCents': 400,
|
||||
'quantity': 1,
|
||||
'units': [
|
||||
{
|
||||
'id': 'same-unit',
|
||||
'addedAt': '2026-09-24T20:00:00.000Z',
|
||||
'removedAt': null,
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
'id': 'tab-1',
|
||||
'customerName': 'Alice',
|
||||
'openedAt': '2026-09-24T20:00:00.000Z',
|
||||
'version': 1,
|
||||
'lines': [line('line-1'), line('line-2')],
|
||||
};
|
||||
}
|
||||
|
||||
test('does not keep prices and stock from arriving', () async {
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
'catalog': [
|
||||
{
|
||||
'id': 'prod-1',
|
||||
'name': 'Beer',
|
||||
'category': 'Drinks',
|
||||
'priceInCents': 450,
|
||||
'lowStockThreshold': 10,
|
||||
'archived': false,
|
||||
},
|
||||
],
|
||||
'stock': [
|
||||
{'productId': 'prod-1', 'stock': 42, 'countedAt': null},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
final beer = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||
expect(beer.priceInCents, 450);
|
||||
expect(beer.stockQuantity, 42);
|
||||
});
|
||||
|
||||
test('is reported as a failed sync naming the part', () async {
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
final failed = events.where(
|
||||
(e) => e.type == RemoteSyncEventType.syncFailed,
|
||||
);
|
||||
expect(failed.single.message, startsWith('tabs: '));
|
||||
expect(eventTypes(), isNot(contains(RemoteSyncEventType.syncCompleted)));
|
||||
});
|
||||
|
||||
test('leaves the local tabs as they were', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Bob');
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await barTabService.getTabById(tab.id), isNotNull);
|
||||
expect(await barTabService.getTabById('tab-1'), isNull);
|
||||
});
|
||||
|
||||
test('a later good response is applied normally', () async {
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
server.respondWith({
|
||||
'tabs': [
|
||||
{...brokenTab(), 'lines': <Object>[]},
|
||||
],
|
||||
});
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(await barTabService.getTabById('tab-1'), isNotNull);
|
||||
expect(eventTypes().last, RemoteSyncEventType.syncCompleted);
|
||||
});
|
||||
});
|
||||
|
||||
group('server holds the stock', () {
|
||||
Future<ProductRow> beer() {
|
||||
return (database.select(
|
||||
|
||||
+69
-13
@@ -99,23 +99,79 @@ void main() {
|
||||
expect(row.id, event['id']);
|
||||
});
|
||||
|
||||
test('adding to an existing line keeps that line\'s price', () async {
|
||||
test(
|
||||
'a drink at a new price goes on a new line, with that price',
|
||||
() async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: _cola.copyWith(priceInCents: 300),
|
||||
);
|
||||
|
||||
final events = await outbox();
|
||||
expect(events.map((event) => event['unitPriceInCents']), [250, 300]);
|
||||
expect(events.map((event) => event['productId']), ['prod-1', 'prod-1']);
|
||||
// A different line, so the server doesn't put it on the old one either
|
||||
expect(events[0]['lineId'], isNot(events[1]['lineId']));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'+ on a line at an old price sends the new price, on a new line',
|
||||
() async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
final oldLine = await lineIdOf(tab.id);
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals('prod-1')))
|
||||
.write(const ProductsCompanion(priceInCents: Value(300)));
|
||||
|
||||
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
|
||||
|
||||
final added = (await outbox()).last;
|
||||
expect(added['type'], 'line_added');
|
||||
expect(added['unitPriceInCents'], 300);
|
||||
expect(added['quantity'], 1);
|
||||
expect(added['lineId'], isNot(oldLine));
|
||||
},
|
||||
);
|
||||
|
||||
test('a line without a product sends no productId', () async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: const Product(
|
||||
id: 'prod-1',
|
||||
name: 'Cola',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 100,
|
||||
lowStockThreshold: 10,
|
||||
priceInCents: 300,
|
||||
),
|
||||
// As taken over from the tablet's own tabs: the line has no product
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: 'seed:${tab.id}:0',
|
||||
tabId: tab.id,
|
||||
productId: '',
|
||||
productName: 'Cola',
|
||||
quantity: 1,
|
||||
unitPriceInCents: 250,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: 'seed:${tab.id}:0',
|
||||
delta: -1,
|
||||
);
|
||||
|
||||
final event = (await outbox()).last;
|
||||
expect(event['type'], 'line_removed');
|
||||
expect(event.containsKey('productId'), isFalse);
|
||||
expect(event['productName'], 'Cola');
|
||||
});
|
||||
|
||||
test('a drink at the same price goes on the same line', () async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
|
||||
final events = await outbox();
|
||||
expect(events.map((event) => event['unitPriceInCents']), [250, 250]);
|
||||
expect(events[0]['lineId'], events[1]['lineId']);
|
||||
});
|
||||
|
||||
test('changing a quantity writes the difference', () async {
|
||||
|
||||
Reference in New Issue
Block a user