204 lines
6.1 KiB
Go
204 lines
6.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
type Fields struct {
|
|
Merchant bool `json:"merchant"`
|
|
Category bool `json:"category"`
|
|
Tags bool `json:"tags"`
|
|
}
|
|
type PreviewRequest struct {
|
|
Revision string `json:"revision"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Model string `json:"model"`
|
|
Fields Fields `json:"fields"`
|
|
}
|
|
type Change struct {
|
|
ID string `json:"id"`
|
|
Description string `json:"description"`
|
|
Before domain.Enrichment `json:"before"`
|
|
After domain.Enrichment `json:"after"`
|
|
}
|
|
type ClassificationError struct {
|
|
ID string `json:"id"`
|
|
Error string `json:"error"`
|
|
}
|
|
type Preview struct {
|
|
ID string `json:"id"`
|
|
Revision string `json:"revision"`
|
|
Changes []Change `json:"changes"`
|
|
Analysed int `json:"analysed"`
|
|
Unchanged int `json:"unchanged"`
|
|
Errors []ClassificationError `json:"errors"`
|
|
NewMerchants []domain.Merchant `json:"new_merchants"`
|
|
created time.Time
|
|
}
|
|
|
|
func validRange(from, to string) error {
|
|
f, e := time.Parse("2006-01-02", from)
|
|
if e != nil {
|
|
return errors.New("from must be YYYY-MM-DD")
|
|
}
|
|
t, e := time.Parse("2006-01-02", to)
|
|
if e != nil {
|
|
return errors.New("to must be YYYY-MM-DD")
|
|
}
|
|
if f.After(t) {
|
|
return errors.New("from must not exceed to")
|
|
}
|
|
return nil
|
|
}
|
|
func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
|
if err := validRange(r.From, r.To); err != nil {
|
|
return Preview{}, err
|
|
}
|
|
if !r.Fields.Merchant && !r.Fields.Category && !r.Fields.Tags {
|
|
return Preview{}, errors.New("select at least one enrichment field")
|
|
}
|
|
if strings.TrimSpace(r.Model) == "" {
|
|
return Preview{}, errors.New("model is required")
|
|
}
|
|
a.mu.Lock()
|
|
s, err := a.snapshot(ctx)
|
|
client := a.classifier.WithModel(r.Model)
|
|
a.mu.Unlock()
|
|
if err != nil {
|
|
return Preview{}, err
|
|
}
|
|
if r.Revision != s.Revision {
|
|
return Preview{}, errors.New("revision conflict: reload before analysing")
|
|
}
|
|
p := Preview{ID: domain.NewID("preview"), Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
|
|
baseMerchants := len(s.Data.Merchants)
|
|
for _, t := range s.Data.Transactions {
|
|
if t.Facts.BookingDate < r.From || t.Facts.BookingDate > r.To || t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment {
|
|
continue
|
|
}
|
|
if err = ctx.Err(); err != nil {
|
|
return Preview{}, err
|
|
}
|
|
p.Analysed++
|
|
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
|
|
if err = ctx.Err(); err != nil {
|
|
return Preview{}, err
|
|
}
|
|
if e != nil {
|
|
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
|
continue
|
|
}
|
|
after := t.Enrichment
|
|
if r.Fields.Merchant {
|
|
after.MerchantID = proposal.Enrichment.MerchantID
|
|
if e = addProposal(&s.Data, proposal, t.Facts); e != nil {
|
|
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
|
continue
|
|
}
|
|
}
|
|
if r.Fields.Category {
|
|
after.CategoryID = proposal.Enrichment.CategoryID
|
|
}
|
|
if r.Fields.Tags {
|
|
after.TagIDs = slices.Clone(proposal.Enrichment.TagIDs)
|
|
}
|
|
if e = domain.ValidateEnrichment(s.Data, t.Facts, after); e != nil {
|
|
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
|
continue
|
|
}
|
|
beforeComparable, afterComparable := t.Enrichment, after
|
|
beforeComparable.Classification = domain.Provenance{}
|
|
afterComparable.Classification = domain.Provenance{}
|
|
beforeComparable.TagIDs = slices.Clone(beforeComparable.TagIDs)
|
|
afterComparable.TagIDs = slices.Clone(afterComparable.TagIDs)
|
|
slices.Sort(beforeComparable.TagIDs)
|
|
slices.Sort(afterComparable.TagIDs)
|
|
if reflect.DeepEqual(beforeComparable, afterComparable) {
|
|
p.Unchanged++
|
|
continue
|
|
}
|
|
after.Classification = proposal.Enrichment.Classification
|
|
p.Changes = append(p.Changes, Change{t.Facts.ID, t.Facts.RawDescription, t.Enrichment, after})
|
|
}
|
|
p.NewMerchants = append([]domain.Merchant{}, s.Data.Merchants[baseMerchants:]...)
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
for id, old := range a.previews {
|
|
if time.Since(old.created) > time.Hour {
|
|
delete(a.previews, id)
|
|
}
|
|
}
|
|
if len(a.previews) >= 20 {
|
|
return Preview{}, errors.New("too many active previews; cancel one first")
|
|
}
|
|
a.previews[p.ID] = p
|
|
return p, nil
|
|
}
|
|
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
p, ok := a.previews[id]
|
|
if !ok || time.Since(p.created) > time.Hour {
|
|
return State{}, errors.New("preview expired or unknown; analyse again")
|
|
}
|
|
if rev != p.Revision {
|
|
return State{}, errors.New("revision conflict: preview was generated from different records")
|
|
}
|
|
s, err := a.snapshot(ctx)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
if s.Revision != rev {
|
|
return State{}, errors.New("revision conflict: data changed after preview; analyse again")
|
|
}
|
|
changes := map[string]domain.Enrichment{}
|
|
for _, c := range p.Changes {
|
|
changes[c.ID] = c.After
|
|
}
|
|
selected := map[string]bool{}
|
|
for _, id := range ids {
|
|
if _, ok := changes[id]; !ok {
|
|
return State{}, errors.New("selected transaction is not in preview")
|
|
}
|
|
selected[id] = true
|
|
}
|
|
if len(selected) == 0 {
|
|
return State{}, errors.New("select at least one change")
|
|
}
|
|
needed := map[string]bool{}
|
|
for i, t := range s.Data.Transactions {
|
|
if !selected[t.Facts.ID] {
|
|
continue
|
|
}
|
|
s.Data.Transactions[i].Enrichment = changes[t.Facts.ID]
|
|
needed[changes[t.Facts.ID].MerchantID] = true
|
|
}
|
|
for _, m := range p.NewMerchants {
|
|
if needed[m.ID] {
|
|
s.Data.Merchants = append(s.Data.Merchants, m)
|
|
}
|
|
}
|
|
for _, t := range s.Data.Transactions {
|
|
if selected[t.Facts.ID] && t.Enrichment.MerchantID != "" {
|
|
// New merchants already carry their first alias; existing merchants
|
|
// learn only when the real matcher stays unambiguous.
|
|
LearnAlias(&s.Data, t.Facts, t.Enrichment.MerchantID)
|
|
}
|
|
}
|
|
state, err := a.commit(ctx, rev, s.Data)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
delete(a.previews, id)
|
|
return state, nil
|
|
}
|
|
func (a *App) CancelPreview(id string) { a.mu.Lock(); defer a.mu.Unlock(); delete(a.previews, id) }
|