feat: make server own products

This commit is contained in:
2026-10-21 12:10:42 +02:00
parent 93acfe15a9
commit b15d94a805
21 changed files with 1717 additions and 106 deletions
+97 -66
View File
@@ -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)));
+73 -7
View File
@@ -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([