feat(inventory): additive CSV import for bulk digitization

Import a spreadsheet list (the same 21-column schema the CSV export
already produces) as new varieties/lots. Tolerant reader: columns
matched by header name in any order, only variety_label required,
unknown enums fall back to a safe default, rows sharing variety_id
collapse into one variety with several lots. Species re-linked by
scientific name. Additive by design (fresh ids + HLC), so it never
overwrites existing rows; JSON stays the canonical id-reconciling import.

Adds a hand-rolled RFC 4180 parser (no new dependency), an importCsv
path in VarietyRepository/ExportImportService, an 'Import from CSV'
tile in the backup section, and en/es strings.

Note: i18n .g.dart also carries pre-existing About/intro strings from
the working tree (regenerated by slang).
This commit is contained in:
vjrj 2026-07-09 14:41:28 +02:00
parent 21072633b5
commit 89b61bc9e4
11 changed files with 1070 additions and 2 deletions

View file

@ -0,0 +1,78 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_csv_codec.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../../support/test_support.dart';
void main() {
const codec = InventoryCsvCodec();
const tomato = SpeciesSeed(
scientificName: 'Solanum lycopersicum',
family: 'Solanaceae',
);
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
test('inserts a variety with its lot, resolving species by name', () async {
await newTestSpeciesRepository(db).seedBundled(const [tomato]);
final repo = newTestRepository(db);
const csv = 'variety_label,category,species_scientific_name,'
'cultivar_name,lot_type,harvest_year,quantity_kind,quantity_precise\r\n'
'Grandma tomato,Solanaceae,Solanum lycopersicum,Marmande,seed,2024,cob,3\r\n';
final summary = await repo.importCsv(codec.decode(csv));
expect(summary.inserted, 1);
final variety = await db.select(db.varieties).getSingle();
expect(variety.label, 'Grandma tomato');
expect(variety.cultivarName, 'Marmande');
final species = await db.select(db.species).getSingle();
expect(variety.speciesId, species.id);
final lot = await db.select(db.lots).getSingle();
expect(lot.varietyId, variety.id);
expect(lot.type, LotType.seed);
expect(lot.harvestYear, 2024);
expect(lot.quantityKind, 'cob');
expect(lot.quantityPrecise, 3.0);
});
test('an unmatched species name leaves speciesId null', () async {
final repo = newTestRepository(db); // no bundled catalog
const csv = 'variety_label,species_scientific_name\r\n'
'Mystery bean,Phaseolus nonexistus\r\n';
await repo.importCsv(codec.decode(csv));
final variety = await db.select(db.varieties).getSingle();
expect(variety.speciesId, isNull);
});
test('is additive: importing the same CSV twice adds it twice', () async {
final repo = newTestRepository(db);
const csv = 'variety_label\r\nTomato\r\n';
await repo.importCsv(codec.decode(csv));
await repo.importCsv(codec.decode(csv));
final varieties = await db.select(db.varieties).get();
expect(varieties, hasLength(2));
});
test('stores vernacular names and links attached to the new variety', () async {
final repo = newTestRepository(db);
const csv = 'variety_label,vernacular_names,external_links\r\n'
'Tomato,"tomate (es); granny tomato","Wiki|https://example.org"\r\n';
await repo.importCsv(codec.decode(csv));
final variety = await db.select(db.varieties).getSingle();
final names = await db.select(db.varietyVernacularNames).get();
expect(names.map((n) => n.name), containsAll(['tomate', 'granny tomato']));
expect(names.every((n) => n.varietyId == variety.id), isTrue);
final links = await db.select(db.externalLinks).get();
expect(links.single.url, 'https://example.org');
expect(links.single.parentId, variety.id);
});
}

View file

@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_csv_codec.dart';
import 'package:tane/data/export_import/inventory_snapshot.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
void main() {
const codec = InventoryCsvCodec();
group('decode (additive CSV import)', () {
test('a minimal one-column list only needs variety_label', () {
final csv = 'variety_label\r\nGrandma tomato\r\nPurple carrot\r\n';
final result = codec.decode(csv);
expect(result.skippedRows, 0);
expect(result.varieties.map((v) => v.label), [
'Grandma tomato',
'Purple carrot',
]);
// No lot columns no lots.
expect(result.varieties.first.lots, isEmpty);
});
test('skips rows without a variety_label', () {
final csv = 'variety_label,category\r\n,Solanaceae\r\nTomato,Solanaceae\r\n';
final result = codec.decode(csv);
expect(result.skippedRows, 1);
expect(result.varieties, hasLength(1));
expect(result.varieties.single.label, 'Tomato');
});
test('maps columns by header name regardless of order', () {
final csv = 'category,variety_label,harvest_year,lot_type\r\n'
'Solanaceae,Tomato,2024,seed\r\n';
final v = codec.decode(csv).varieties.single;
expect(v.label, 'Tomato');
expect(v.category, 'Solanaceae');
expect(v.lots.single.harvestYear, 2024);
expect(v.lots.single.type, LotType.seed);
});
test('ignores unknown columns', () {
final csv = 'variety_label,made_up_column\r\nTomato,whatever\r\n';
final v = codec.decode(csv).varieties.single;
expect(v.label, 'Tomato');
});
test('unknown enums fall back: lot_type→seed, offer_status→private, '
'quantity_kind dropped', () {
final csv = 'variety_label,lot_type,offer_status,quantity_kind\r\n'
'Tomato,spaceship,gift,truckload\r\n';
final lot = codec.decode(csv).varieties.single.lots.single;
expect(lot.type, LotType.seed);
expect(lot.offerStatus, OfferStatus.private);
expect(lot.quantityKind, isNull);
});
test('keeps a valid quantity_kind and parses precise/label', () {
final csv = 'variety_label,quantity_kind,quantity_precise,quantity_label\r\n'
'Maize,cob,3,a handful\r\n';
final lot = codec.decode(csv).varieties.single.lots.single;
expect(lot.quantityKind, 'cob');
expect(lot.quantityPrecise, 3.0);
expect(lot.quantityLabel, 'a handful');
});
test('rows sharing a variety_id collapse into one variety with many lots', () {
final csv = 'variety_id,variety_label,lot_type,harvest_year\r\n'
'v1,Tomato,seed,2023\r\n'
'v1,Tomato,seed,2024\r\n'
'v2,Carrot,seed,2024\r\n';
final result = codec.decode(csv);
expect(result.varieties, hasLength(2));
final tomato = result.varieties.firstWhere((v) => v.label == 'Tomato');
expect(tomato.lots.map((l) => l.harvestYear), [2023, 2024]);
});
test('rows without a variety_id each become their own variety', () {
final csv = 'variety_label,lot_type\r\nTomato,seed\r\nTomato,plant\r\n';
final result = codec.decode(csv);
expect(result.varieties, hasLength(2));
});
test('parses quoted fields with commas, doubled quotes and newlines', () {
final csv = 'variety_label,variety_notes\r\n'
'"Bean, ""the best""","line one\nline two"\r\n';
final v = codec.decode(csv).varieties.single;
expect(v.label, 'Bean, "the best"');
expect(v.notes, 'line one\nline two');
});
test('splits vernacular names and links back out', () {
final csv = 'variety_label,vernacular_names,external_links\r\n'
'Tomato,"tomate (es); granny tomato","Wiki|https://example.org"\r\n';
final v = codec.decode(csv).varieties.single;
expect(v.vernacularNames.map((n) => n.name), ['tomate', 'granny tomato']);
expect(v.vernacularNames.first.language, 'es');
expect(v.links.single.url, 'https://example.org');
expect(v.links.single.title, 'Wiki');
});
test('tolerates \\n-only line endings and a missing trailing newline', () {
final csv = 'variety_label\nTomato\nCarrot';
final result = codec.decode(csv);
expect(result.varieties.map((v) => v.label), ['Tomato', 'Carrot']);
});
test('an empty or header-only file yields nothing', () {
expect(codec.decode('').varieties, isEmpty);
expect(codec.decode('variety_label\r\n').varieties, isEmpty);
});
});
group('round-trip with the export encoder', () {
Variety variety() => Variety(
id: 'var-1',
createdAt: 0,
updatedAt: '000000000001000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
label: 'Grandma tomato',
speciesId: 'sp-1',
cultivarName: 'Marmande',
category: 'Solanaceae',
notes: 'heirloom',
);
Lot lot({String id = 'lot-1'}) => Lot(
id: id,
createdAt: 0,
updatedAt: '000000000002000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
varietyId: 'var-1',
type: LotType.seed,
harvestYear: 2024,
harvestMonth: 9,
quantityKind: 'cob',
quantityPrecise: 2,
quantityLabel: null,
presentation: null,
storageLocation: 'fridge',
offerStatus: OfferStatus.shared,
seedbankId: null,
);
test('encode → decode preserves variety fields and both lots', () {
final csv = codec.encode(
InventorySnapshot(
varieties: [variety()],
lots: [lot(), lot(id: 'lot-2')],
speciesNamesById: const {'sp-1': 'Solanum lycopersicum'},
),
);
final v = codec.decode(csv).varieties.single;
expect(v.label, 'Grandma tomato');
expect(v.category, 'Solanaceae');
expect(v.cultivarName, 'Marmande');
expect(v.notes, 'heirloom');
expect(v.scientificName, 'Solanum lycopersicum');
expect(v.lots, hasLength(2));
final l = v.lots.first;
expect(l.type, LotType.seed);
expect(l.harvestYear, 2024);
expect(l.harvestMonth, 9);
expect(l.quantityKind, 'cob');
expect(l.quantityPrecise, 2.0);
expect(l.storageLocation, 'fridge');
expect(l.offerStatus, OfferStatus.shared);
});
});
}