Seed provenance + inventory: source links and seed lots (#50) (#64)
Build image / build-and-push (push) Successful in 8s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #64.
This commit is contained in:
2026-07-21 05:39:24 +00:00
committed by steve
parent 2a8fe24766
commit 78a04672b5
18 changed files with 1511 additions and 31 deletions
+5
View File
@@ -234,6 +234,11 @@ func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string)
// Unreachable: every active planting joins an object we just copied.
return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID)
}
// A copy must not inherit the source's seed-lot attribution. The copied
// plops are a plan, not a second planting out of the same packet, and
// carrying the link would double-count that lot's usage — and quietly
// attach a private lot to a garden that may later be shared.
p.SeedLotID = nil
if _, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(objectID, &p)...)); err != nil {
return nil, fmt.Errorf("store: copy planting: %w", err)
}
@@ -0,0 +1,53 @@
-- Seed provenance and inventory (#50): where "German Red Garlic" came from, and
-- how much of it is left.
--
-- Two modelling calls worth stating, because both look like omissions otherwise:
--
-- 1. The plant catalog stays FLAT. No species→variety hierarchy. A row in your
-- catalog just is the thing you plant — "German Red Garlic" is a plant row,
-- and the built-in "Garlic" is a generic starting point you clone. A
-- hierarchy buys taxonomy nobody asked for and complicates every join.
--
-- 2. Inventory belongs to a PURCHASE, not to a variety. You might buy the same
-- garlic from two vendors in two years at two prices and two germination
-- rates. Quantity columns on plants would collapse all of that into one
-- number and lose it, so lots get their own table.
--
-- Nothing here is destructive: plants gains two defaulted columns, the built-ins
-- keep working with empty strings, and plantings gains a nullable link.
ALTER TABLE plants ADD COLUMN source_url TEXT NOT NULL DEFAULT '';
ALTER TABLE plants ADD COLUMN vendor TEXT NOT NULL DEFAULT '';
-- owner_id sits on the LOT rather than being inherited from the plant, because a
-- lot may reference a built-in plant (you can buy generic Garlic from Johnny's)
-- while still being nobody's business but yours. Lots are never shared along
-- with a garden.
CREATE TABLE seed_lots (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE CASCADE,
vendor TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
sku TEXT NOT NULL DEFAULT '',
lot_code TEXT NOT NULL DEFAULT '',
purchased_at TEXT, -- 'YYYY-MM-DD'
packed_for_year INTEGER,
quantity REAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL CHECK (unit IN ('seeds','grams','ounces','packets','bulbs','plants')),
cost_cents INTEGER,
germination_pct REAL,
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_seed_lots_owner ON seed_lots (owner_id);
CREATE INDEX idx_seed_lots_plant ON seed_lots (plant_id);
-- Which lot a planting came out of. ON DELETE SET NULL rather than CASCADE:
-- retiring a lot must never destroy planting history — the plants really were in
-- the ground, whatever happened to the record of the packet.
ALTER TABLE plantings ADD COLUMN seed_lot_id INTEGER REFERENCES seed_lots (id) ON DELETE SET NULL;
CREATE INDEX idx_plantings_seed_lot ON plantings (seed_lot_id) WHERE seed_lot_id IS NOT NULL;
+10 -10
View File
@@ -12,13 +12,13 @@ import (
// plantingColumns lists plantings columns in the order scanPlanting expects.
// Used unqualified for direct selects; the /full read below qualifies with pl.
const plantingColumns = `id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label,
planted_at, removed_at, version, created_at, updated_at`
planted_at, removed_at, seed_lot_id, version, created_at, updated_at`
func scanPlanting(s scanner) (*domain.Planting, error) {
var p domain.Planting
if err := s.Scan(
&p.ID, &p.ObjectID, &p.PlantID, &p.XCM, &p.YCM, &p.RadiusCM,
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt,
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt, &p.SeedLotID,
&p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
@@ -89,12 +89,12 @@ func (d *DB) RestorePlanting(ctx context.Context, p *domain.Planting) (*domain.P
restored, err := scanPlanting(d.sql.QueryRowContext(ctx,
`INSERT INTO plantings
(id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, removed_at,
version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
seed_lot_id, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
RETURNING `+plantingColumns,
p.ID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label,
p.PlantedAt, p.RemovedAt, p.Version, p.CreatedAt,
p.PlantedAt, p.RemovedAt, p.SeedLotID, p.Version, p.CreatedAt,
))
if err != nil {
return nil, fmt.Errorf("store: restore planting: %w", err)
@@ -153,14 +153,14 @@ func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error
// — with plantingInsertArgs supplying its parameters — so all three stay in step
// with the table. removed_at is never set here: a new plop is active, and "clear
// bed" sets removed_at later via UpdatePlanting.
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, seed_lot_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING ` + plantingColumns
// plantingInsertArgs builds plantingInsert's parameters, parenting p to objectID
// (p's own object normally, and the copied object when copying a garden).
func plantingInsertArgs(objectID int64, p *domain.Planting) []any {
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt}
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.SeedLotID}
}
// CreatePlanting inserts a plop (fields already validated by the service) and
@@ -207,12 +207,12 @@ func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Pl
updated, err := scanPlanting(d.sql.QueryRowContext(ctx,
`UPDATE plantings
SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?,
planted_at = ?, removed_at = ?,
planted_at = ?, removed_at = ?, seed_lot_id = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+plantingColumns,
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt,
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID,
p.ID, p.Version,
))
if errors.Is(err, sql.ErrNoRows) {
+9 -7
View File
@@ -11,13 +11,13 @@ import (
// plantColumns lists plants columns in the order scanPlant expects.
const plantColumns = `id, owner_id, name, category, spacing_cm, color, icon,
days_to_maturity, notes, version, created_at, updated_at`
days_to_maturity, source_url, vendor, notes, version, created_at, updated_at`
func scanPlant(s scanner) (*domain.Plant, error) {
var p domain.Plant
if err := s.Scan(
&p.ID, &p.OwnerID, &p.Name, &p.Category, &p.SpacingCM, &p.Color, &p.Icon,
&p.DaysToMaturity, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
&p.DaysToMaturity, &p.SourceURL, &p.Vendor, &p.Notes, &p.Version, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
@@ -111,10 +111,11 @@ func (d *DB) GetPlant(ctx context.Context, id int64) (*domain.Plant, error) {
// migration only, never through this path.
func (d *DB) CreatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, error) {
created, err := scanPlant(d.sql.QueryRowContext(ctx,
`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon, days_to_maturity, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon, days_to_maturity, source_url, vendor, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+plantColumns,
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
p.OwnerID, p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity,
p.SourceURL, p.Vendor, p.Notes,
))
if err != nil {
return nil, fmt.Errorf("store: insert plant: %w", err)
@@ -130,12 +131,13 @@ func (d *DB) UpdatePlant(ctx context.Context, p *domain.Plant) (*domain.Plant, e
updated, err := scanPlant(d.sql.QueryRowContext(ctx,
`UPDATE plants
SET name = ?, category = ?, spacing_cm = ?, color = ?, icon = ?,
days_to_maturity = ?, notes = ?,
days_to_maturity = ?, source_url = ?, vendor = ?, notes = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+plantColumns,
p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity, p.Notes,
p.Name, p.Category, p.SpacingCM, p.Color, p.Icon, p.DaysToMaturity,
p.SourceURL, p.Vendor, p.Notes,
p.ID, p.Version,
))
if errors.Is(err, sql.ErrNoRows) {
+217
View File
@@ -0,0 +1,217 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// maxSeedLotsPerRead caps a lot listing. Far past any real seed shelf; it exists
// so the read is bounded rather than trusting it to stay small.
const maxSeedLotsPerRead = 1000
// seedLotColumns lists seed_lots columns in the order scanSeedLot expects.
const seedLotColumns = `id, owner_id, plant_id, vendor, source_url, sku, lot_code,
purchased_at, packed_for_year, quantity, unit, cost_cents, germination_pct, notes,
version, created_at, updated_at`
func scanSeedLot(s scanner) (*domain.SeedLot, error) {
var l domain.SeedLot
if err := s.Scan(
&l.ID, &l.OwnerID, &l.PlantID, &l.Vendor, &l.SourceURL, &l.SKU, &l.LotCode,
&l.PurchasedAt, &l.PackedForYear, &l.Quantity, &l.Unit, &l.CostCents,
&l.GerminationPct, &l.Notes, &l.Version, &l.CreatedAt, &l.UpdatedAt,
); err != nil {
return nil, err
}
return &l, nil
}
// CreateSeedLot inserts a lot (fields already validated by the service).
func (d *DB) CreateSeedLot(ctx context.Context, l *domain.SeedLot) (*domain.SeedLot, error) {
created, err := scanSeedLot(d.sql.QueryRowContext(ctx,
`INSERT INTO seed_lots
(owner_id, plant_id, vendor, source_url, sku, lot_code, purchased_at,
packed_for_year, quantity, unit, cost_cents, germination_pct, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+seedLotColumns,
l.OwnerID, l.PlantID, l.Vendor, l.SourceURL, l.SKU, l.LotCode, l.PurchasedAt,
l.PackedForYear, l.Quantity, l.Unit, l.CostCents, l.GerminationPct, l.Notes,
))
if err != nil {
return nil, fmt.Errorf("store: insert seed lot: %w", err)
}
return created, nil
}
// GetSeedLot returns a lot by id, or domain.ErrNotFound. Ownership is the
// service's check, not this one's.
func (d *DB) GetSeedLot(ctx context.Context, id int64) (*domain.SeedLot, error) {
l, err := scanSeedLot(d.sql.QueryRowContext(ctx,
`SELECT `+seedLotColumns+` FROM seed_lots WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get seed lot: %w", err)
}
return l, nil
}
// ListSeedLotsForOwner returns every lot the actor owns, newest purchase first
// (undated lots last), then by id. Optionally narrowed to one plant. Always a
// non-nil slice.
func (d *DB) ListSeedLotsForOwner(ctx context.Context, ownerID int64, plantID *int64) ([]domain.SeedLot, error) {
query := `SELECT ` + seedLotColumns + ` FROM seed_lots WHERE owner_id = ?`
args := []any{ownerID}
if plantID != nil {
query += ` AND plant_id = ?`
args = append(args, *plantID)
}
// NULL purchased_at sorts last: an undated lot is the least useful to see
// first. Capped like every other list read so one runaway account can't ask
// for an unbounded result set.
query += ` ORDER BY purchased_at IS NULL, purchased_at DESC, id DESC LIMIT ?`
args = append(args, maxSeedLotsPerRead)
rows, err := d.sql.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list seed lots: %w", err)
}
defer rows.Close()
lots := []domain.SeedLot{}
for rows.Next() {
l, err := scanSeedLot(rows)
if err != nil {
return nil, fmt.Errorf("store: scan seed lot: %w", err)
}
lots = append(lots, *l)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate seed lots: %w", err)
}
return lots, nil
}
// UpdateSeedLot applies a version-guarded update of every mutable column (the
// service merges partial patches onto the current row first). Same contract as
// the other mutable resources: the updated row, or (current, ErrVersionConflict).
// plant_id and owner_id are immutable — re-pointing a purchase at a different
// variety or user would rewrite history rather than correct it.
func (d *DB) UpdateSeedLot(ctx context.Context, l *domain.SeedLot) (*domain.SeedLot, error) {
updated, err := scanSeedLot(d.sql.QueryRowContext(ctx,
`UPDATE seed_lots
SET vendor = ?, source_url = ?, sku = ?, lot_code = ?, purchased_at = ?,
packed_for_year = ?, quantity = ?, unit = ?, cost_cents = ?,
germination_pct = ?, notes = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+seedLotColumns,
l.Vendor, l.SourceURL, l.SKU, l.LotCode, l.PurchasedAt, l.PackedForYear,
l.Quantity, l.Unit, l.CostCents, l.GerminationPct, l.Notes,
l.ID, l.Version,
))
if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetSeedLot(ctx, l.ID)
if gerr != nil {
return nil, gerr
}
return current, domain.ErrVersionConflict
}
if err != nil {
return nil, fmt.Errorf("store: update seed lot: %w", err)
}
return updated, nil
}
// DeleteSeedLot removes a lot. Plantings attributed to it survive with a NULL
// seed_lot_id (see the migration): the plants really were in the ground,
// whatever happened to the record of the packet.
func (d *DB) DeleteSeedLot(ctx context.Context, id int64) error {
res, err := d.sql.ExecContext(ctx, `DELETE FROM seed_lots WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete seed lot: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("store: seed lot delete rows: %w", err)
}
if n == 0 {
return domain.ErrNotFound
}
return nil
}
// CountSeedLotsForPlant returns how many lots reference a plant. Used to refuse
// deleting a plant that has purchase records: the FK is ON DELETE CASCADE, so
// without this check the delete would silently destroy them.
func (d *DB) CountSeedLotsForPlant(ctx context.Context, plantID int64) (int, error) {
var n int
if err := d.sql.QueryRowContext(ctx,
`SELECT COUNT(*) FROM seed_lots WHERE plant_id = ?`, plantID).Scan(&n); err != nil {
return 0, fmt.Errorf("store: count seed lots for plant: %w", err)
}
return n, nil
}
// SeedLotUse is one active planting attributed to a lot, carrying just enough to
// compute its effective plant count.
type SeedLotUse struct {
LotID int64
RadiusCM float64
Count *int
SpacingCM float64
}
// SeedLotUsage returns the ACTIVE plantings attributed to the given lots — the
// "used" half of a lot's remaining quantity. Scoped to the lots the caller
// actually needs, so reading one lot doesn't scan every planting the owner has.
//
// It returns the raw ingredients rather than a SUM, because the effective count
// of a planting is its explicit count when it has one and the derived
// π·r²/spacing² formula otherwise. That formula lives in the service; reproducing
// it in SQL would guarantee the two drift apart.
//
// ownerID is still in the WHERE clause even though the ids come from rows the
// service already checked: a scoping query should not depend on its caller
// having checked scope.
func (d *DB) SeedLotUsage(ctx context.Context, ownerID int64, lotIDs []int64) ([]SeedLotUse, error) {
if len(lotIDs) == 0 {
return nil, nil
}
args := make([]any, 0, len(lotIDs)+1)
args = append(args, ownerID)
for _, id := range lotIDs {
args = append(args, id)
}
rows, err := d.sql.QueryContext(ctx,
`SELECT pl.seed_lot_id, pl.radius_cm, pl.count, p.spacing_cm
FROM plantings pl
JOIN seed_lots sl ON sl.id = pl.seed_lot_id
JOIN plants p ON p.id = pl.plant_id
WHERE sl.owner_id = ? AND pl.removed_at IS NULL
AND sl.id IN (`+placeholders(len(lotIDs))+`)`,
args...)
if err != nil {
return nil, fmt.Errorf("store: seed lot usage: %w", err)
}
defer rows.Close()
uses := []SeedLotUse{}
for rows.Next() {
var u SeedLotUse
if err := rows.Scan(&u.LotID, &u.RadiusCM, &u.Count, &u.SpacingCM); err != nil {
return nil, fmt.Errorf("store: scan seed lot usage: %w", err)
}
uses = append(uses, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate seed lot usage: %w", err)
}
return uses, nil
}