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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue