feat(ruecknahmen): zurückgenommene Tiere erfassen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||
|
||||
// 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,18 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(f => f.CreatedAt);
|
||||
});
|
||||
|
||||
// RÜCKNAHMEN (getback_tb): wie Feedback bewusst beziehungsfrei. GerbilId/
|
||||
// FromContactId sind einfache nullable Guid-Spalten (keine Navigation → EF legt
|
||||
// KEINEN Foreign Key an), damit der Import-Re-Ingest-Wipe von Gerbils/Contacts
|
||||
// diese Zeilen weder löscht noch bricht. Sie überleben den Re-Ingest.
|
||||
modelBuilder.Entity<ReturnRecord>(e =>
|
||||
{
|
||||
e.Property(r => r.ReturnPrice).HasPrecision(10, 2);
|
||||
e.Property(r => r.OriginalPrice).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
e.HasIndex(r => r.CreatedAt);
|
||||
});
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
|
||||
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>RÜCKNAHME: Payload für POST/PUT /returns.</summary>
|
||||
public record ReturnRecordInput(
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note);
|
||||
|
||||
/// <summary>RÜCKNAHME: Antwort-DTO für einen gespeicherten Rücknahme-Datensatz.</summary>
|
||||
public record ReturnRecordDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
104
GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs
Normal file
104
GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// RÜCKNAHMEN (RPRO3 getback_tb): zurückgekommene/zurückgenommene Tiere.
|
||||
/// GET /returns[?gerbilId=] -> Liste, neueste zuerst (optional nach Tier gefiltert).
|
||||
/// POST /returns -> Rücknahme erfassen, 201.
|
||||
/// PUT /returns/{id} -> Rücknahme aktualisieren, 200.
|
||||
/// DELETE /returns/{id} -> Rücknahme löschen, 204.
|
||||
/// Entkoppelt von Gerbils/Contacts (lose nullable Guid-Spalten, kein FK), daher
|
||||
/// überleben Zeilen den Import-Re-Ingest-Wipe — wie Feedback.
|
||||
/// </summary>
|
||||
public static class ReturnRecordEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReturnRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/returns").WithTags("Returns");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
var query = db.ReturnRecords.AsNoTracking();
|
||||
if (gerbilId is { } gid)
|
||||
query = query.Where(r => r.GerbilId == gid);
|
||||
// In-Memory sortieren: SQLite (Test-Host) kann nicht nach DateTimeOffset ORDER BY-en.
|
||||
var rows = await query.ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(r => r.ReturnDate ?? DateTime.MinValue)
|
||||
.ThenByDescending(r => r.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<ReturnRecordDto>, BadRequest<string>>> (
|
||||
ReturnRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName))
|
||||
return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden.");
|
||||
|
||||
var entity = new ReturnRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
GerbilName = Trimmed(input.GerbilName),
|
||||
ReturnDate = input.ReturnDate,
|
||||
ReturnPrice = input.ReturnPrice,
|
||||
OriginalPrice = input.OriginalPrice,
|
||||
OriginalSaleDate = input.OriginalSaleDate,
|
||||
FromContactId = input.FromContactId,
|
||||
FromContactName = Trimmed(input.FromContactName),
|
||||
Note = Trimmed(input.Note),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ReturnRecords.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/returns/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<ReturnRecordDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, ReturnRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
|
||||
if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName))
|
||||
return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden.");
|
||||
|
||||
entity.GerbilId = input.GerbilId;
|
||||
entity.GerbilName = Trimmed(input.GerbilName);
|
||||
entity.ReturnDate = input.ReturnDate;
|
||||
entity.ReturnPrice = input.ReturnPrice;
|
||||
entity.OriginalPrice = input.OriginalPrice;
|
||||
entity.OriginalSaleDate = input.OriginalSaleDate;
|
||||
entity.FromContactId = input.FromContactId;
|
||||
entity.FromContactName = Trimmed(input.FromContactName);
|
||||
entity.Note = Trimmed(input.Note);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
db.ReturnRecords.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? Trimmed(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static ReturnRecordDto ToDto(ReturnRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.GerbilName, r.ReturnDate, r.ReturnPrice, r.OriginalPrice,
|
||||
r.OriginalSaleDate, r.FromContactId, r.FromContactName, r.Note, r.CreatedAt);
|
||||
}
|
||||
}
|
||||
1595
GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs
generated
Normal file
1595
GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReturnRecord : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReturnRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
GerbilName = table.Column<string>(type: "text", nullable: true),
|
||||
ReturnDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ReturnPrice = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||
OriginalPrice = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||
OriginalSaleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
FromContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
FromContactName = table.Column<string>(type: "text", 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_ReturnRecords", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReturnRecords_CreatedAt",
|
||||
table: "ReturnRecords",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReturnRecords_GerbilId",
|
||||
table: "ReturnRecords",
|
||||
column: "GerbilId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReturnRecords");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1284,6 +1284,53 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Requests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("FromContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FromContactName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("GerbilName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("OriginalPrice")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<DateTime?>("OriginalSaleDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ReturnDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal?>("ReturnPrice")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("ReturnRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
48
GerbilManagerWebAPI/Models/ReturnRecord.cs
Normal file
48
GerbilManagerWebAPI/Models/ReturnRecord.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// RÜCKNAHME (RPRO3 getback_tb): ein bereits abgegebenes Tier kommt zur Züchterin
|
||||
/// zurück. Bewusst vom restlichen Modell entkoppelt — GerbilId/FromContactId sind
|
||||
/// einfache nullable Guid-Spalten (KEINE Foreign Keys), genau wie bei <see cref="Feedback"/>.
|
||||
/// Dadurch überleben Rücknahmen den Import-Re-Ingest-Wipe (IngestResolvedService löscht
|
||||
/// Gerbils/Contacts, ohne diese Tabelle anzufassen). Der erfasste GerbilName hält den
|
||||
/// Datensatz auch dann lesbar, wenn das referenzierte Tier neu eingespielt/entfernt wurde.
|
||||
/// </summary>
|
||||
public class ReturnRecord
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Lose Referenz (kein FK) auf das zurückgekommene Tier (_TID).</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Erfasster Tiername — überlebt einen Ingest-Wipe (bleibt lesbar).</summary>
|
||||
public string? GerbilName { get; set; }
|
||||
|
||||
/// <summary>Rücknahmedatum (_ZAM).</summary>
|
||||
public DateTime? ReturnDate { get; set; }
|
||||
|
||||
/// <summary>Rücknahmepreis (_ZPREIS) — was die Züchterin bei der Rücknahme zahlte/erstattete.</summary>
|
||||
public decimal? ReturnPrice { get; set; }
|
||||
|
||||
/// <summary>Ursprünglicher Abgabepreis (_PREIS), zur Historie mitgeführt.</summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>Ursprüngliches Abgabedatum (_AM), zur Historie mitgeführt.</summary>
|
||||
public DateTime? OriginalSaleDate { get; set; }
|
||||
|
||||
/// <summary>Lose Referenz (kein FK) auf den Kontakt, von dem das Tier zurückkam (_ABN).</summary>
|
||||
public Guid? FromContactId { get; set; }
|
||||
|
||||
/// <summary>Erfasster Kontaktname — überlebt einen Ingest-Wipe (bleibt lesbar).</summary>
|
||||
public string? FromContactName { get; set; }
|
||||
|
||||
/// <summary>Freitext-Grund/Notiz zur Rücknahme (_BEM).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>Server-seitige Anlagezeit.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapReturnRecordEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user