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:
parent
cfa1053842
commit
665e3e9452
3 changed files with 336 additions and 19 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
181
apps/app_seeds/test/data/handover_test.dart
Normal file
181
apps/app_seeds/test/data/handover_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue