feat(tier): Erwerb/Kauf je Tier (Datum, Preis, Notiz)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:18 +02:00
parent 5e14124322
commit 55e79cda51
16 changed files with 2360 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -212,6 +213,16 @@ public class ApplicationContext : DbContext
e.HasIndex(f => f.CreatedAt);
});
// ERWERB: like Feedback, deliberately relationship-free. GerbilId/SourceContactId
// are plain nullable Guid columns (no navigation properties → EF creates NO foreign
// key), so the import re-ingest wipe of Gerbils/Contacts never cascades into — or
// breaks — acquisition rows. They survive re-ingest, which is the whole point.
modelBuilder.Entity<AcquisitionRecord>(e =>
{
e.Property(a => a.Price).HasPrecision(10, 2);
e.HasIndex(a => a.GerbilId);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{

View File

@@ -0,0 +1,20 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>ERWERB: payload for POST/PUT /acquisitions (acquisition data per animal).</summary>
public record AcquisitionInput(
Guid? GerbilId,
Guid? SourceContactId,
DateOnly? Date,
decimal? Price,
string? Note);
/// <summary>ERWERB: response DTO for a stored acquisition record.</summary>
public record AcquisitionDto(
Guid Id,
Guid? GerbilId,
Guid? SourceContactId,
DateOnly? Date,
decimal? Price,
string? Note,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,89 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// ERWERB: acquisition data per animal (purchase date/price/note).
/// GET /acquisitions?gerbilId=… -> records for one animal (or all), newest date first.
/// GET /acquisitions/{id} -> a single record.
/// POST /acquisitions -> create, returns 201.
/// PUT /acquisitions/{id} -> update, returns 204.
/// DELETE /acquisitions/{id} -> delete, returns 204.
/// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive
/// the import re-ingest wipe (mirrors Feedback).
/// </summary>
public static class AcquisitionEndpoints
{
public static IEndpointRouteBuilder MapAcquisitionEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/acquisitions").WithTags("Acquisitions");
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
{
var rows = await db.AcquisitionRecords.AsNoTracking()
.Where(a => gerbilId == null || a.GerbilId == gerbilId)
.ToListAsync();
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column,
// and DateOnly ordering stays consistent across providers this way.
return TypedResults.Ok(rows
.OrderByDescending(a => a.Date ?? DateOnly.MinValue)
.ThenByDescending(a => a.CreatedAt)
.Select(ToDto)
.ToList());
});
group.MapGet("/{id:guid}", async Task<Results<Ok<AcquisitionDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var a = await db.AcquisitionRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return a is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(a));
});
group.MapPost("/", async (AcquisitionInput input, ApplicationContext db) =>
{
var a = new AcquisitionRecord
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
SourceContactId = input.SourceContactId,
Date = input.Date,
Price = input.Price,
Note = string.IsNullOrWhiteSpace(input.Note) ? null : input.Note.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
db.AcquisitionRecords.Add(a);
await db.SaveChangesAsync();
return TypedResults.Created($"/acquisitions/{a.Id}", ToDto(a));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, AcquisitionInput input, ApplicationContext db) =>
{
var a = await db.AcquisitionRecords.FirstOrDefaultAsync(x => x.Id == id);
if (a is null) return TypedResults.NotFound();
a.GerbilId = input.GerbilId;
a.SourceContactId = input.SourceContactId;
a.Date = input.Date;
a.Price = input.Price;
a.Note = string.IsNullOrWhiteSpace(input.Note) ? null : input.Note.Trim();
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var a = await db.AcquisitionRecords.FirstOrDefaultAsync(x => x.Id == id);
if (a is null) return TypedResults.NotFound();
db.AcquisitionRecords.Remove(a);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static AcquisitionDto ToDto(AcquisitionRecord a) =>
new(a.Id, a.GerbilId, a.SourceContactId, a.Date, a.Price, a.Note, a.CreatedAt);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddAcquisitionRecord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AcquisitionRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
SourceContactId = table.Column<Guid>(type: "uuid", nullable: true),
Date = table.Column<DateOnly>(type: "date", nullable: true),
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
Note = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AcquisitionRecords", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AcquisitionRecords_GerbilId",
table: "AcquisitionRecords",
column: "GerbilId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AcquisitionRecords");
}
}
}

View File

@@ -21,6 +21,38 @@ namespace GerbilManagerWebAPI.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GerbilManagerWebAPI.Models.AcquisitionRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly?>("Date")
.HasColumnType("date");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<Guid?>("SourceContactId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("AcquisitionRecords");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// ERWERB: how/when an animal was acquired (purchase date, price, note) — sourced from
/// RennmausPro's <c>herktier_tb</c> (_TID/_DATE/_PRICE/_BEM). The seller is already linked
/// via Gerbil.OriginContactId; this record adds the date/price/note around that.
///
/// Decoupled from the rest of the model ON PURPOSE — GerbilId/SourceContactId are plain
/// nullable Guid columns (NOT enforced foreign keys), so the import re-ingest wipe
/// (IngestResolvedService, which deletes Gerbils/Contacts) never cascades into — or
/// breaks — acquisition rows. They survive re-ingest, mirroring the Feedback pattern.
/// </summary>
public class AcquisitionRecord
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the acquired gerbil.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Loose reference (no FK) to the source/seller contact, if known.</summary>
public Guid? SourceContactId { get; set; }
/// <summary>When the animal was acquired (purchase date).</summary>
public DateOnly? Date { get; set; }
/// <summary>Acquisition price (purchase cost), if recorded.</summary>
public decimal? Price { get; set; }
/// <summary>Free-text note about the acquisition.</summary>
public string? Note { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.MapAcquisitionEndpoints();
app.Run();