feat(tier): Erwerb/Kauf je Tier (Datum, Preis, Notiz)
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<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)
|
||||
{
|
||||
|
||||
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal 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);
|
||||
}
|
||||
89
GerbilManagerWebAPI/Endpoints/AcquisitionEndpoints.cs
Normal file
89
GerbilManagerWebAPI/Endpoints/AcquisitionEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1580
GerbilManagerWebAPI/Migrations/20260622201453_AddAcquisitionRecord.Designer.cs
generated
Normal file
1580
GerbilManagerWebAPI/Migrations/20260622201453_AddAcquisitionRecord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
38
GerbilManagerWebAPI/Models/AcquisitionRecord.cs
Normal file
38
GerbilManagerWebAPI/Models/AcquisitionRecord.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapAcquisitionEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user