feat(sales): local sales/purchase ledger (any currency)

A sale is a distinct model from a gift or a Plantare (reproduction
commitment): a recorded seed sale or purchase with an optional price in
ANY currency — €, Ğ1, time, or none yet. Mirrors the Plantare feature.

- schema v10: Sales table (SyncColumns), guarded createTable migration,
  schema dump + generated schema_v10 for the migration round-trip test
- enum SaleDirection { iSold, iBought }
- VarietyRepository: create/watch/watchForVariety/delete + backup
  export/exportForSync/import (LWW-by-HLC, tombstones)
- InventorySnapshot.sales + JSON codec encode/decode (round-trips amount,
  currency, counterparty)
- UI: SalesScreen (/sales), sale sheet, drawer entry, variety-detail
  action beside 'add Plantare'; money hides a trailing .0
- i18n sale block + menu.sales in en/es/pt/ast
- tests: sales repo test (5) incl. Ğ1/price-less/backup round-trip;
  Sales screen added to the small-screen overflow guard

Also harden the overflow guard: swallow the headless engine's image
resource service PNG-decode errors (unrelated to layout) while still
failing on real RenderFlex overflows, so home/about stop reporting
false failures.
This commit is contained in:
vjrj 2026-07-11 02:33:42 +02:00
parent de6938d5d7
commit 6de039d518
27 changed files with 6156 additions and 23 deletions

View file

@ -1781,6 +1781,64 @@ class VarietyRepository {
);
}
// --- Sales: recorded seed sales (separate from gift/Plantare) --------------
/// Records a seed sale (or purchase) price in ANY currency. Returns its id.
Future<String> createSale({
required SaleDirection direction,
String? varietyId,
String? counterparty,
double? amount,
String? currency,
String? note,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db.into(_db.sales).insert(
SalesCompanion.insert(
id: id,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
direction: direction,
varietyId: Value(varietyId),
counterparty: Value(counterparty),
amount: Value(amount),
currency: Value(currency),
soldOn: created,
note: Value(note),
),
);
return id;
}
/// 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();
/// The sales recorded against one variety.
Stream<List<Sale>> watchSalesForVariety(String varietyId) =>
(_db.select(_db.sales)
..where(
(s) => s.varietyId.equals(varietyId) & s.isDeleted.equals(false),
)
..orderBy([(s) => OrderingTerm.desc(s.soldOn)]))
.watch();
/// Soft-deletes a sale (tombstone, so it syncs as a removal).
Future<void> deleteSale(String id) async {
final (_, updated) = await _stamp();
await (_db.update(_db.sales)..where((s) => s.id.equals(id))).write(
SalesCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// 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.
@ -1819,6 +1877,9 @@ class VarietyRepository {
plantares: await (_db.select(
_db.plantares,
)..where((p) => p.isDeleted.equals(false))).get(),
sales: await (_db.select(
_db.sales,
)..where((s) => s.isDeleted.equals(false))).get(),
);
}
@ -1842,6 +1903,7 @@ class VarietyRepository {
movements: await _db.select(_db.movements).get(),
parties: await _db.select(_db.parties).get(),
plantares: await _db.select(_db.plantares).get(),
sales: await _db.select(_db.sales).get(),
// attachments intentionally omitted photos don't ride the sync wire.
);
}
@ -1942,6 +2004,14 @@ class VarietyRepository {
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.sales,
idColumn: _db.sales.id,
rows: snapshot.sales,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMovements(snapshot.movements, reconciler);
});
@ -1955,6 +2025,7 @@ class VarietyRepository {
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
for (final pl in snapshot.plantares) pl.updatedAt,
for (final s in snapshot.sales) s.updatedAt,
]);
if (maxIncoming != null) {
_clock = _clock.receiveEvent(maxIncoming, _now());