feat(handover): recordHandover — one transaction for the moment seeds change hands

Returns to the original model (data-model §2.8: a closed deal produces a
Movement and, optionally, a Plantare): the given/received Movement plus,
when they came with it, the Sale (informational payment note) and the
Plantare (return promise), all sharing the counterparty. The Movement
carries the promise via the plantare_id column the schema had reserved.

'Gave all' (one tap, the natural path for shrubs/whole plants) moves the
lot's whole quantity, leaves an empty jar (0, same unit), clears
abundance, and returns the lot to private — withdrawing its market offer
on the next publish cycle, price cleared with it.

New enum HandoverDirection; tests in test/data/handover_test.dart.
This commit is contained in:
vjrj 2026-07-11 07:28:27 +02:00
parent cfa1053842
commit 665e3e9452
3 changed files with 336 additions and 19 deletions

View file

@ -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 = <String, List<({String name, String? language})>>{};
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 = <String, String>{};
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<List<Plantare>> watchPlantares() => (_db.select(_db.plantares)
..where((p) => p.isDeleted.equals(false))
..orderBy([(p) => OrderingTerm.desc(p.madeOn)]))
.watch();
Stream<List<Plantare>> 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<List<Plantare>> 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<List<Sale>> watchSales() => (_db.select(_db.sales)
..where((s) => s.isDeleted.equals(false))
..orderBy([(s) => OrderingTerm.desc(s.soldOn)]))
.watch();
Stream<List<Sale>> 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<List<Sale>> 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.

View file

@ -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 }

View file

@ -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<Movement> movementById(String id) =>
(db.select(db.movements)..where((m) => m.id.equals(id))).getSingle();
Future<Lot> 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);
});
}