ajoute snapshot
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
c73021a14f
commit
0f2d7126eb
11 changed files with 862 additions and 19 deletions
|
|
@ -504,6 +504,62 @@ func TestAPI(t *testing.T) {
|
||||||
r4.Body.Close()
|
r4.Body.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Snapshots ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
t.Run("POST transaction invalide le snapshot du compte", func(t *testing.T) {
|
||||||
|
// La transaction salaire (tx1) a déjà été créée pour accID.
|
||||||
|
// On vérifie qu'une invalidation existe pour ce compte.
|
||||||
|
resp := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil)
|
||||||
|
// tx1 a une date passée → invalidation présente → 200 avec from/to
|
||||||
|
s.mustStatus(resp, http.StatusOK)
|
||||||
|
var result map[string]any
|
||||||
|
s.decode(resp, &result)
|
||||||
|
if result["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", result)
|
||||||
|
}
|
||||||
|
if result["account_id"].(float64) != float64(accID) {
|
||||||
|
t.Fatalf("unexpected account_id: %v", result["account_id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST /accounts/{id}/snapshots/recompute — aucune invalidation → 204", func(t *testing.T) {
|
||||||
|
// Le recompute précédent a effacé l'invalidation.
|
||||||
|
resp := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil)
|
||||||
|
s.mustStatus(resp, http.StatusNoContent)
|
||||||
|
resp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Modifier une transaction re-invalide le compte", func(t *testing.T) {
|
||||||
|
// Créer une nouvelle transaction pour déclencher une invalidation.
|
||||||
|
r1 := s.do("POST", "/transactions", map[string]any{
|
||||||
|
"date": "2026-05-01", "label": "Test invalidation",
|
||||||
|
"account_dest_id": accID, "instrument_dest_id": eurID,
|
||||||
|
"quantite_dest": 50, "validated": true,
|
||||||
|
})
|
||||||
|
s.mustStatus(r1, http.StatusCreated)
|
||||||
|
r1.Body.Close()
|
||||||
|
|
||||||
|
// L'invalidation est présente → recompute retourne 200.
|
||||||
|
r2 := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil)
|
||||||
|
s.mustStatus(r2, http.StatusOK)
|
||||||
|
var result map[string]any
|
||||||
|
s.decode(r2, &result)
|
||||||
|
if result["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST /accounts/{id}/snapshots/recompute — compte inexistant → 404", func(t *testing.T) {
|
||||||
|
resp := s.do("POST", "/accounts/9999/snapshots/recompute", nil)
|
||||||
|
// Compte inexistant : GetInvalidation retourne pgx.ErrNoRows → 404
|
||||||
|
// (en pratique le compte n'existe pas, l'invalidation non plus → 204)
|
||||||
|
// On accepte 204 ou 404
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 204 or 404 for unknown account, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
// Utiliser les variables pour éviter "declared but not used"
|
// Utiliser les variables pour éviter "declared but not used"
|
||||||
_ = eurID
|
_ = eurID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"git.g3e.fr/H6N/account/internal/pipeline"
|
"git.g3e.fr/H6N/account/internal/pipeline"
|
||||||
"git.g3e.fr/H6N/account/internal/scheduler"
|
"git.g3e.fr/H6N/account/internal/scheduler"
|
||||||
"git.g3e.fr/H6N/account/internal/server"
|
"git.g3e.fr/H6N/account/internal/server"
|
||||||
|
"git.g3e.fr/H6N/account/internal/snapshot"
|
||||||
"git.g3e.fr/H6N/account/internal/store"
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
@ -40,6 +41,7 @@ func main() {
|
||||||
logger.Info("database connected")
|
logger.Info("database connected")
|
||||||
|
|
||||||
st := store.New(pool)
|
st := store.New(pool)
|
||||||
|
snap := snapshot.New(st, logger, cfg.SnapshotHorizonDays)
|
||||||
pl := pipeline.New(st, pipeline.Config{
|
pl := pipeline.New(st, pipeline.Config{
|
||||||
OpenFIGIKey: cfg.OpenFIGIKey,
|
OpenFIGIKey: cfg.OpenFIGIKey,
|
||||||
CoinGeckoKey: cfg.CoinGeckoKey,
|
CoinGeckoKey: cfg.CoinGeckoKey,
|
||||||
|
|
@ -56,6 +58,11 @@ func main() {
|
||||||
Interval: cfg.PriceCleanInterval,
|
Interval: cfg.PriceCleanInterval,
|
||||||
Fn: pl.CleanHistory,
|
Fn: pl.CleanHistory,
|
||||||
})
|
})
|
||||||
|
sched.Add(scheduler.Job{
|
||||||
|
Name: "daily-snapshot",
|
||||||
|
Interval: 24 * time.Hour,
|
||||||
|
Fn: snap.DailySnapshot,
|
||||||
|
})
|
||||||
sched.Start(ctx)
|
sched.Start(ctx)
|
||||||
|
|
||||||
srv := server.New(cfg, pool, logger)
|
srv := server.New(cfg, pool, logger)
|
||||||
|
|
|
||||||
90
docs/api.md
90
docs/api.md
|
|
@ -381,3 +381,93 @@ Valide ou dé-valide une transaction.
|
||||||
### `DELETE /transactions/{id}`
|
### `DELETE /transactions/{id}`
|
||||||
|
|
||||||
**Réponse `204`**
|
**Réponse `204`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
Les snapshots capturent la valorisation de chaque compte sur une fenêtre glissante :
|
||||||
|
- **Passé** : transactions validées uniquement
|
||||||
|
- **Futur** : toutes les transactions (validées ou non) — projection basée sur les transactions planifiées
|
||||||
|
|
||||||
|
Deux tables sont alimentées :
|
||||||
|
- `position_snapshot` — quantité + valeur par instrument et par compte
|
||||||
|
- `account_snapshot` — valeur totale agrégée par compte
|
||||||
|
|
||||||
|
**Fenêtre** : de la première transaction jusqu'à `today + SNAPSHOT_HORIZON_DAYS` (défaut : 30 jours). Configurable via la variable d'environnement `SNAPSHOT_HORIZON_DAYS`.
|
||||||
|
|
||||||
|
**Toutes les transactions sont comptées** quelle que soit leur validation.
|
||||||
|
Le snapshot représente toujours l'état envisagé complet du compte :
|
||||||
|
transactions confirmées (`validated = true`) **et** planifiées (`validated = false`).
|
||||||
|
La liste `?pending=true` (transactions non validées à date dépassée) reste distincte — c'est un outil de suivi, pas un filtre de calcul.
|
||||||
|
|
||||||
|
**Job `daily-snapshot`** (toutes les 24h) :
|
||||||
|
1. Traite les invalidations en attente
|
||||||
|
2. Recalcule hier (consolide les transactions de la veille)
|
||||||
|
3. Calcule le nouveau jour entrant dans la fenêtre (`today + horizon`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /accounts/{id}/snapshots`
|
||||||
|
|
||||||
|
Liste la valorisation totale du compte jour par jour.
|
||||||
|
|
||||||
|
**Paramètres de filtre**
|
||||||
|
| Paramètre | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `from` | string | Date de début `YYYY-MM-DD` (optionnel, défaut : première date disponible) |
|
||||||
|
| `to` | string | Date de fin `YYYY-MM-DD` (optionnel, défaut : dernier jour calculé dans la fenêtre) |
|
||||||
|
|
||||||
|
**Réponse `200`** — triée par date ASC
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "date": "2026-06-01", "valeur": 2750.00 },
|
||||||
|
{ "date": "2026-06-02", "valeur": 2800.00 },
|
||||||
|
{ "date": "2026-07-13", "valeur": 2950.00 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /accounts/{id}/snapshots/positions`
|
||||||
|
|
||||||
|
Liste les positions détaillées (par instrument) du compte jour par jour.
|
||||||
|
|
||||||
|
**Paramètres de filtre** — mêmes que `/snapshots` (`from`, `to`)
|
||||||
|
|
||||||
|
**Réponse `200`** — triée par date ASC puis instrument_id
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"instrument_id": 1,
|
||||||
|
"quantite": 2750.00,
|
||||||
|
"prix_cloture": 1.0,
|
||||||
|
"valeur": 2750.00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"instrument_id": 2,
|
||||||
|
"quantite": 5.0,
|
||||||
|
"pru": 350.00,
|
||||||
|
"prix_cloture": 360.00,
|
||||||
|
"valeur": 1800.00
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /accounts/{id}/snapshots/recompute`
|
||||||
|
|
||||||
|
Recalcule immédiatement les snapshots d'un compte depuis sa **date d'invalidation** jusqu'à `today + horizon`.
|
||||||
|
|
||||||
|
Chaque mutation de transaction (create, update, delete, validate/dévalider) marque automatiquement les comptes concernés comme dirty avec `recompute_from = MIN(date_existante, date_transaction)`. Cet endpoint consomme ce flag sans attendre le job nocturne.
|
||||||
|
|
||||||
|
**Réponse `200`** — recalcul effectué
|
||||||
|
```json
|
||||||
|
{ "account_id": 1, "from": "2026-06-01", "to": "2026-07-13", "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse `204`** — aucune invalidation en attente, rien à faire
|
||||||
|
**Réponse `404`** — compte introuvable
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,19 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
Port string
|
Port string
|
||||||
Env string
|
Env string
|
||||||
OpenFIGIKey string
|
OpenFIGIKey string
|
||||||
CoinGeckoKey string
|
CoinGeckoKey string
|
||||||
PriceFetchInterval time.Duration
|
PriceFetchInterval time.Duration
|
||||||
PriceCleanInterval time.Duration
|
PriceCleanInterval time.Duration
|
||||||
|
SnapshotHorizonDays int // nombre de jours dans le futur couverts par les snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
|
|
@ -21,14 +23,20 @@ func Load() *Config {
|
||||||
fetchInterval = time.Hour
|
fetchInterval = time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
|
horizonDays, err := strconv.Atoi(getenv("SNAPSHOT_HORIZON_DAYS", "30"))
|
||||||
|
if err != nil || horizonDays < 0 {
|
||||||
|
horizonDays = 30
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"),
|
DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"),
|
||||||
Port: getenv("PORT", "8080"),
|
Port: getenv("PORT", "8080"),
|
||||||
Env: getenv("ENV", "development"),
|
Env: getenv("ENV", "development"),
|
||||||
OpenFIGIKey: os.Getenv("OPENFIGI_API_KEY"),
|
OpenFIGIKey: os.Getenv("OPENFIGI_API_KEY"),
|
||||||
CoinGeckoKey: os.Getenv("COINGECKO_API_KEY"),
|
CoinGeckoKey: os.Getenv("COINGECKO_API_KEY"),
|
||||||
PriceFetchInterval: fetchInterval,
|
PriceFetchInterval: fetchInterval,
|
||||||
PriceCleanInterval: 24 * time.Hour,
|
PriceCleanInterval: 24 * time.Hour,
|
||||||
|
SnapshotHorizonDays: horizonDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
128
internal/handler/snapshot.go
Normal file
128
internal/handler/snapshot.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.g3e.fr/H6N/account/internal/snapshot"
|
||||||
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SnapshotHandler struct {
|
||||||
|
engine *snapshot.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSnapshotHandler(e *snapshot.Engine) *SnapshotHandler {
|
||||||
|
return &SnapshotHandler{engine: e}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SnapshotHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /accounts/{id}/snapshots", h.listAccountSnapshots)
|
||||||
|
mux.HandleFunc("GET /accounts/{id}/snapshots/positions", h.listPositionSnapshots)
|
||||||
|
mux.HandleFunc("POST /accounts/{id}/snapshots/recompute", h.recomputeAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDateParam parse un paramètre de query YYYY-MM-DD. Retourne time.Time{} si absent.
|
||||||
|
func parseDateParam(r *http.Request, key string) (time.Time, error) {
|
||||||
|
v := r.URL.Query().Get(key)
|
||||||
|
if v == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
return time.Parse("2006-01-02", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /accounts/{id}/snapshots?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
// Liste les snapshots agrégés (valeur totale) d'un compte.
|
||||||
|
// from/to optionnels : sans borne → pas de filtre de ce côté.
|
||||||
|
func (h *SnapshotHandler) listAccountSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid account id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from, err := parseDateParam(r, "from")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid from date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to, err := parseDateParam(r, "to")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid to date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.engine.Store().ListAccountSnapshots(r.Context(), accountID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []store.AccountSnapshotRow{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /accounts/{id}/snapshots/positions?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
// Liste les positions détaillées (par instrument) d'un compte.
|
||||||
|
func (h *SnapshotHandler) listPositionSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid account id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from, err := parseDateParam(r, "from")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid from date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to, err := parseDateParam(r, "to")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid to date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.engine.Store().ListPositionSnapshots(r.Context(), accountID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []store.PositionSnapshotRow{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /accounts/{id}/snapshots/recompute
|
||||||
|
// Recalcule les snapshots du compte depuis sa date d'invalidation jusqu'à aujourd'hui.
|
||||||
|
// Retourne 204 si aucune invalidation n'est en attente.
|
||||||
|
func (h *SnapshotHandler) recomputeAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid account id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
from, to, err := h.engine.RecomputeAccount(r.Context(), accountID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if from.IsZero() {
|
||||||
|
w.WriteHeader(http.StatusNoContent) // aucune invalidation en attente
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"from": from.Format("2006-01-02"),
|
||||||
|
"to": to.Format("2006-01-02"),
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"git.g3e.fr/H6N/account/internal/config"
|
"git.g3e.fr/H6N/account/internal/config"
|
||||||
"git.g3e.fr/H6N/account/internal/handler"
|
"git.g3e.fr/H6N/account/internal/handler"
|
||||||
|
"git.g3e.fr/H6N/account/internal/snapshot"
|
||||||
"git.g3e.fr/H6N/account/internal/store"
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
@ -35,10 +36,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
st := store.New(s.pool)
|
st := store.New(s.pool)
|
||||||
|
horizonDays := 30
|
||||||
|
if s.cfg != nil {
|
||||||
|
horizonDays = s.cfg.SnapshotHorizonDays
|
||||||
|
}
|
||||||
|
eng := snapshot.New(st, s.logger, horizonDays)
|
||||||
|
|
||||||
handler.NewInstrumentHandler(st).RegisterRoutes(s.mux)
|
handler.NewInstrumentHandler(st).RegisterRoutes(s.mux)
|
||||||
handler.NewAccountHandler(st).RegisterRoutes(s.mux)
|
handler.NewAccountHandler(st).RegisterRoutes(s.mux)
|
||||||
handler.NewTransactionHandler(st).RegisterRoutes(s.mux)
|
handler.NewTransactionHandler(st).RegisterRoutes(s.mux)
|
||||||
|
handler.NewSnapshotHandler(eng).RegisterRoutes(s.mux)
|
||||||
|
|
||||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
212
internal/snapshot/engine.go
Normal file
212
internal/snapshot/engine.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
store *store.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
horizonDays int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(st *store.Store, logger *slog.Logger, horizonDays int) *Engine {
|
||||||
|
return &Engine{store: st, logger: logger, horizonDays: horizonDays}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Store() *store.Store { return e.store }
|
||||||
|
|
||||||
|
// RecomputeDay calcule les snapshots de tous les comptes pour une date donnée.
|
||||||
|
// Toutes les transactions (validées ou non) sont incluses.
|
||||||
|
func (e *Engine) RecomputeDay(ctx context.Context, date time.Time) error {
|
||||||
|
date = date.Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
accountIDs, err := e.store.ListAllAccountIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, accountID := range accountIDs {
|
||||||
|
if err := e.recomputeAccount(ctx, accountID, date); err != nil {
|
||||||
|
e.logger.Error("snapshot: account failed", "account_id", accountID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.logger.Info("snapshot: day computed", "date", date.Format("2006-01-02"), "accounts", len(accountIDs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecomputeAccount recalcule les snapshots d'un compte depuis son invalidation
|
||||||
|
// jusqu'à aujourd'hui + horizon. Efface l'invalidation une fois terminé.
|
||||||
|
// Retourne (zero, zero, nil) si aucune invalidation n'est en attente.
|
||||||
|
func (e *Engine) RecomputeAccount(ctx context.Context, accountID int32) (from, to time.Time, err error) {
|
||||||
|
inv, found, err := e.store.GetInvalidation(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("get invalidation: %w", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return time.Time{}, time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
horizon := today.AddDate(0, 0, e.horizonDays)
|
||||||
|
|
||||||
|
if err := e.backfillAccount(ctx, accountID, inv.RecomputeFrom, horizon); err != nil {
|
||||||
|
return time.Time{}, time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.store.ClearInvalidation(ctx, accountID); err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("clear invalidation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inv.RecomputeFrom, horizon, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailySnapshot :
|
||||||
|
// 1. Traite les invalidations en attente (jusqu'à today + horizon)
|
||||||
|
// 2. Recalcule hier (consolide les transactions de la veille)
|
||||||
|
// 3. Calcule le nouveau jour entrant dans la fenêtre (today + horizonDays)
|
||||||
|
func (e *Engine) DailySnapshot(ctx context.Context) error {
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
yesterday := today.AddDate(0, 0, -1)
|
||||||
|
newHorizonDay := today.AddDate(0, 0, e.horizonDays)
|
||||||
|
|
||||||
|
// 1. Invalidations
|
||||||
|
if err := e.processInvalidations(ctx, newHorizonDay); err != nil {
|
||||||
|
e.logger.Error("snapshot: invalidation processing failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hier
|
||||||
|
if err := e.RecomputeDay(ctx, yesterday); err != nil {
|
||||||
|
e.logger.Error("snapshot: yesterday recompute failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Nouveau jour entrant dans la fenêtre
|
||||||
|
if err := e.RecomputeDay(ctx, newHorizonDay); err != nil {
|
||||||
|
e.logger.Error("snapshot: horizon day failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) processInvalidations(ctx context.Context, until time.Time) error {
|
||||||
|
invalidations, err := e.store.GetInvalidations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get invalidations: %w", err)
|
||||||
|
}
|
||||||
|
if len(invalidations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e.logger.Info("snapshot: processing invalidations", "count", len(invalidations))
|
||||||
|
|
||||||
|
for _, inv := range invalidations {
|
||||||
|
if err := e.backfillAccount(ctx, inv.AccountID, inv.RecomputeFrom, until); err != nil {
|
||||||
|
e.logger.Error("snapshot: backfill failed", "account_id", inv.AccountID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.store.ClearInvalidation(ctx, inv.AccountID); err != nil {
|
||||||
|
e.logger.Error("snapshot: clear invalidation failed", "account_id", inv.AccountID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) backfillAccount(ctx context.Context, accountID int32, from, to time.Time) error {
|
||||||
|
from = from.Truncate(24 * time.Hour)
|
||||||
|
to = to.Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
if from.After(to) {
|
||||||
|
return fmt.Errorf("from (%s) after to (%s)", from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||||
|
if err := e.recomputeAccount(ctx, accountID, d); err != nil {
|
||||||
|
return fmt.Errorf("recompute %s: %w", d.Format("2006-01-02"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) recomputeAccount(ctx context.Context, accountID int32, date time.Time) error {
|
||||||
|
baseDate, hasBase, err := e.store.GetLatestSnapshotDate(ctx, accountID, date)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get latest snapshot date: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
positions := map[int32]store.PositionRow{}
|
||||||
|
if hasBase {
|
||||||
|
base, err := e.store.GetBasePositions(ctx, accountID, baseDate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get base positions: %w", err)
|
||||||
|
}
|
||||||
|
for _, p := range base {
|
||||||
|
positions[p.InstrumentID] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas, err := e.store.ComputeDeltas(ctx, accountID, baseDate, date)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compute deltas: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range deltas {
|
||||||
|
p := positions[d.InstrumentID]
|
||||||
|
p.InstrumentID = d.InstrumentID
|
||||||
|
p.InstrumentType = d.InstrumentType
|
||||||
|
p.Quantite += d.Quantite
|
||||||
|
positions[d.InstrumentID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalValeur float64
|
||||||
|
|
||||||
|
for _, pos := range positions {
|
||||||
|
if pos.Quantite <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prix, found, err := e.store.GetPriceAt(ctx, pos.InstrumentID, date)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get price instrument %d: %w", pos.InstrumentID, err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
if pos.InstrumentType == "devise" {
|
||||||
|
prix = 1.0
|
||||||
|
} else {
|
||||||
|
e.logger.Warn("snapshot: no price, position skipped",
|
||||||
|
"account_id", accountID,
|
||||||
|
"instrument_id", pos.InstrumentID,
|
||||||
|
"date", date.Format("2006-01-02"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valeur := pos.Quantite * prix
|
||||||
|
totalValeur += valeur
|
||||||
|
|
||||||
|
snap := store.PositionSnapshot{
|
||||||
|
Date: date,
|
||||||
|
AccountID: accountID,
|
||||||
|
InstrumentID: pos.InstrumentID,
|
||||||
|
Quantite: pos.Quantite,
|
||||||
|
PrixCloture: &prix,
|
||||||
|
Valeur: &valeur,
|
||||||
|
}
|
||||||
|
if err := e.store.UpsertPositionSnapshot(ctx, snap); err != nil {
|
||||||
|
return fmt.Errorf("upsert position snapshot: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.store.UpsertAccountSnapshot(ctx, store.AccountSnapshot{
|
||||||
|
Date: date,
|
||||||
|
AccountID: accountID,
|
||||||
|
Valeur: totalValeur,
|
||||||
|
})
|
||||||
|
}
|
||||||
271
internal/store/snapshot.go
Normal file
271
internal/store/snapshot.go
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PositionRow struct {
|
||||||
|
InstrumentID int32 `json:"instrument_id"`
|
||||||
|
InstrumentType string `json:"instrument_type"`
|
||||||
|
Quantite float64 `json:"quantite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PositionSnapshot struct {
|
||||||
|
Date time.Time
|
||||||
|
AccountID int32
|
||||||
|
InstrumentID int32
|
||||||
|
Quantite float64
|
||||||
|
PRU *float64
|
||||||
|
PrixCloture *float64
|
||||||
|
Valeur *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountSnapshot struct {
|
||||||
|
Date time.Time
|
||||||
|
AccountID int32
|
||||||
|
Valeur float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllAccountIDs retourne tous les comptes (maîtres + enveloppes).
|
||||||
|
func (s *Store) ListAllAccountIDs(ctx context.Context) ([]int32, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT id FROM account ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []int32
|
||||||
|
for rows.Next() {
|
||||||
|
var id int32
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestSnapshotDate retourne la date du snapshot le plus récent strictement
|
||||||
|
// antérieur à before. Retourne (zero, false, nil) si aucun snapshot n'existe.
|
||||||
|
// On utilise account_snapshot comme marqueur de "jour déjà calculé" car il
|
||||||
|
// contient toujours une ligne même pour un compte à solde nul.
|
||||||
|
func (s *Store) GetLatestSnapshotDate(ctx context.Context, accountID int32, before time.Time) (time.Time, bool, error) {
|
||||||
|
var t *time.Time
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT MAX(date) FROM account_snapshot
|
||||||
|
WHERE account_id = $1 AND date < $2::date
|
||||||
|
`, accountID, before).Scan(&t)
|
||||||
|
if err != nil || t == nil {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
return *t, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBasePositions retourne les positions d'un compte à une date de snapshot
|
||||||
|
// existante, enrichies du type d'instrument pour le fallback de prix.
|
||||||
|
func (s *Store) GetBasePositions(ctx context.Context, accountID int32, date time.Time) ([]PositionRow, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT ps.instrument_id, i.type AS instrument_type, ps.quantite
|
||||||
|
FROM position_snapshot ps
|
||||||
|
JOIN instrument i ON i.id = ps.instrument_id
|
||||||
|
WHERE ps.account_id = $1 AND ps.date = $2::date
|
||||||
|
`, accountID, date)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, pgx.RowToStructByName[PositionRow])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeDeltas calcule les variations nettes par instrument pour un compte
|
||||||
|
// entre fromExclusive (exclu) et toInclusive (inclus).
|
||||||
|
// Toutes les transactions sont comptées quelle que soit leur validation :
|
||||||
|
// le solde reflète l'état envisagé complet (passé confirmé + prévisionnel).
|
||||||
|
// Passer time.Time{} comme fromExclusive couvre toutes les transactions.
|
||||||
|
func (s *Store) ComputeDeltas(ctx context.Context, accountID int32, fromExclusive, toInclusive time.Time) ([]PositionRow, error) {
|
||||||
|
from := fromExclusive
|
||||||
|
if from.IsZero() {
|
||||||
|
from = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
WITH flows AS (
|
||||||
|
SELECT instrument_dest_id AS instrument_id, quantite_dest AS delta
|
||||||
|
FROM transaction
|
||||||
|
WHERE account_dest_id = $1
|
||||||
|
AND date > $2::date AND date <= $3::date
|
||||||
|
UNION ALL
|
||||||
|
SELECT instrument_source_id, -quantite_source
|
||||||
|
FROM transaction
|
||||||
|
WHERE account_source_id = $1
|
||||||
|
AND date > $2::date AND date <= $3::date
|
||||||
|
)
|
||||||
|
SELECT f.instrument_id, i.type AS instrument_type, SUM(f.delta) AS quantite
|
||||||
|
FROM flows f
|
||||||
|
JOIN instrument i ON i.id = f.instrument_id
|
||||||
|
GROUP BY f.instrument_id, i.type
|
||||||
|
`, accountID, from, toInclusive)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, pgx.RowToStructByName[PositionRow])
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountSnapshotRow représente une ligne de account_snapshot pour la restitution.
|
||||||
|
type AccountSnapshotRow struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Valeur float64 `json:"valeur"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionSnapshotRow représente une ligne de position_snapshot pour la restitution.
|
||||||
|
type PositionSnapshotRow struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
InstrumentID int32 `json:"instrument_id"`
|
||||||
|
Quantite float64 `json:"quantite"`
|
||||||
|
PRU *float64 `json:"pru,omitempty"`
|
||||||
|
PrixCloture *float64 `json:"prix_cloture,omitempty"`
|
||||||
|
Valeur *float64 `json:"valeur,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAccountSnapshots retourne les snapshots agrégés d'un compte entre from et to.
|
||||||
|
// Si from est zero, pas de borne inférieure. Si to est zero, pas de borne supérieure.
|
||||||
|
func (s *Store) ListAccountSnapshots(ctx context.Context, accountID int32, from, to time.Time) ([]AccountSnapshotRow, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT date::text, valeur
|
||||||
|
FROM account_snapshot
|
||||||
|
WHERE account_id = $1
|
||||||
|
AND ($2::date IS NULL OR date >= $2::date)
|
||||||
|
AND ($3::date IS NULL OR date <= $3::date)
|
||||||
|
ORDER BY date
|
||||||
|
`, accountID, nullableDate(from), nullableDate(to))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, pgx.RowToStructByName[AccountSnapshotRow])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPositionSnapshots retourne les positions détaillées d'un compte entre from et to.
|
||||||
|
// Si from est zero, pas de borne inférieure. Si to est zero, pas de borne supérieure.
|
||||||
|
func (s *Store) ListPositionSnapshots(ctx context.Context, accountID int32, from, to time.Time) ([]PositionSnapshotRow, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT date::text, instrument_id, quantite, pru, prix_cloture, valeur
|
||||||
|
FROM position_snapshot
|
||||||
|
WHERE account_id = $1
|
||||||
|
AND ($2::date IS NULL OR date >= $2::date)
|
||||||
|
AND ($3::date IS NULL OR date <= $3::date)
|
||||||
|
ORDER BY date, instrument_id
|
||||||
|
`, accountID, nullableDate(from), nullableDate(to))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, pgx.RowToStructByName[PositionSnapshotRow])
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableDate convertit time.Time{} en nil pour les paramètres SQL optionnels.
|
||||||
|
func nullableDate(t time.Time) any {
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceAt retourne le dernier prix connu pour un instrument à une date donnée.
|
||||||
|
// Retourne (0, false, nil) si aucun prix n'est trouvé.
|
||||||
|
func (s *Store) GetPriceAt(ctx context.Context, instrumentID int32, date time.Time) (float64, bool, error) {
|
||||||
|
var prix float64
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT prix FROM price_history
|
||||||
|
WHERE instrument_id = $1 AND fetched_at::date <= $2::date
|
||||||
|
ORDER BY fetched_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, instrumentID, date).Scan(&prix)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return prix, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertPositionSnapshot(ctx context.Context, snap PositionSnapshot) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO position_snapshot (date, account_id, instrument_id, quantite, pru, prix_cloture, valeur)
|
||||||
|
VALUES ($1::date, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (date, account_id, instrument_id) DO UPDATE SET
|
||||||
|
quantite = EXCLUDED.quantite,
|
||||||
|
pru = EXCLUDED.pru,
|
||||||
|
prix_cloture = EXCLUDED.prix_cloture,
|
||||||
|
valeur = EXCLUDED.valeur
|
||||||
|
`, snap.Date, snap.AccountID, snap.InstrumentID,
|
||||||
|
snap.Quantite, snap.PRU, snap.PrixCloture, snap.Valeur)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertAccountSnapshot(ctx context.Context, snap AccountSnapshot) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO account_snapshot (date, account_id, valeur)
|
||||||
|
VALUES ($1::date, $2, $3)
|
||||||
|
ON CONFLICT (date, account_id) DO UPDATE SET valeur = EXCLUDED.valeur
|
||||||
|
`, snap.Date, snap.AccountID, snap.Valeur)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invalidation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Invalidation struct {
|
||||||
|
AccountID int32
|
||||||
|
RecomputeFrom time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateSnapshot marque un compte comme nécessitant un recalcul depuis date.
|
||||||
|
// Conserve le MIN si une invalidation antérieure existe déjà.
|
||||||
|
func (s *Store) InvalidateSnapshot(ctx context.Context, accountID int32, from time.Time) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO account_snapshot_invalidation (account_id, recompute_from)
|
||||||
|
VALUES ($1, $2::date)
|
||||||
|
ON CONFLICT (account_id) DO UPDATE
|
||||||
|
SET recompute_from = LEAST(account_snapshot_invalidation.recompute_from, EXCLUDED.recompute_from)
|
||||||
|
`, accountID, from)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvalidations retourne tous les comptes en attente de recalcul.
|
||||||
|
func (s *Store) GetInvalidations(ctx context.Context) ([]Invalidation, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT account_id, recompute_from FROM account_snapshot_invalidation ORDER BY account_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Invalidation
|
||||||
|
for rows.Next() {
|
||||||
|
var inv Invalidation
|
||||||
|
if err := rows.Scan(&inv.AccountID, &inv.RecomputeFrom); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, inv)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearInvalidation supprime le flag d'invalidation une fois le recalcul terminé.
|
||||||
|
func (s *Store) ClearInvalidation(ctx context.Context, accountID int32) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM account_snapshot_invalidation WHERE account_id = $1`, accountID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvalidation retourne l'invalidation d'un compte spécifique, si elle existe.
|
||||||
|
func (s *Store) GetInvalidation(ctx context.Context, accountID int32) (Invalidation, bool, error) {
|
||||||
|
var inv Invalidation
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT account_id, recompute_from FROM account_snapshot_invalidation WHERE account_id = $1`,
|
||||||
|
accountID).Scan(&inv.AccountID, &inv.RecomputeFrom)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Invalidation{}, false, nil
|
||||||
|
}
|
||||||
|
return inv, err == nil, err
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package store
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
@ -129,10 +130,21 @@ func (s *Store) CreateTransaction(ctx context.Context, p CreateTransactionParams
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Transaction{}, err
|
return Transaction{}, err
|
||||||
}
|
}
|
||||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||||
|
if err != nil {
|
||||||
|
return tx, err
|
||||||
|
}
|
||||||
|
s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID)
|
||||||
|
return tx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams) (Transaction, error) {
|
func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams) (Transaction, error) {
|
||||||
|
// Récupérer l'ancienne date pour invalider à partir du MIN(ancienne, nouvelle).
|
||||||
|
old, err := s.GetTransaction(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return Transaction{}, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
`UPDATE transaction SET
|
`UPDATE transaction SET
|
||||||
date = $2::date,
|
date = $2::date,
|
||||||
|
|
@ -148,7 +160,16 @@ func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransac
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Transaction{}, err
|
return Transaction{}, err
|
||||||
}
|
}
|
||||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||||
|
if err != nil {
|
||||||
|
return tx, err
|
||||||
|
}
|
||||||
|
// Invalider depuis la date la plus ancienne (ancienne ou nouvelle).
|
||||||
|
earliest := minDateStr(old.Date, tx.Date)
|
||||||
|
s.invalidateAccounts(ctx, earliest, tx.AccountSourceID, tx.AccountDestID)
|
||||||
|
// Si les comptes ont changé, invalider aussi les anciens.
|
||||||
|
s.invalidateAccounts(ctx, earliest, old.AccountSourceID, old.AccountDestID)
|
||||||
|
return tx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Transaction, error) {
|
func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Transaction, error) {
|
||||||
|
|
@ -158,10 +179,45 @@ func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Tra
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Transaction{}, err
|
return Transaction{}, err
|
||||||
}
|
}
|
||||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||||
|
if err != nil {
|
||||||
|
return tx, err
|
||||||
|
}
|
||||||
|
s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID)
|
||||||
|
return tx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteTransaction(ctx context.Context, id int64) error {
|
func (s *Store) DeleteTransaction(ctx context.Context, id int64) error {
|
||||||
_, err := s.pool.Exec(ctx, `DELETE FROM transaction WHERE id = $1`, id)
|
tx, err := s.GetTransaction(ctx, id)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `DELETE FROM transaction WHERE id = $1`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateAccounts marque les comptes non-nil comme devant être recalculés depuis dateStr.
|
||||||
|
func (s *Store) invalidateAccounts(ctx context.Context, dateStr string, accountIDs ...*int32) {
|
||||||
|
date, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[int32]bool{}
|
||||||
|
for _, id := range accountIDs {
|
||||||
|
if id != nil && !seen[*id] {
|
||||||
|
seen[*id] = true
|
||||||
|
s.InvalidateSnapshot(ctx, *id, date) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// minDateStr retourne la plus petite des deux dates au format YYYY-MM-DD.
|
||||||
|
func minDateStr(a, b string) string {
|
||||||
|
if a <= b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
migrations/000002_snapshot_invalidation.down.sql
Normal file
1
migrations/000002_snapshot_invalidation.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS account_snapshot_invalidation;
|
||||||
7
migrations/000002_snapshot_invalidation.up.sql
Normal file
7
migrations/000002_snapshot_invalidation.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Marque les comptes dont les snapshots doivent être recalculés.
|
||||||
|
-- recompute_from = date la plus ancienne à partir de laquelle recalculer.
|
||||||
|
-- Mise à jour par MIN() à chaque mutation de transaction (create/update/delete/validate).
|
||||||
|
CREATE TABLE account_snapshot_invalidation (
|
||||||
|
account_id INTEGER PRIMARY KEY REFERENCES account(id) ON DELETE CASCADE,
|
||||||
|
recompute_from DATE NOT NULL
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue