feat(datasets): periods (start_date/end_date), dg_id column, date filters; rename observation dates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 13:16:07 +05:00
parent 37d4f3519b
commit 922812b765
10 changed files with 729 additions and 181 deletions

View File

@@ -56,19 +56,60 @@ func (r *stubDatasetRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Datas
return d, nil
}
func (r *stubDatasetRepo) UpdateFields(_ context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error) {
func (r *stubDatasetRepo) UpdateFields(_ context.Context, id uuid.UUID, f domain.DatasetFields) (domain.Dataset, error) {
d, ok := r.store[id]
if !ok {
return domain.Dataset{}, domain.ErrNotFound
}
d.CategoryID = categoryID
d.Name = name
d.Description = description
d.Unit = unit
d.CategoryID = f.CategoryID
d.Name = f.Name
d.Description = f.Description
d.Unit = f.Unit
d.DatasetGroupID = f.DatasetGroupID
d.StartDate = f.StartDate
d.EndDate = f.EndDate
r.store[id] = d
return d, nil
}
func (r *stubDatasetRepo) LatestInGroup(_ context.Context, groupID uuid.UUID, status *string, fileType *domain.FileType) (domain.Dataset, error) {
var best *domain.Dataset
for _, d := range r.store {
d := d
if d.DatasetGroupID == nil || *d.DatasetGroupID != groupID {
continue
}
if status != nil && d.Status != *status {
continue
}
if fileType != nil && d.FileType != *fileType {
continue
}
if best == nil || laterPeriod(d, *best) {
best = &d
}
}
if best == nil {
return domain.Dataset{}, domain.ErrNotFound
}
return *best, nil
}
// laterPeriod reports whether a is fresher than b: greater end_date, NULL
// end_date last, then newer created_at.
func laterPeriod(a, b domain.Dataset) bool {
switch {
case a.EndDate != nil && b.EndDate == nil:
return true
case a.EndDate == nil && b.EndDate != nil:
return false
case a.EndDate != nil && b.EndDate != nil && *a.EndDate != *b.EndDate:
return *a.EndDate > *b.EndDate
default:
return a.CreatedAt.After(b.CreatedAt)
}
}
func (r *stubDatasetRepo) ListSummaries(_ context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
r.lastFilter = filter
r.lastLimit = limit
@@ -756,7 +797,7 @@ func TestDatasetService_SaveMapping(t *testing.T) {
Status: domain.DatasetStatusAwaitingMapping,
AttributeColumns: []domain.AttributeColumn{{Name: "като"}, {Name: "F_2023"}},
}
validYears := []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}}
validYears := []domain.YearColumn{{Column: "F_2023", StartDate: "2023-01-01"}}
newSvc := func() (*stubDatasetRepo, *DatasetService) {
repo := newStubDatasetRepo()
@@ -774,31 +815,31 @@ func TestDatasetService_SaveMapping(t *testing.T) {
t.Run("unknown year column", func(t *testing.T) {
_, svc := newSvc()
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "X", Date: "2023-01-01"}}})
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "X", StartDate: "2023-01-01"}}})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("bad date", func(t *testing.T) {
t.Run("bad start_date", func(t *testing.T) {
_, svc := newSvc()
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023"}}})
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023"}}})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("bad date_to", func(t *testing.T) {
t.Run("bad end_date", func(t *testing.T) {
_, svc := newSvc()
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01", DateTo: strPtr("2023")}}})
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023-01-01", EndDate: strPtr("2023")}}})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("date_to before date", func(t *testing.T) {
t.Run("end_date before start_date", func(t *testing.T) {
_, svc := newSvc()
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-12-31", DateTo: strPtr("2023-01-01")}}})
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023-12-31", EndDate: strPtr("2023-01-01")}}})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
@@ -808,12 +849,12 @@ func TestDatasetService_SaveMapping(t *testing.T) {
repo, svc := newSvc()
got, err := svc.SaveMapping(ctx, id, MappingInput{
KatoColumn: "като",
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}},
YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31")}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got.YearColumns) != 1 || got.YearColumns[0].DateTo == nil || *got.YearColumns[0].DateTo != "2023-12-31" {
if len(got.YearColumns) != 1 || got.YearColumns[0].EndDate == nil || *got.YearColumns[0].EndDate != "2023-12-31" {
t.Fatalf("period not saved: %+v", got.YearColumns)
}
if len(repo.store[id].YearColumns) != 1 {
@@ -866,8 +907,8 @@ func TestDatasetService_SaveMapping(t *testing.T) {
func TestBuildObservations(t *testing.T) {
id := uuid.New()
years := []domain.YearColumn{
{Column: "F_2023", Date: "2023-01-01"},
{Column: "D_2025", Date: "2025-01-01"},
{Column: "F_2023", StartDate: "2023-01-01"},
{Column: "D_2025", StartDate: "2025-01-01"},
}
rows := []map[string]string{
{"като": "751010000", "F_2023": "100", "D_2025": "n/a"},
@@ -882,7 +923,7 @@ func TestBuildObservations(t *testing.T) {
byKey := map[string]domain.Observation{}
for _, o := range obs {
byKey[o.KatoCode+"|"+o.Date] = o
byKey[o.KatoCode+"|"+o.StartDate] = o
}
if o := byKey["751010000|2023-01-01"]; o.Value == nil || *o.Value != 100 {
t.Errorf("numeric cell not stored as value: %+v", o)
@@ -891,8 +932,8 @@ func TestBuildObservations(t *testing.T) {
t.Errorf("non-numeric cell not stored as value_text: %+v", o)
}
for _, o := range obs {
if o.DateTo != nil {
t.Fatalf("point-in-time mapping must not set DateTo, got %+v", o)
if o.EndDate != nil {
t.Fatalf("point-in-time mapping must not set EndDate, got %+v", o)
}
}
}
@@ -900,8 +941,8 @@ func TestBuildObservations(t *testing.T) {
func TestBuildObservations_Period(t *testing.T) {
id := uuid.New()
years := []domain.YearColumn{
{Column: "F_2020_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}, // period
{Column: "F_2024", Date: "2024-01-01"}, // point-in-time, unaffected
{Column: "F_2020_2023", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31")}, // period
{Column: "F_2024", StartDate: "2024-01-01"}, // point-in-time, unaffected
}
rows := []map[string]string{
{"като": "751010000", "F_2020_2023": "100", "F_2024": "200"},
@@ -912,22 +953,22 @@ func TestBuildObservations_Period(t *testing.T) {
t.Fatalf("want 2 observations, got %d", len(obs))
}
byDate := map[string]domain.Observation{}
byStartDate := map[string]domain.Observation{}
for _, o := range obs {
byDate[o.Date] = o
byStartDate[o.StartDate] = o
}
period := byDate["2020-01-01"]
if period.DateTo == nil || *period.DateTo != "2023-12-31" {
t.Fatalf("period observation missing date_to: %+v", period)
period := byStartDate["2020-01-01"]
if period.EndDate == nil || *period.EndDate != "2023-12-31" {
t.Fatalf("period observation missing end_date: %+v", period)
}
if period.Value == nil || *period.Value != 100 {
t.Fatalf("period observation value wrong: %+v", period)
}
point := byDate["2024-01-01"]
if point.DateTo != nil {
t.Fatalf("point-in-time observation must have nil date_to (backward compat), got %+v", point)
point := byStartDate["2024-01-01"]
if point.EndDate != nil {
t.Fatalf("point-in-time observation must have nil end_date (backward compat), got %+v", point)
}
}
@@ -940,7 +981,7 @@ func TestDatasetService_Extract(t *testing.T) {
Filename: "r.geojson", StorageKey: "k",
Status: domain.DatasetStatusExtracting,
KatoColumn: &kato,
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}},
YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023-01-01"}},
}
repo.districts["751010000"] = domain.District{
Kato: "751010000", Name: "Almaty",
@@ -1002,9 +1043,9 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
v2020, v2021 := 100.0, 150.0
repo.observations[id] = []domain.Observation{
{KatoCode: "710000000", Date: "2020-01-01", Value: &v2020},
{KatoCode: "710000000", Date: "2021-01-01", Value: &v2021},
{KatoCode: "999999999", Date: "2020-01-01", Value: &v2020}, // no district -> skipped
{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v2020},
{KatoCode: "710000000", StartDate: "2021-01-01", Value: &v2021},
{KatoCode: "999999999", StartDate: "2020-01-01", Value: &v2020}, // no district -> skipped
}
repo.districts["710000000"] = domain.District{
Kato: "710000000", Name: "Astana",
@@ -1050,8 +1091,8 @@ func TestDatasetService_GeoJSON_Period(t *testing.T) {
period, point := 100.0, 200.0
repo.observations[id] = []domain.Observation{
{KatoCode: "710000000", Date: "2020-01-01", DateTo: strPtr("2023-12-31"), Value: &period},
{KatoCode: "710000000", Date: "2024-01-01", Value: &point}, // point-in-time, no period
{KatoCode: "710000000", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31"), Value: &period},
{KatoCode: "710000000", StartDate: "2024-01-01", Value: &point}, // point-in-time, no period
}
repo.districts["710000000"] = domain.District{
Kato: "710000000", Name: "Astana",
@@ -1092,7 +1133,7 @@ func TestDatasetService_GeoJSON_NoPeriods_OmitsPeriodsKey(t *testing.T) {
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
v := 100.0
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", Date: "2020-01-01", Value: &v}}
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v}}
repo.districts["710000000"] = domain.District{
Kato: "710000000", Name: "Astana",
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
@@ -1116,8 +1157,8 @@ func TestDatasetService_GeoJSON_IgnoresDatasetGeometry(t *testing.T) {
}
v := 100.0
repo.observations[id] = []domain.Observation{
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
{KatoCode: "999999999", Date: "2020-01-01", Value: &v}, // no district -> skipped
{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v},
{KatoCode: "999999999", StartDate: "2020-01-01", Value: &v}, // no district -> skipped
}
district := json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`)
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana", Geometry: district}
@@ -1174,7 +1215,7 @@ func TestDatasetService_GeoJSON_LazilyGeneratesAndCaches(t *testing.T) {
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
v := 100.0
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", Date: "2020-01-01", Value: &v}}
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v}}
repo.districts["710000000"] = domain.District{
Kato: "710000000", Name: "Astana",
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
@@ -1694,3 +1735,40 @@ func TestDatasetService_Patch_NotFound(t *testing.T) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestDatasetService_Upload_StoresPeriodAndGroup(t *testing.T) {
repo := newStubDatasetRepo()
svc := newDatasetService(repo, &stubStore{}, true)
gid := uuid.New()
start, end := "2020-01-01", "2020-12-31"
in := validUpload()
in.DatasetGroupID = &gid
in.StartDate = &start
in.EndDate = &end
ds, err := svc.Upload(context.Background(), in)
if err != nil {
t.Fatal(err)
}
if ds.DatasetGroupID == nil || *ds.DatasetGroupID != gid || *ds.StartDate != start || *ds.EndDate != end {
t.Fatalf("period/group not stored: %+v", ds)
}
}
func TestDatasetService_Upload_RejectsReversedPeriod(t *testing.T) {
store := &stubStore{}
svc := newDatasetService(newStubDatasetRepo(), store, true)
start, end := "2021-01-01", "2020-12-31"
in := validUpload()
in.StartDate = &start
in.EndDate = &end
if _, err := svc.Upload(context.Background(), in); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
if len(store.put) != 0 {
t.Fatalf("nothing should be uploaded on validation failure, got %v", store.put)
}
}