feat(inventory): CSV/JSON export and JSON import with LWW reconciliation
Interchange export/import for Phase 1 (data-model §7): - JSON: canonical, versioned envelope (formatVersion 1) with all sync metadata verbatim, photos embedded as base64, tombstones excluded. Species are re-resolved on import by scientific name (catalog ids are per-install). Reader tolerates unknown fields/enum values (§5.2) and rejects newer format versions with a clear error. - CSV: export-only spreadsheet flatten, one row per lot, RFC 4180 escaping, never any photo bytes. - Import merges by UUIDv7 id in one transaction: insert-if-unknown preserving original stamps, last-writer-wins by packed HLC for known mutable rows, append-only movements; afterwards the local clock receiveEvent()s the newest imported stamp so it never runs behind. - Settings gains a Backup section (export CSV/JSON, import JSON with confirmation); file dialogs behind a FileService interface backed by file_picker (MIT). - Tests: codec round-trip and tolerance, reconciler LWW, repository export→import round-trip (fresh DB, idempotent re-import, newer-local wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
parent
136ed701a7
commit
2812c99280
25 changed files with 2207 additions and 7 deletions
|
|
@ -5,6 +5,8 @@ import 'package:equatable/equatable.dart';
|
|||
|
||||
import '../db/database.dart';
|
||||
import '../db/enums.dart';
|
||||
import 'export_import/import_reconciler.dart';
|
||||
import 'export_import/inventory_snapshot.dart';
|
||||
|
||||
/// A lightweight row for the inventory list (only what the list renders).
|
||||
class VarietyListItem extends Equatable {
|
||||
|
|
@ -826,6 +828,223 @@ class VarietyRepository {
|
|||
return id;
|
||||
}
|
||||
|
||||
/// Snapshots the live inventory (tombstones excluded) for the interchange
|
||||
/// export — data-model §7. Includes photo bytes; the JSON codec embeds them
|
||||
/// as base64 and the CSV codec ignores them.
|
||||
Future<InventorySnapshot> exportInventory() async {
|
||||
final varieties = await (_db.select(
|
||||
_db.varieties,
|
||||
)..where((v) => v.isDeleted.equals(false))).get();
|
||||
final speciesNamesById = await _scientificNamesFor(
|
||||
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
|
||||
);
|
||||
return InventorySnapshot(
|
||||
varieties: varieties,
|
||||
speciesNamesById: speciesNamesById,
|
||||
lots: await (_db.select(
|
||||
_db.lots,
|
||||
)..where((l) => l.isDeleted.equals(false))).get(),
|
||||
vernacularNames: await (_db.select(
|
||||
_db.varietyVernacularNames,
|
||||
)..where((n) => n.isDeleted.equals(false))).get(),
|
||||
externalLinks: await (_db.select(
|
||||
_db.externalLinks,
|
||||
)..where((e) => e.isDeleted.equals(false))).get(),
|
||||
germinationTests: await (_db.select(
|
||||
_db.germinationTests,
|
||||
)..where((g) => g.isDeleted.equals(false))).get(),
|
||||
movements: await _db.select(_db.movements).get(),
|
||||
parties: await (_db.select(
|
||||
_db.parties,
|
||||
)..where((p) => p.isDeleted.equals(false))).get(),
|
||||
attachments: await (_db.select(
|
||||
_db.attachments,
|
||||
)..where((a) => a.isDeleted.equals(false))).get(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
|
||||
/// unknown id → insert preserving the original sync metadata; known mutable
|
||||
/// id → last-writer-wins on the packed HLC `updatedAt`; movements are
|
||||
/// append-only (insert-if-unknown). Runs in one transaction; afterwards the
|
||||
/// local clock absorbs the newest imported stamp so it never runs behind.
|
||||
///
|
||||
/// Species ids are per-install, so incoming `speciesId`s are re-resolved
|
||||
/// against the local catalog by scientific name (unmatched → null).
|
||||
Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
|
||||
const reconciler = ImportReconciler();
|
||||
final localSpeciesIdByIncoming = await _resolveSpecies(
|
||||
snapshot.speciesNamesById,
|
||||
);
|
||||
final varieties = [
|
||||
for (final v in snapshot.varieties)
|
||||
v.speciesId == null
|
||||
? v
|
||||
: v.copyWith(
|
||||
speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
|
||||
),
|
||||
];
|
||||
|
||||
var summary = const ImportSummary();
|
||||
await _db.transaction(() async {
|
||||
summary += await _importMutableRows(
|
||||
table: _db.varieties,
|
||||
idColumn: _db.varieties.id,
|
||||
rows: varieties,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.lots,
|
||||
idColumn: _db.lots.id,
|
||||
rows: snapshot.lots,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.varietyVernacularNames,
|
||||
idColumn: _db.varietyVernacularNames.id,
|
||||
rows: snapshot.vernacularNames,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.externalLinks,
|
||||
idColumn: _db.externalLinks.id,
|
||||
rows: snapshot.externalLinks,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.germinationTests,
|
||||
idColumn: _db.germinationTests.id,
|
||||
rows: snapshot.germinationTests,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.parties,
|
||||
idColumn: _db.parties.id,
|
||||
rows: snapshot.parties,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.attachments,
|
||||
idColumn: _db.attachments.id,
|
||||
rows: snapshot.attachments,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMovements(snapshot.movements, reconciler);
|
||||
});
|
||||
|
||||
final maxIncoming = reconciler.maxHlc([
|
||||
for (final v in snapshot.varieties) v.updatedAt,
|
||||
for (final l in snapshot.lots) l.updatedAt,
|
||||
for (final n in snapshot.vernacularNames) n.updatedAt,
|
||||
for (final e in snapshot.externalLinks) e.updatedAt,
|
||||
for (final g in snapshot.germinationTests) g.updatedAt,
|
||||
for (final p in snapshot.parties) p.updatedAt,
|
||||
for (final a in snapshot.attachments) a.updatedAt,
|
||||
]);
|
||||
if (maxIncoming != null) {
|
||||
_clock = _clock.receiveEvent(maxIncoming, _now());
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// Maps incoming species ids to local catalog ids by scientific name.
|
||||
Future<Map<String, String?>> _resolveSpecies(
|
||||
Map<String, String> speciesNamesById,
|
||||
) async {
|
||||
final resolved = <String, String?>{};
|
||||
for (final entry in speciesNamesById.entries) {
|
||||
final local =
|
||||
await (_db.select(_db.species)
|
||||
..where(
|
||||
(s) =>
|
||||
s.scientificName.equals(entry.value) &
|
||||
s.isDeleted.equals(false),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
resolved[entry.key] = local?.id;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// LWW import of one mutable table. Existing rows are looked up including
|
||||
/// tombstones, so a locally-deleted row only resurrects when the incoming
|
||||
/// version is strictly newer — plain last-writer-wins.
|
||||
Future<ImportSummary> _importMutableRows<T extends Table, R>({
|
||||
required TableInfo<T, R> table,
|
||||
required GeneratedColumn<String> idColumn,
|
||||
required List<R> rows,
|
||||
required String Function(R) idOf,
|
||||
required String Function(R) updatedAtOf,
|
||||
required ImportReconciler reconciler,
|
||||
}) async {
|
||||
if (rows.isEmpty) return const ImportSummary();
|
||||
final byId = {for (final r in rows) idOf(r): r};
|
||||
final existing = await (_db.select(
|
||||
table,
|
||||
)..where((_) => idColumn.isIn(byId.keys))).get();
|
||||
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
|
||||
|
||||
var inserted = 0, updated = 0, skipped = 0;
|
||||
for (final row in byId.values) {
|
||||
final action = reconciler.reconcileMutable(
|
||||
existingUpdatedAt: existingUpdatedAt[idOf(row)],
|
||||
incomingUpdatedAt: updatedAtOf(row),
|
||||
);
|
||||
switch (action) {
|
||||
case ReconcileAction.insert:
|
||||
await _db.into(table).insert(row as Insertable<R>);
|
||||
inserted++;
|
||||
case ReconcileAction.update:
|
||||
await _db.update(table).replace(row as Insertable<R>);
|
||||
updated++;
|
||||
case ReconcileAction.skip:
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
|
||||
}
|
||||
|
||||
/// Movements are append-only: insert unknown ids, never touch known ones.
|
||||
Future<ImportSummary> _importMovements(
|
||||
List<Movement> movements,
|
||||
ImportReconciler reconciler,
|
||||
) async {
|
||||
if (movements.isEmpty) return const ImportSummary();
|
||||
final ids = movements.map((m) => m.id).toList();
|
||||
final existing = await (_db.select(
|
||||
_db.movements,
|
||||
)..where((m) => m.id.isIn(ids))).get();
|
||||
final existingIds = existing.map((m) => m.id).toSet();
|
||||
|
||||
var inserted = 0, skipped = 0;
|
||||
for (final movement in movements) {
|
||||
final action = reconciler.reconcileAppendOnly(
|
||||
exists: existingIds.contains(movement.id),
|
||||
);
|
||||
if (action == ReconcileAction.insert) {
|
||||
await _db.into(_db.movements).insert(movement);
|
||||
inserted++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return ImportSummary(inserted: inserted, skipped: skipped);
|
||||
}
|
||||
|
||||
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
|
||||
final hasQuantity =
|
||||
l.quantityKind != null ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue