feat: make server own products
This commit is contained in:
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user