diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index bd5a05c..69dfea0 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -1199,7 +1199,10 @@ class VarietyRepository { Future<({String id, String? family})?> _autoClassifyFromLabel( String label, ) async { - final speciesId = matchSpeciesInLabel(label, await _speciesNamesForMatching()); + final speciesId = matchSpeciesInLabel( + label, + await _speciesNamesForMatching(), + ); if (speciesId == null) return null; final species = await (_db.select( _db.species, @@ -1569,16 +1572,13 @@ class VarietyRepository { .get(); final bySpecies = >{}; for (final n in rows) { - (bySpecies[n.speciesId] ??= []).add(( - name: n.name, - language: n.language, - )); + (bySpecies[n.speciesId] ??= []).add((name: n.name, language: n.language)); } final result = {}; for (final entry in bySpecies.entries) { final inLocale = entry.value.where((n) => n.language == languageCode); - result[entry.key] = (inLocale.isEmpty ? entry.value.first : inLocale.first) - .name; + result[entry.key] = + (inLocale.isEmpty ? entry.value.first : inLocale.first).name; } return result; } @@ -1866,7 +1866,9 @@ class VarietyRepository { }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); - await _db.into(_db.plantares).insert( + await _db + .into(_db.plantares) + .insert( PlantaresCompanion.insert( id: id, createdAt: created, @@ -1885,10 +1887,11 @@ class VarietyRepository { } /// All live commitments, newest first (for the Plantares screen). - Stream> watchPlantares() => (_db.select(_db.plantares) - ..where((p) => p.isDeleted.equals(false)) - ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) - .watch(); + Stream> watchPlantares() => + (_db.select(_db.plantares) + ..where((p) => p.isDeleted.equals(false)) + ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) + .watch(); /// The commitments recorded against one variety. Stream> watchPlantaresForVariety(String varietyId) => @@ -1906,8 +1909,7 @@ class VarietyRepository { await (_db.update(_db.plantares)..where((p) => p.id.equals(id))).write( PlantaresCompanion( status: Value(status), - settledOn: - Value(status == PlantareStatus.open ? null : now), + settledOn: Value(status == PlantareStatus.open ? null : now), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), @@ -1939,7 +1941,9 @@ class VarietyRepository { }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); - await _db.into(_db.sales).insert( + await _db + .into(_db.sales) + .insert( SalesCompanion.insert( id: id, createdAt: created, @@ -1958,10 +1962,11 @@ class VarietyRepository { } /// All live sales, newest first (for the Sales screen). - Stream> watchSales() => (_db.select(_db.sales) - ..where((s) => s.isDeleted.equals(false)) - ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) - .watch(); + Stream> watchSales() => + (_db.select(_db.sales) + ..where((s) => s.isDeleted.equals(false)) + ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) + .watch(); /// The sales recorded against one variety. Stream> watchSalesForVariety(String varietyId) => @@ -1984,6 +1989,130 @@ class VarietyRepository { ); } + // --- Hand-over: the one moment seeds change hands -------------------------- + + /// Records a hand-over in a single transaction (data-model §2.8: a closed + /// deal produces a `Movement` and, optionally, a `Plantare`): the Movement + /// (given/received) plus — when they came with it — the Sale (money changed + /// hands, informational only, never processed in-app) and the Plantare (a + /// return promise), all sharing the same free-text [counterparty]. + /// + /// With nothing optional this is a plain gift: just the Movement. + /// + /// [gaveAll] is the one-tap "all of it" (the natural path for a whole plant + /// or shrub): the movement carries the lot's current quantity, the lot's + /// precise quantity drops to 0 and it returns to [OfferStatus.private] — + /// withdrawing it from the market on the next publish cycle. Otherwise + /// [quantity] says how much of the batch moved (optional). + Future<({String movementId, String? saleId, String? plantareId})> + recordHandover({ + required String lotId, + required HandoverDirection direction, + bool gaveAll = false, + Quantity? quantity, + String? counterparty, + bool withPayment = false, + double? paymentAmount, + String? paymentCurrency, + bool withPromise = false, + String? promiseOwedDescription, + int? promiseDueBy, + String? note, + }) async { + final gave = direction == HandoverDirection.iGave; + return _db.transaction(() async { + final lot = await (_db.select( + _db.lots, + )..where((l) => l.id.equals(lotId))).getSingle(); + + // The promise first, so the movement can carry its id — the link the + // model reserved for exactly this (data-model §2.4: "a `given` may + // carry a Plantare"). + String? plantareId; + if (withPromise) { + plantareId = await createPlantare( + direction: gave + ? PlantareDirection.owedToMe + : PlantareDirection.iReturn, + varietyId: lot.varietyId, + counterparty: counterparty, + owedDescription: promiseOwedDescription, + dueBy: promiseDueBy, + ); + } + + final movedAll = gaveAll && gave; + final lotHasQuantity = + lot.quantityKind != null || + lot.quantityPrecise != null || + lot.quantityLabel != null; + final movedQuantity = movedAll + ? (lotHasQuantity + ? Quantity( + kind: _parseKind(lot.quantityKind), + count: lot.quantityPrecise, + label: lot.quantityLabel, + ) + : null) + : quantity; + final noteParts = [ + if (counterparty != null && counterparty.trim().isNotEmpty) + counterparty.trim(), + if (note != null && note.trim().isNotEmpty) note.trim(), + ]; + final (created, _) = await _stamp(); + final movementId = idGen.newId(); + await _db + .into(_db.movements) + .insert( + MovementsCompanion.insert( + id: movementId, + createdAt: created, + lastAuthor: nodeId, + lotId: lotId, + type: gave ? MovementType.given : MovementType.received, + occurredOn: Value(created), + quantityKind: Value(movedQuantity?.kind.name), + quantityPrecise: Value(movedQuantity?.count?.toDouble()), + quantityLabel: Value(movedQuantity?.label), + plantareId: Value(plantareId), + notes: Value(noteParts.isEmpty ? null : noteParts.join(' — ')), + ), + ); + + if (movedAll) { + // The batch is gone: an empty jar (0, same unit) with nothing left to + // declare or offer. Price goes with the sale status. + final (_, updated) = await _stamp(); + await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( + LotsCompanion( + quantityPrecise: const Value(0), + abundance: const Value(null), + offerStatus: const Value(OfferStatus.private), + priceAmount: const Value(null), + priceCurrency: const Value(null), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + String? saleId; + if (withPayment) { + saleId = await createSale( + direction: gave ? SaleDirection.iSold : SaleDirection.iBought, + varietyId: lot.varietyId, + counterparty: counterparty, + amount: paymentAmount, + currency: paymentCurrency, + note: note, + ); + } + + return (movementId: movementId, saleId: saleId, plantareId: plantareId); + }); + } + /// 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. diff --git a/apps/app_seeds/lib/db/enums.dart b/apps/app_seeds/lib/db/enums.dart index 4617309..1e3db8a 100644 --- a/apps/app_seeds/lib/db/enums.dart +++ b/apps/app_seeds/lib/db/enums.dart @@ -106,3 +106,10 @@ enum PlantareStatus { open, returned, forgiven } /// SEPARATE model from a gift or a Plantare — seed for money (any currency: /// €, Ğ1, a local/time currency…). No commission is ever taken on seeds. enum SaleDirection { iSold, iBought } + +/// Direction of a hand-over — the one moment seeds actually change hands +/// (data-model §2.8: a closed deal produces a Movement and, optionally, a +/// Plantare and/or a Sale record). Not stored in a column of its own: it maps +/// to [MovementType.given]/[MovementType.received] plus the derived directions +/// of the optional Sale and Plantare rows. +enum HandoverDirection { iGave, iReceived } diff --git a/apps/app_seeds/test/data/handover_test.dart b/apps/app_seeds/test/data/handover_test.dart new file mode 100644 index 0000000..5bf6282 --- /dev/null +++ b/apps/app_seeds/test/data/handover_test.dart @@ -0,0 +1,181 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:drift/drift.dart' hide isNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + late VarietyRepository repo; + late String varietyId; + setUp(() async { + db = newTestDatabase(); + repo = newTestRepository(db); + varietyId = await repo.addQuickVariety(label: 'Tomate rosa'); + }); + tearDown(() => db.close()); + + Future movementById(String id) => + (db.select(db.movements)..where((m) => m.id.equals(id))).getSingle(); + + Future lotById(String id) => + (db.select(db.lots)..where((l) => l.id.equals(id))).getSingle(); + + test('a plain gift records just the given movement', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + ); + + expect(result.saleId, isNull); + expect(result.plantareId, isNull); + final movement = await movementById(result.movementId); + expect(movement.type, MovementType.given); + expect(movement.lotId, lotId); + expect(movement.notes, 'María'); + expect(movement.plantareId, isNull); + expect(await repo.watchSales().first, isEmpty); + expect(await repo.watchPlantares().first, isEmpty); + }); + + test( + 'giving all depletes the lot, privatizes it and drops its price', + () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + abundance: Abundance.plentyToShare, + offerStatus: OfferStatus.sell, + priceAmount: 3, + priceCurrency: '€', + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + gaveAll: true, + ); + + // The movement carries what actually moved: the lot's whole quantity. + final movement = await movementById(result.movementId); + expect(movement.quantityKind, QuantityKind.grams.name); + expect(movement.quantityPrecise, 40); + + // The lot stays as an empty jar, withdrawn from the market. + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 0); + expect(lot.quantityKind, QuantityKind.grams.name); + expect(lot.abundance, isNull); + expect(lot.offerStatus, OfferStatus.private); + expect(lot.priceAmount, isNull); + expect(lot.priceCurrency, isNull); + expect( + (await repo.shareableLots()).where((l) => l.lotId == lotId), + isEmpty, + ); + }, + ); + + test('giving part keeps the lot open and offered', () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + offerStatus: OfferStatus.shared, + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + quantity: const Quantity(kind: QuantityKind.grams, count: 10), + ); + + final movement = await movementById(result.movementId); + expect(movement.quantityPrecise, 10); + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 40); // untouched: the log holds the history + expect(lot.offerStatus, OfferStatus.shared); + expect( + (await repo.shareableLots()).where((l) => l.lotId == lotId), + hasLength(1), + ); + }); + + test('a payment on a give creates the matching sale record', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + withPayment: true, + paymentAmount: 2.5, + paymentCurrency: 'Ğ1', + ); + + final sales = await repo.watchSales().first; + expect(sales, hasLength(1)); + expect(sales.single.id, result.saleId); + expect(sales.single.direction, SaleDirection.iSold); + expect(sales.single.varietyId, varietyId); + expect(sales.single.counterparty, 'María'); + expect(sales.single.amount, 2.5); + expect(sales.single.currency, 'Ğ1'); + }); + + test('a promise on a give creates a plantare owed to me, linked from the ' + 'movement', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + withPromise: true, + promiseOwedDescription: 'un puñado la próxima temporada', + ); + + final plantares = await repo.watchPlantares().first; + expect(plantares, hasLength(1)); + expect(plantares.single.id, result.plantareId); + expect(plantares.single.direction, PlantareDirection.owedToMe); + expect(plantares.single.varietyId, varietyId); + expect(plantares.single.counterparty, 'María'); + expect(plantares.single.owedDescription, 'un puñado la próxima temporada'); + // The link the model reserved: the movement carries the plantare id. + final movement = await movementById(result.movementId); + expect(movement.plantareId, result.plantareId); + }); + + test('receiving with payment and promise mirrors the directions', () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iReceived, + // Meaningless on a receive; must not deplete anything. + gaveAll: true, + withPayment: true, + paymentAmount: 5, + paymentCurrency: '€', + withPromise: true, + ); + + final movement = await movementById(result.movementId); + expect(movement.type, MovementType.received); + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 40); + final sales = await repo.watchSales().first; + expect(sales.single.direction, SaleDirection.iBought); + final plantares = await repo.watchPlantares().first; + expect(plantares.single.direction, PlantareDirection.iReturn); + }); +}