Merge feature/status-model (STATUS-MODEL-BE P0): enum Active->Breeding(default)+Pet/ForSale; Deceased/GivenAway serverseitig abgeleitet (GerbilStatusService.Derive + Praezedenz + >7J presumed); Migration+Backfill+Startup-Sweep

This commit is contained in:
2026-06-07 03:36:32 +02:00
16 changed files with 1698 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
@@ -104,11 +105,12 @@ namespace GerbilManagerWebAPI.Endpoints
db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
g.Status = GerbilStatus.GivenAway;
GerbilStatusService.Apply(g, today);
}
try

View File

@@ -1,6 +1,7 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Gridify;
using Gridify.EntityFramework;
using Microsoft.AspNetCore.Http.HttpResults;
@@ -107,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
{
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
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.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
@@ -126,6 +128,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
}
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(

View File

@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -266,7 +267,7 @@ namespace GerbilManagerWebAPI.Import
Name = da.Name.Trim(),
DateOfBirth = animalDob,
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,
ReceiverContactId = receiverId,
GoHomeDate = goHomeDate,

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
if (execute)
{
_db.Gerbils.Add(new Gerbil
var importedGerbil = new Gerbil
{
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
});
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
_db.Gerbils.Add(importedGerbil);
}
// photos

File diff suppressed because it is too large Load Diff

View 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';");
}
}
}

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
public Guid Id { get; set; }
public required string Name { 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).
public Guid? LitterId { get; set; }

View File

@@ -1,12 +1,17 @@
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
{
Active = 0,
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
Breeding = 0,
Deceased = 1,
GivenAway = 2,
/// <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,
}
}

View File

@@ -1,5 +1,7 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -84,7 +86,23 @@ app.MapScalarApiReference();
if (!app.Environment.IsEnvironment("Testing"))
{
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);

View 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);
}
}