STATUS-MODEL: Active→Breeding + Pet enum + derived status logic (P0)
GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED. GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1), ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4). All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply(). Startup sweep flips >7y gerbils to Deceased at next app restart. Migration StatusModel: Active→Breeding rename + backfill derived statuses. 10 new tests; 200/200 green; has-pending=No. Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale. FE contract: status field values updated (see Done-Report).
This commit is contained in:
@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||||
// a non-ForSale gerbil must NOT appear
|
// a non-ForSale gerbil must NOT appear
|
||||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||||
|
|
||||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = "Krümel",
|
Name = "Krümel",
|
||||||
Gender = Gender.female,
|
Gender = Gender.female,
|
||||||
Status = GerbilStatus.Active,
|
Status = GerbilStatus.Breeding,
|
||||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||||
LitterId = litter.Id,
|
LitterId = litter.Id,
|
||||||
EnclosureId = enclosure.Id,
|
EnclosureId = enclosure.Id,
|
||||||
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
|
|||||||
var row = lines[1];
|
var row = lines[1];
|
||||||
Assert.Contains("Krümel", row);
|
Assert.Contains("Krümel", row);
|
||||||
Assert.Contains("Weiblich", row);
|
Assert.Contains("Weiblich", row);
|
||||||
Assert.Contains("Aktiv", row);
|
Assert.Contains("Zucht", row);
|
||||||
Assert.Contains("12.03.2025", row);
|
Assert.Contains("12.03.2025", row);
|
||||||
Assert.Contains("Großbecken", row);
|
Assert.Contains("Großbecken", row);
|
||||||
Assert.Contains("Wurf K", row);
|
Assert.Contains("Wurf K", row);
|
||||||
|
|||||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
|
||||||
|
public class GerbilStatusTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly Today = new(2026, 6, 7);
|
||||||
|
|
||||||
|
// 1) DateOfDeath → Deceased, always overrides user input
|
||||||
|
[Fact]
|
||||||
|
public void DateOfDeath_set_returns_Deceased()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateOfDeath wins even when also Abgegeben
|
||||||
|
[Fact]
|
||||||
|
public void DateOfDeath_wins_over_Abgegeben()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||||
|
isAbgegeben: true, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Abgegeben without death date → GivenAway
|
||||||
|
[Fact]
|
||||||
|
public void ReceiverContact_set_returns_GivenAway()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: true, Today);
|
||||||
|
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
|
||||||
|
[Fact]
|
||||||
|
public void OlderThan7y_without_death_returns_Deceased()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
|
||||||
|
[Fact]
|
||||||
|
public void ExactlyMaxAge_is_not_Deceased()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
|
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
|
||||||
|
// (threshold = "older than" so >= means Deceased)
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One day before threshold is alive
|
||||||
|
[Fact]
|
||||||
|
public void OneDayBeforeMaxAge_is_alive()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
|
||||||
|
[Fact]
|
||||||
|
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
|
||||||
|
{
|
||||||
|
var dob = Today.AddYears(-8);
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||||
|
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Default → Breeding
|
||||||
|
[Fact]
|
||||||
|
public void No_special_condition_returns_Breeding()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4a) User sets Pet → kept
|
||||||
|
[Fact]
|
||||||
|
public void User_Pet_is_preserved()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Pet, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4b) User sets ForSale → kept
|
||||||
|
[Fact]
|
||||||
|
public void User_ForSale_is_preserved()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.ForSale, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateOfBirth null → age rule does not fire
|
||||||
|
[Fact]
|
||||||
|
public void Null_DateOfBirth_does_not_trigger_age_rule()
|
||||||
|
{
|
||||||
|
var status = GerbilStatusService.Derive(
|
||||||
|
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
|
||||||
|
isAbgegeben: false, Today);
|
||||||
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
Assert.Equal("Active", GetStr(json, "status"));
|
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||||
Assert.Equal("female", GetStr(json, "gender"));
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
|
|
||||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||||
Assert.Equal("Active", GetStr(json, "status"));
|
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||||
// characterTraits defaults to [] on create when not provided
|
// characterTraits defaults to [] on create when not provided
|
||||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
|||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||||
{
|
{
|
||||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||||
new { name, gender = "female", status, originBreeder });
|
new { name, gender = "female", status, originBreeder });
|
||||||
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
|||||||
public async Task Combined_status_and_name_filter()
|
public async Task Combined_status_and_name_filter()
|
||||||
{
|
{
|
||||||
await Create("KombiVerkauf", status: "ForSale");
|
await Create("KombiVerkauf", status: "ForSale");
|
||||||
await Create("KombiAktiv", status: "Active");
|
await Create("KombiAktiv", status: "Breeding");
|
||||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||||
Assert.Contains("KombiVerkauf", hits);
|
Assert.Contains("KombiVerkauf", hits);
|
||||||
Assert.DoesNotContain("KombiAktiv", hits);
|
Assert.DoesNotContain("KombiAktiv", hits);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
|
|||||||
using GerbilManagerWebAPI.Contracts;
|
using GerbilManagerWebAPI.Contracts;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -104,11 +105,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
db.SaleContracts.Add(entity);
|
db.SaleContracts.Add(entity);
|
||||||
|
|
||||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
foreach (var g in gerbils)
|
foreach (var g in gerbils)
|
||||||
{
|
{
|
||||||
g.ReceiverContactId = contact.Id;
|
g.ReceiverContactId = contact.Id;
|
||||||
g.GoHomeDate = input.HandoverDate;
|
g.GoHomeDate = input.HandoverDate;
|
||||||
g.Status = GerbilStatus.GivenAway;
|
GerbilStatusService.Apply(g, today);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using GerbilManagerWebAPI.Common;
|
using GerbilManagerWebAPI.Common;
|
||||||
using GerbilManagerWebAPI.Dtos;
|
using GerbilManagerWebAPI.Dtos;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Gridify;
|
using Gridify;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -86,7 +87,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||||
|
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||||
g.LitterId = i.LitterId ?? g.LitterId;
|
g.LitterId = i.LitterId ?? g.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||||
@@ -105,6 +107,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
|
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
internal static GerbilDto ToDto(Gerbil g) => new(
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
|
|||||||
|
|
||||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||||
{
|
{
|
||||||
[GerbilStatus.Active] = "Aktiv",
|
[GerbilStatus.Breeding] = "Zucht",
|
||||||
[GerbilStatus.Deceased] = "Verstorben",
|
[GerbilStatus.Deceased] = "Verstorben",
|
||||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||||
|
[GerbilStatus.ForSale] = "Abzugeben",
|
||||||
|
[GerbilStatus.Pet] = "Liebhaber",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Import
|
namespace GerbilManagerWebAPI.Import
|
||||||
@@ -266,7 +267,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Name = da.Name.Trim(),
|
Name = da.Name.Trim(),
|
||||||
DateOfBirth = animalDob,
|
DateOfBirth = animalDob,
|
||||||
Gender = ParseGender(da.Gender),
|
Gender = ParseGender(da.Gender),
|
||||||
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
|
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||||
LitterId = litterId,
|
LitterId = litterId,
|
||||||
ReceiverContactId = receiverId,
|
ReceiverContactId = receiverId,
|
||||||
GoHomeDate = goHomeDate,
|
GoHomeDate = goHomeDate,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Import
|
namespace GerbilManagerWebAPI.Import
|
||||||
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
_db.Gerbils.Add(new Gerbil
|
var importedGerbil = new Gerbil
|
||||||
{
|
{
|
||||||
Id = p.Gid,
|
Id = p.Gid,
|
||||||
Name = p.A.Name,
|
Name = p.A.Name,
|
||||||
Gender = p.Gender,
|
Gender = p.Gender,
|
||||||
Status = GerbilStatus.Active,
|
Status = GerbilStatus.Breeding,
|
||||||
DateOfBirth = ParseDate(p.A.Dob),
|
DateOfBirth = ParseDate(p.A.Dob),
|
||||||
DateOfDeath = ParseDate(p.A.Death),
|
DateOfDeath = ParseDate(p.A.Death),
|
||||||
LitterId = litterId,
|
LitterId = litterId,
|
||||||
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
p.A.SourceFiles,
|
p.A.SourceFiles,
|
||||||
FarbschlagRaw = p.A.Farbschlag,
|
FarbschlagRaw = p.A.Farbschlag,
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
_db.Gerbils.Add(importedGerbil);
|
||||||
}
|
}
|
||||||
|
|
||||||
// photos
|
// photos
|
||||||
|
|||||||
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class StatusModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1) Rename string value 'Active' → 'Breeding'.
|
||||||
|
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
|
||||||
|
|
||||||
|
// 2) DateOfDeath set → Deceased (always takes precedence).
|
||||||
|
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
|
||||||
|
|
||||||
|
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
|
||||||
|
WHERE ""Status"" != 'Deceased'
|
||||||
|
AND (""ReceiverContactId"" IS NOT NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM ""SaleContractAnimals"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||||
|
|
||||||
|
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
|
||||||
|
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
|
||||||
|
AND ""DateOfBirth"" IS NOT NULL
|
||||||
|
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public Gender Gender { get; set; }
|
public Gender Gender { get; set; }
|
||||||
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
|
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
|
||||||
|
|
||||||
// Birth litter (the litter this gerbil was born in).
|
// Birth litter (the litter this gerbil was born in).
|
||||||
public Guid? LitterId { get; set; }
|
public Guid? LitterId { get; set; }
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
namespace GerbilManagerWebAPI.Models
|
namespace GerbilManagerWebAPI.Models
|
||||||
{
|
{
|
||||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
|
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
|
||||||
|
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
|
||||||
|
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
|
||||||
public enum GerbilStatus
|
public enum GerbilStatus
|
||||||
{
|
{
|
||||||
Active = 0,
|
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
|
||||||
|
Breeding = 0,
|
||||||
Deceased = 1,
|
Deceased = 1,
|
||||||
GivenAway = 2,
|
GivenAway = 2,
|
||||||
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
||||||
ForSale = 3
|
ForSale = 3,
|
||||||
|
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
|
||||||
|
Pet = 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using GerbilManagerWebAPI.Endpoints;
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
@@ -84,7 +86,23 @@ app.MapScalarApiReference();
|
|||||||
if (!app.Environment.IsEnvironment("Testing"))
|
if (!app.Environment.IsEnvironment("Testing"))
|
||||||
{
|
{
|
||||||
using var scope = app.Services.CreateScope();
|
using var scope = app.Services.CreateScope();
|
||||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
|
||||||
|
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
|
||||||
|
// since the last write. No-op if all statuses are already current.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var candidates = await db.Gerbils
|
||||||
|
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
|
||||||
|
&& g.DateOfDeath == null && g.ReceiverContactId == null
|
||||||
|
&& g.DateOfBirth != null
|
||||||
|
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
|
||||||
|
.ToListAsync();
|
||||||
|
if (candidates.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseCors(LanCorsPolicy);
|
app.UseCors(LanCorsPolicy);
|
||||||
|
|||||||
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
|
||||||
|
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
|
||||||
|
/// derived statuses (Deceased, GivenAway) are always consistent.
|
||||||
|
///
|
||||||
|
/// Precedence (highest wins):
|
||||||
|
/// 1) DateOfDeath set → Deceased (explicit, always)
|
||||||
|
/// 2) Abgabe (ReceiverContactId set) → GivenAway
|
||||||
|
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
|
||||||
|
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
|
||||||
|
///
|
||||||
|
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
|
||||||
|
/// a) Apply() on every write (catches the animal at write time)
|
||||||
|
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
|
||||||
|
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
|
||||||
|
/// </summary>
|
||||||
|
public static class GerbilStatusService
|
||||||
|
{
|
||||||
|
public const int MaxAgeYears = 7;
|
||||||
|
|
||||||
|
/// <summary>Derives and sets g.Status using the gerbil's current field values.
|
||||||
|
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
||||||
|
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
|
||||||
|
public static void Apply(Gerbil g, DateOnly today)
|
||||||
|
{
|
||||||
|
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
|
||||||
|
isAbgegeben: g.ReceiverContactId is not null, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
|
||||||
|
public static GerbilStatus Derive(
|
||||||
|
GerbilStatus requested,
|
||||||
|
DateOnly? dateOfBirth,
|
||||||
|
DateOnly? dateOfDeath,
|
||||||
|
bool isAbgegeben,
|
||||||
|
DateOnly today)
|
||||||
|
{
|
||||||
|
if (dateOfDeath is not null) return GerbilStatus.Deceased;
|
||||||
|
if (isAbgegeben) return GerbilStatus.GivenAway;
|
||||||
|
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
|
||||||
|
return GerbilStatus.Deceased;
|
||||||
|
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
|
||||||
|
? requested : GerbilStatus.Breeding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
|
||||||
|
today >= dob.AddYears(years);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user