Merge branch 'worktree-agent-a36d202be311b9289'
# Conflicts: # GerbilManagerWebAPI/ApplicationContext.cs # GerbilManagerWebAPI/Program.cs # gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
@@ -26,6 +26,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
|
||||
// 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.
|
||||
@@ -223,6 +224,16 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(a => a.GerbilId);
|
||||
});
|
||||
|
||||
// ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback).
|
||||
// GerbilId/ReservedForContactId are plain Guid columns (no navigation properties →
|
||||
// EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never
|
||||
// cascades into — or breaks — reservation rows. They survive re-ingest by design.
|
||||
modelBuilder.Entity<SaleReservation>(e =>
|
||||
{
|
||||
e.Property(r => r.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
});
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
|
||||
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>ABGABE-STATUS: response DTO for a stored reservation/sale status.</summary>
|
||||
public record SaleReservationDto(
|
||||
Guid Id,
|
||||
Guid GerbilId,
|
||||
string? GerbilName,
|
||||
string Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>ABGABE-STATUS: payload for POST /reservations.</summary>
|
||||
public record SaleReservationInput(
|
||||
Guid GerbilId,
|
||||
string? GerbilName,
|
||||
string? Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate);
|
||||
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS: payload for PUT /reservations/{id}. All fields optional — only the
|
||||
/// provided ones are changed. A null/blank Status is ignored.
|
||||
/// </summary>
|
||||
public record SaleReservationUpdate(
|
||||
string? GerbilName,
|
||||
string? Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate);
|
||||
}
|
||||
124
GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs
Normal file
124
GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale
|
||||
/// pipeline — verfügbar → reserviert → abgegeben.
|
||||
/// GET /reservations -> list all, newest-updated first.
|
||||
/// GET /reservations?gerbilId= -> the (single) reservation status for one animal, if any.
|
||||
/// POST /reservations -> create/establish a status, returns 201.
|
||||
/// PUT /reservations/{id} -> change status / reservation details. 404 on missing id.
|
||||
/// DELETE /reservations/{id} -> remove. 404 on missing id.
|
||||
/// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive
|
||||
/// the import re-ingest wipe — like Feedback.
|
||||
/// </summary>
|
||||
public static class SaleReservationEndpoints
|
||||
{
|
||||
private static readonly string[] AllowedStatuses = { "verfuegbar", "reserviert", "abgegeben" };
|
||||
|
||||
/// <summary>Normalize a status string to one of the three canonical values; default "verfuegbar".</summary>
|
||||
private static string NormalizeStatus(string? raw)
|
||||
{
|
||||
var s = raw?.Trim().ToLowerInvariant();
|
||||
return AllowedStatuses.Contains(s) ? s! : "verfuegbar";
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapSaleReservationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/reservations").WithTags("Reservations");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.SaleReservations.AsNoTracking().ToListAsync();
|
||||
var filtered = gerbilId is { } gid
|
||||
? rows.Where(r => r.GerbilId == gid)
|
||||
: rows;
|
||||
return TypedResults.Ok(filtered
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<SaleReservationDto>, BadRequest<string>>> (
|
||||
SaleReservationInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (input.GerbilId == Guid.Empty)
|
||||
return TypedResults.BadRequest("GerbilId ist erforderlich.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entity = new SaleReservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
GerbilName = Trim(input.GerbilName),
|
||||
Status = NormalizeStatus(input.Status),
|
||||
ReservedForContactId = input.ReservedForContactId,
|
||||
ContactName = Trim(input.ContactName),
|
||||
AppointmentDate = input.AppointmentDate,
|
||||
Price = input.Price,
|
||||
Note = Trim(input.Note),
|
||||
HandedOverDate = input.HandedOverDate,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.SaleReservations.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/reservations/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<SaleReservationDto>, NotFound>> (
|
||||
Guid id, SaleReservationUpdate input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (input.Status is not null)
|
||||
entity.Status = NormalizeStatus(input.Status);
|
||||
if (input.GerbilName is not null)
|
||||
entity.GerbilName = Trim(input.GerbilName);
|
||||
if (input.ContactName is not null)
|
||||
entity.ContactName = Trim(input.ContactName);
|
||||
if (input.Note is not null)
|
||||
entity.Note = Trim(input.Note);
|
||||
|
||||
// Value-type/nullable fields are always applied from the payload (a null clears them).
|
||||
entity.ReservedForContactId = input.ReservedForContactId;
|
||||
entity.AppointmentDate = input.AppointmentDate;
|
||||
entity.Price = input.Price;
|
||||
entity.HandedOverDate = input.HandedOverDate;
|
||||
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
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.SaleReservations.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.SaleReservations.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? Trim(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static SaleReservationDto ToDto(SaleReservation r) =>
|
||||
new(r.Id, r.GerbilId, r.GerbilName, r.Status, r.ReservedForContactId, r.ContactName,
|
||||
r.AppointmentDate, r.Price, r.Note, r.HandedOverDate, r.CreatedAt, r.UpdatedAt);
|
||||
}
|
||||
}
|
||||
1596
GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs
generated
Normal file
1596
GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSaleReservation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SaleReservations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilName = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
ReservedForContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ContactName = table.Column<string>(type: "text", nullable: true),
|
||||
AppointmentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||
Note = table.Column<string>(type: "text", nullable: true),
|
||||
HandedOverDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SaleReservations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SaleReservations_GerbilId",
|
||||
table: "SaleReservations",
|
||||
column: "GerbilId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SaleReservations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1404,6 +1404,54 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("SaleContractAnimal");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("AppointmentDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ContactName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("GerbilName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("HandedOverDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("Price")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<Guid?>("ReservedForContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("SaleReservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
58
GerbilManagerWebAPI/Models/SaleReservation.cs
Normal file
58
GerbilManagerWebAPI/Models/SaleReservation.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale
|
||||
/// pipeline for an animal — verfügbar (available) → reserviert (reserved) → abgegeben
|
||||
/// (handed over). This is the *informal status* that precedes the legal sale contract
|
||||
/// (SaleContract) — it does NOT replace it.
|
||||
///
|
||||
/// Deliberately decoupled from the rest of the model (same pattern as Feedback):
|
||||
/// GerbilId and ReservedForContactId are plain nullable Guid columns, NOT enforced
|
||||
/// foreign keys, so the import re-ingest wipe (IngestResolvedService) can delete and
|
||||
/// recreate gerbils/contacts without deleting or breaking reservation rows. The
|
||||
/// captured GerbilName/ContactName keep the row human-readable after a wipe.
|
||||
/// </summary>
|
||||
public class SaleReservation
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the animal this status is about.</summary>
|
||||
public Guid GerbilId { get; set; }
|
||||
|
||||
/// <summary>Captured animal name (survives an ingest wipe).</summary>
|
||||
public string? GerbilName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Status: "verfuegbar" | "reserviert" | "abgegeben". Plain string, no FK —
|
||||
/// keeps the row decoupled and ingest-surviving.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "verfuegbar";
|
||||
|
||||
/// <summary>Loose reference (no FK) to the reserving/receiving contact (Interessent/Abnehmer), if any.</summary>
|
||||
public Guid? ReservedForContactId { get; set; }
|
||||
|
||||
/// <summary>Captured contact name (survives an ingest wipe).</summary>
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>Planned hand-over appointment (RPRO3 abgeben_tb._TERMIN).</summary>
|
||||
public DateTime? AppointmentDate { get; set; }
|
||||
|
||||
/// <summary>Agreed price (RPRO3 abstat_tb._PRICE).</summary>
|
||||
public decimal? Price { get; set; }
|
||||
|
||||
/// <summary>Free-text note (RPRO3 _BEM).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>When the animal was actually handed over (RPRO3 abstat_tb._AM); null until "abgegeben".</summary>
|
||||
public DateTime? HandedOverDate { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Server-side last-update time.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapAcquisitionEndpoints();
|
||||
app.MapSaleReservationEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user