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:
parent
de6938d5d7
commit
6de039d518
27 changed files with 6156 additions and 23 deletions
|
|
@ -230,6 +230,26 @@ class InventoryJsonCodec {
|
|||
'note': p.note,
|
||||
},
|
||||
],
|
||||
'sales': [
|
||||
for (final s in snapshot.sales)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
lastAuthor: s.lastAuthor,
|
||||
schemaRowVersion: s.schemaRowVersion,
|
||||
isDeleted: s.isDeleted,
|
||||
),
|
||||
'varietyId': s.varietyId,
|
||||
'direction': s.direction.name,
|
||||
'counterparty': s.counterparty,
|
||||
'amount': s.amount,
|
||||
'currency': s.currency,
|
||||
'soldOn': s.soldOn,
|
||||
'note': s.note,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -462,6 +482,24 @@ class InventoryJsonCodec {
|
|||
note: m['note'] as String?,
|
||||
);
|
||||
}),
|
||||
sales: _rows(root, 'sales', (m) {
|
||||
return Sale(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: _bool(m, 'isDeleted'),
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
varietyId: m['varietyId'] as String?,
|
||||
direction:
|
||||
_enumOr(SaleDirection.values, m['direction'], SaleDirection.iSold),
|
||||
counterparty: m['counterparty'] as String?,
|
||||
amount: (m['amount'] as num?)?.toDouble(),
|
||||
currency: m['currency'] as String?,
|
||||
soldOn: _int(m, 'soldOn'),
|
||||
note: m['note'] as String?,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class InventorySnapshot {
|
|||
this.parties = const [],
|
||||
this.attachments = const [],
|
||||
this.plantares = const [],
|
||||
this.sales = const [],
|
||||
this.speciesNamesById = const {},
|
||||
});
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ class InventorySnapshot {
|
|||
final List<Party> parties;
|
||||
final List<Attachment> attachments;
|
||||
final List<Plantare> plantares;
|
||||
final List<Sale> sales;
|
||||
|
||||
/// speciesId → scientificName, for the species referenced by [varieties].
|
||||
final Map<String, String> speciesNamesById;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue