124 lines
4.1 KiB
Go
124 lines
4.1 KiB
Go
|
|
package storage
|
||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ClassificationRow is a stored AI classification of a receipt, joined with the
|
||
|
|
// receipt's final field values so the caller can derive which fields the user
|
||
|
|
// overrode (a "miss"). The response blob is opaque diagnostic data here — the web
|
||
|
|
// layer parses it; storage never interprets it.
|
||
|
|
type ClassificationRow struct {
|
||
|
|
ReceiptID string
|
||
|
|
Model string
|
||
|
|
ResponseJSON string
|
||
|
|
CreatedAt time.Time
|
||
|
|
Reviewed bool
|
||
|
|
ReviewedAt *time.Time
|
||
|
|
Resolution string
|
||
|
|
|
||
|
|
// Final receipt values, for deriving overrides and display.
|
||
|
|
FinalAmountCents int64
|
||
|
|
FinalReceiptDate time.Time
|
||
|
|
FinalCategoryID int64
|
||
|
|
FinalPersonID *int64
|
||
|
|
}
|
||
|
|
|
||
|
|
// InsertClassification records the AI suggestion (its response blob + model) for a
|
||
|
|
// receipt. One row per AI-run upload; skip-AI/disabled uploads insert nothing.
|
||
|
|
func (s *Store) InsertClassification(receiptID, model, responseJSON string, createdAt time.Time) error {
|
||
|
|
_, err := s.db.Exec(
|
||
|
|
`INSERT INTO classifications(receipt_id, model, response_json, created_at)
|
||
|
|
VALUES (?, ?, ?, ?)`,
|
||
|
|
receiptID, model, responseJSON, createdAt.UTC().Format(rfc3339))
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("insert classification: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const classificationSelect = `
|
||
|
|
SELECT c.receipt_id, c.model, c.response_json, c.created_at, c.reviewed, c.reviewed_at, c.resolution,
|
||
|
|
r.amount_cents, r.receipt_date, r.category_id, r.person_id
|
||
|
|
FROM classifications c
|
||
|
|
JOIN receipts r ON r.id = c.receipt_id`
|
||
|
|
|
||
|
|
// ListUnreviewedClassifications returns not-yet-reviewed classifications of live
|
||
|
|
// receipts, newest first. Whether each is an actual "miss" is derived by the caller
|
||
|
|
// from the blob vs. the final fields.
|
||
|
|
func (s *Store) ListUnreviewedClassifications() ([]ClassificationRow, error) {
|
||
|
|
rows, err := s.db.Query(classificationSelect +
|
||
|
|
` WHERE c.reviewed = 0 AND r.deleted_at IS NULL ORDER BY c.created_at DESC`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("list classifications: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
var out []ClassificationRow
|
||
|
|
for rows.Next() {
|
||
|
|
c, err := scanClassification(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out = append(out, c)
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetClassification returns the classification for one receipt.
|
||
|
|
func (s *Store) GetClassification(receiptID string) (ClassificationRow, error) {
|
||
|
|
return scanClassification(s.db.QueryRow(classificationSelect+` WHERE c.receipt_id = ?`, receiptID))
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanClassification(sc rowScanner) (ClassificationRow, error) {
|
||
|
|
var c ClassificationRow
|
||
|
|
var createdAt, receiptDate string
|
||
|
|
var reviewed int64
|
||
|
|
var reviewedAt, resolution sql.NullString
|
||
|
|
var personID sql.NullInt64
|
||
|
|
if err := sc.Scan(&c.ReceiptID, &c.Model, &c.ResponseJSON, &createdAt, &reviewed, &reviewedAt, &resolution,
|
||
|
|
&c.FinalAmountCents, &receiptDate, &c.FinalCategoryID, &personID); err != nil {
|
||
|
|
return ClassificationRow{}, fmt.Errorf("scan classification: %w", err)
|
||
|
|
}
|
||
|
|
c.CreatedAt, _ = time.Parse(rfc3339, createdAt)
|
||
|
|
c.FinalReceiptDate, _ = time.Parse(rfc3339, receiptDate)
|
||
|
|
c.Reviewed = reviewed != 0
|
||
|
|
if reviewedAt.Valid {
|
||
|
|
if t, err := time.Parse(rfc3339, reviewedAt.String); err == nil {
|
||
|
|
c.ReviewedAt = &t
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if resolution.Valid {
|
||
|
|
c.Resolution = resolution.String
|
||
|
|
}
|
||
|
|
if personID.Valid {
|
||
|
|
c.FinalPersonID = &personID.Int64
|
||
|
|
}
|
||
|
|
return c, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// MarkClassificationReviewed flags a classification reviewed with the given
|
||
|
|
// resolution and links the notes the user credited with fixing it.
|
||
|
|
func (s *Store) MarkClassificationReviewed(receiptID, resolution string, fixNoteIDs []int64) error {
|
||
|
|
tx, err := s.db.Begin()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("begin review: %w", err)
|
||
|
|
}
|
||
|
|
defer tx.Rollback()
|
||
|
|
|
||
|
|
if _, err := tx.Exec(
|
||
|
|
`UPDATE classifications SET reviewed = 1, reviewed_at = ?, resolution = ? WHERE receipt_id = ?`,
|
||
|
|
time.Now().UTC().Format(rfc3339), resolution, receiptID); err != nil {
|
||
|
|
return fmt.Errorf("mark reviewed: %w", err)
|
||
|
|
}
|
||
|
|
for _, nid := range fixNoteIDs {
|
||
|
|
if _, err := tx.Exec(
|
||
|
|
`INSERT OR IGNORE INTO miss_fixes(receipt_id, note_id) VALUES (?, ?)`,
|
||
|
|
receiptID, nid); err != nil {
|
||
|
|
return fmt.Errorf("link fix note: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return tx.Commit()
|
||
|
|
}
|