attempt to fix test txs

This commit is contained in:
davmar
2026-09-10 23:59:00 +02:00
parent f8e293f823
commit 00ab05eefc
6 changed files with 327 additions and 62 deletions
+82 -21
View File
@@ -598,10 +598,14 @@ class PbSyncService {
await _syncAppPrefs(userId, filter, prefs);
}
/// Upserts local bank txs to PocketBase. Does **not** delete remote rows
/// (partial local caches must not wipe another device).
/// Upserts local bank txs to PocketBase.
///
/// Also garbage-collects remote rows that are:
/// - admin test rows (`is_manual` / `·#…` stamp) no longer local, or
/// - listed in [deleteTombs] (local delete queue).
Future<String> pushTransactions(
List<BankTransaction> local, {
List<BankTransaction> deleteTombs = const [],
void Function(double progress)? onProgress,
void Function(String message)? onStatus,
double progressStart = 0.0,
@@ -630,10 +634,19 @@ class PbSyncService {
final ops = <_BatchOp>[];
var skipped = 0;
final seen = <String>{};
final tombIds = {
for (final t in deleteTombs)
if (_clip(t.id, 2000).isNotEmpty) _clip(t.id, 2000),
};
for (final tx in local) {
final syncId = _clip(tx.id, 2000);
if (syncId.isEmpty || !seen.add(syncId)) continue;
// Never re-upsert a tombstoned row in the same pass.
if (tombIds.contains(syncId) ||
deleteTombs.any(tx.matchesForDelete)) {
continue;
}
final body = <String, dynamic>{
'user': userId,
'tx_id': syncId,
@@ -648,7 +661,9 @@ class PbSyncService {
8,
),
'pending': tx.pending,
'is_manual': tx.isManual,
// Only send when true so servers without the migration still accept
// normal bank/CSV upserts. Test rows are also detected via ·# stamp.
if (tx.isManual) 'is_manual': true,
};
final remote = existing[syncId];
if (remote == null) {
@@ -660,11 +675,25 @@ class PbSyncService {
}
}
// Remove admin test / tombstoned rows that this device no longer has.
var deletes = 0;
for (final entry in existing.entries) {
if (seen.contains(entry.key)) continue;
final remoteTx = _bankTxFromRecord(entry.value);
final tombHit = tombIds.contains(entry.key) ||
(remoteTx != null && deleteTombs.any(remoteTx.matchesForDelete));
final orphanManual = _remoteLooksLikeManualTest(entry.value);
if (!tombHit && !orphanManual) continue;
ops.add(_BatchOp.delete(entry.value.id));
deletes++;
}
final creates = ops.where((o) => o.kind == _BatchOpKind.create).length;
final updates = ops.where((o) => o.kind == _BatchOpKind.update).length;
debugPrint(
'PbSyncService transactions: local=${local.length} '
'remote=${existing.length} create=$creates update=$updates skip=$skipped',
'remote=${existing.length} create=$creates update=$updates '
'delete=$deletes skip=$skipped',
);
onStatus?.call(
ops.isEmpty
@@ -678,8 +707,13 @@ class PbSyncService {
onStatus: onStatus,
);
return 'Bank txs: local ${local.length} → remote '
'${existing.length + creates} '
'(+$creates ~$updates · $skipped unchanged)';
'${existing.length + creates - deletes} '
'(+$creates ~$updates -$deletes · $skipped unchanged)';
}
bool _remoteLooksLikeManualTest(RecordModel r) {
if (r.getBoolValue('is_manual')) return true;
return RegExp(r' ·#\d+$').hasMatch(r.getStringValue('description'));
}
BankTransaction? _bankTxFromRecord(RecordModel r) {
@@ -700,7 +734,8 @@ class PbSyncService {
? 'EUR'
: r.getStringValue('currency'),
pending: r.getBoolValue('pending'),
isManual: r.getBoolValue('is_manual'),
isManual: r.getBoolValue('is_manual') ||
RegExp(r' ·#\d+$').hasMatch(description),
);
}
@@ -711,6 +746,8 @@ class PbSyncService {
String s => double.tryParse(s) ?? 0,
_ => 0.0,
};
final remoteManual = remote.getBoolValue('is_manual') ||
RegExp(r' ·#\d+$').hasMatch(remote.getStringValue('description'));
return remote.getStringValue('date') == tx.date &&
remote.getStringValue('description') ==
_clip(tx.description.isEmpty ? 'Unknown' : tx.description, 4000) &&
@@ -718,29 +755,53 @@ class PbSyncService {
remote.getStringValue('currency').toUpperCase() ==
tx.currency.toUpperCase() &&
remote.getBoolValue('pending') == tx.pending &&
remote.getBoolValue('is_manual') == tx.isManual;
remoteManual == tx.isManual;
}
/// Deletes remote bank rows by local [BankTransaction.id] (tx_id field).
Future<String> deleteTransactionsByTxIds(Iterable<String> txIds) async {
final ids = {
for (final id in txIds)
if (_clip(id, 2000).isNotEmpty) _clip(id, 2000),
};
if (ids.isEmpty) return 'Bank txs: nothing to delete';
/// Deletes remote bank rows matching [tombstones] by tx_id or field fingerprint.
///
/// Returns the tombstones that matched at least one remote row (safe to clear).
Future<List<BankTransaction>> deleteTransactionsMatching(
List<BankTransaction> tombstones,
) async {
if (tombstones.isEmpty) return const [];
final userId = _userId;
final filter = 'user="$userId"';
final existing = await _fullList('transactions', filter);
final ops = <_BatchOp>[
for (final r in existing)
if (ids.contains(r.getStringValue('tx_id'))) _BatchOp.delete(r.id),
];
final matchedTombs = <BankTransaction>[];
final ops = <_BatchOp>[];
final seenRecordIds = <String>{};
for (final r in existing) {
final remoteTx = _bankTxFromRecord(r);
if (remoteTx == null) continue;
for (final tomb in tombstones) {
final txId = r.getStringValue('tx_id');
final idHit = txId == _clip(tomb.id, 2000);
if (!idHit && !remoteTx.matchesForDelete(tomb)) continue;
if (!seenRecordIds.add(r.id)) break;
ops.add(_BatchOp.delete(r.id));
if (!matchedTombs.any(tomb.matchesForDelete)) {
matchedTombs.add(tomb);
}
break;
}
}
if (ops.isEmpty) {
return 'Bank txs: 0 remote deletes (ids ${ids.length})';
debugPrint(
'PbSyncService deleteTransactionsMatching: 0 hits '
'for ${tombstones.length} tombstone(s)',
);
return const [];
}
await _runWriteOps('transactions', ops);
return 'Bank txs: deleted ${ops.length} remote row(s)';
debugPrint(
'PbSyncService deleteTransactionsMatching: deleted ${ops.length} '
'for ${matchedTombs.length} tombstone(s)',
);
return matchedTombs;
}
Future<void> _runOne(String collection, _BatchOp op) {