diff --git a/GerbilManager.Tests/IngestResolvedServiceTests.cs b/GerbilManager.Tests/IngestResolvedServiceTests.cs index 06d4606..0e526cc 100644 --- a/GerbilManager.Tests/IngestResolvedServiceTests.cs +++ b/GerbilManager.Tests/IngestResolvedServiceTests.cs @@ -10,6 +10,10 @@ namespace GerbilManager.Tests { private readonly string _dir; private readonly string _resolvedJsonPath; + private Guid ContactId { get; set; } + private Guid FatherId { get; set; } + private Guid ContractId { get; set; } + private Guid UnknownGerbilId { get; set; } public IngestResolvedServiceTests() { @@ -22,6 +26,10 @@ namespace GerbilManager.Tests var motherId = Guid.NewGuid(); var litterId = Guid.NewGuid(); var photoId = Guid.NewGuid(); + ContactId = contactId; + FatherId = fatherId; + ContractId = Guid.NewGuid(); + UnknownGerbilId = Guid.NewGuid(); var data = new { @@ -127,6 +135,20 @@ namespace GerbilManager.Tests FileName = "photo1.jpg", SortOrder = 0 } + }, + SaleContracts = new[] + { + new + { + Id = ContractId, + ContactId = contactId, + Price = 35.0m, + HandoverDate = "2023-09-29", + ContractDate = "2023-09-29", + FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx", + // One known animal + one unknown id (must be skipped defensively). + Animals = new[] { fatherId, UnknownGerbilId } + } } }; @@ -199,6 +221,41 @@ namespace GerbilManager.Tests Assert.NotNull(litter.Provenance); Assert.Contains("aus Wurfchronik", litter.Provenance); Assert.Contains("fromWurfchronik", litter.Provenance); + + // SaleContracts are created from the payload, and animal links to + // unknown gerbils are skipped defensively (only the known father links). + Assert.Equal(1, await db.SaleContracts.CountAsync()); + var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync(); + Assert.Equal(ContractId, contract.Id); + Assert.Equal(ContactId, contract.ContactId); + Assert.Equal(35.0m, contract.Price); + Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate); + var link = Assert.Single(contract.Animals); + Assert.Equal(FatherId, link.GerbilId); + } + + [Fact] + public async Task IngestResolved_recreates_sale_contracts_on_every_ingest() + { + using var db = NewDb(); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Import:SourcePath", _dir } + }) + .Build(); + + var service = new IngestResolvedService(db, config, null!); + await service.RunAsync(); + Assert.Equal(1, await db.SaleContracts.CountAsync()); + + // Re-ingest wipes then recreates from the (deterministic) payload — + // the contract survives by being part of the payload, no duplicate PK. + await service.RunAsync(); + Assert.Equal(1, await db.SaleContracts.CountAsync()); + var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync(); + Assert.Equal(ContractId, contract.Id); + Assert.Single(contract.Animals); } } } diff --git a/GerbilManagerWebAPI/Dtos/ContractDtos.cs b/GerbilManagerWebAPI/Dtos/ContractDtos.cs index afce2d6..2c248b3 100644 --- a/GerbilManagerWebAPI/Dtos/ContractDtos.cs +++ b/GerbilManagerWebAPI/Dtos/ContractDtos.cs @@ -12,7 +12,11 @@ namespace GerbilManagerWebAPI.Dtos DateTimeOffset CreatedAt, IReadOnlyList GerbilIds, // Download-URL der .docx ("/contracts/{id}/file"). - string Url); + string Url, + // True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte + // Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den + // Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt. + bool HasFile); public record SaleContractInput( Guid ContactId, diff --git a/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs index 6e0dbf7..627022a 100644 --- a/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs @@ -30,18 +30,24 @@ namespace GerbilManagerWebAPI.Endpoints var group = app.MapGroup("/contracts").WithTags("Contracts"); // GET /contracts (Gridify; GerbilIds via Include mitgeladen) - group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) => - TypedResults.Ok(await db.SaleContracts.AsNoTracking() + // HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen). + group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db, + IConfiguration config, IWebHostEnvironment env) => + { + var root = ContractRoot(config, env); + return TypedResults.Ok(await db.SaleContracts.AsNoTracking() .Include(c => c.Animals) - .ToPagedResultAsync(query, ToDto))); + .ToPagedResultAsync(query, c => ToDto(c, root))); + }); // GET /contracts/{id} - group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) => + group.MapGet("/{id:guid}", async Task, NotFound>> ( + Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var c = await db.SaleContracts.AsNoTracking() .Include(x => x.Animals) .FirstOrDefaultAsync(x => x.Id == id); - return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c)); + return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c, ContractRoot(config, env))); }); // POST /contracts — der Abgabe-Abschluss. @@ -272,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints return cleaned.Trim('-'); } - internal static SaleContractDto ToDto(SaleContract c) => new( + internal static SaleContractDto ToDto(SaleContract c, string? contractRoot = null) => new( c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt, c.Animals.Select(a => a.GerbilId).ToList(), - $"/contracts/{c.Id}/file"); + $"/contracts/{c.Id}/file", + // Imported contracts have no staged .docx -> HasFile=false (Word + // download 404s gracefully). When no root is given (e.g. POST, where + // the file was just written) we assume the file exists. + HasFile: contractRoot is null + || (!string.IsNullOrEmpty(c.FileName) + && File.Exists(Path.Combine(contractRoot, c.FileName)))); } } diff --git a/GerbilManagerWebAPI/Import/IngestResolvedService.cs b/GerbilManagerWebAPI/Import/IngestResolvedService.cs index 91d88c3..04ed41b 100644 --- a/GerbilManagerWebAPI/Import/IngestResolvedService.cs +++ b/GerbilManagerWebAPI/Import/IngestResolvedService.cs @@ -238,7 +238,53 @@ namespace GerbilManagerWebAPI.Import } await _db.SaveChangesAsync(); - return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos."; + // 7. Insert imported Abgabeverträge (SaleContract + join rows). + // The table was wiped above, so these are recreated on every ingest + // (they survive only by being part of the payload). FK-safe: contacts + // and gerbils are already inserted. We skip rows whose buyer contact + // is unknown and skip individual animal links to unknown gerbils + // (defensive — the resolver should never emit those). + // No .docx is copied: the source files live on the network share and + // are not staged into Contracts:RootPath, so the Word download 404s + // gracefully (the endpoint already returns NotFound) while the PDF is + // regenerated from the data. The DTO exposes HasFile=false for these + // so the frontend hides the Word button. + int contractsAdded = 0; + int contractAnimalsSkipped = 0; + if (data.SaleContracts.Count > 0) + { + var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync(); + var gerbilIds = await _db.Gerbils.Select(g => g.Id).ToHashSetAsync(); + var seenContractIds = new HashSet(); + foreach (var sc in data.SaleContracts) + { + if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload + if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer + + var animals = new List(); + foreach (var gid in (sc.Animals ?? new List()).Distinct()) + { + if (!gerbilIds.Contains(gid)) { contractAnimalsSkipped++; continue; } + animals.Add(new SaleContractAnimal { SaleContractId = sc.Id, GerbilId = gid }); + } + + _db.SaleContracts.Add(new SaleContract + { + Id = sc.Id, + ContactId = sc.ContactId, + Price = sc.Price, + HandoverDate = sc.HandoverDate, + ContractDate = sc.ContractDate, + FileName = sc.FileName, + CreatedAt = DateTimeOffset.UtcNow, + Animals = animals, + }); + contractsAdded++; + } + await _db.SaveChangesAsync(); + } + + return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped)."; } } @@ -248,6 +294,22 @@ namespace GerbilManagerWebAPI.Import public List Litters { get; set; } = new(); public List Gerbils { get; set; } = new(); public List GerbilPhotos { get; set; } = new(); + public List SaleContracts { get; set; } = new(); + } + + /// Eine importierte Abgabevertrag-Zeile aus resolved_import.json. + /// Animals sind die zugeordneten Gerbil-Ids (Join-Zeilen werden beim + /// Ingest gebaut). Die .docx liegt NICHT im Vertrags-Dateiroot — der + /// Word-Download liefert 404, das PDF wird aus den Daten neu erzeugt. + public class ResolvedSaleContract + { + public Guid Id { get; set; } + public Guid ContactId { get; set; } + public decimal Price { get; set; } + public DateOnly HandoverDate { get; set; } + public DateOnly ContractDate { get; set; } + public required string FileName { get; set; } + public List Animals { get; set; } = new(); } public class ResolvedPhoto diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 78faa7a..5cf130c 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -363,6 +363,7 @@ export async function installMockApi(page: Page): Promise { fileName: `vertrag-${id}.docx`, createdAt: new Date().toISOString(), url: `/contracts/${id}/file`, + hasFile: true, } ;(db.contracts as unknown as Row[]).push(contract as unknown as Row) // Tiere auf GivenAway setzen (wie das echte Backend) diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index b07cefe..2c11cdd 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -55,6 +55,8 @@ export interface MockContract { createdAt: string gerbilIds: string[] url: string + /** Ob die Word-Datei vorliegt. Importierte Verträge: false (kein Word-Button). */ + hasFile: boolean } export interface MockDb { @@ -372,7 +374,21 @@ export function seedDb(): MockDb { requests, aiConfigured: true, mailConfigured: true, - contracts: [], + // Ein importierter Vertrag (hasFile:false) prüft die Word-Button-Ausblendung. + contracts: [ + { + id: 'contract-import-1', + contactId: 'con-meier', + price: 30, + handoverDate: '2023-09-29', + contractDate: '2023-09-29', + fileName: 'Zucht der kleinen Chaoten _ (Krümel) - Zoohandlung Meier_.docx', + createdAt: '2023-09-29T00:00:00Z', + gerbilIds: ['kruemel'], + url: '/contracts/contract-import-1/file', + hasFile: false, + }, + ], saleAdConfigured: true, namesConfigured: true, feedback: [], diff --git a/gerbil-manager-web/e2e/vertraege.spec.ts b/gerbil-manager-web/e2e/vertraege.spec.ts new file mode 100644 index 0000000..7ad3bda --- /dev/null +++ b/gerbil-manager-web/e2e/vertraege.spec.ts @@ -0,0 +1,33 @@ +/** + * FEAT-Contracts: Verträge-Liste (/vertraege) zeigt importierte Abgabeverträge. + * + * Der Mock-Seed enthält EINEN importierten Vertrag (hasFile:false). Wir prüfen, + * dass er gerendert wird (Abnehmer, Preis, Datum, Tieranzahl) und dass der + * Word-Download-Button für ihn ausgeblendet ist (kein .docx), während der + * PDF-Button (aus den Daten neu erzeugt) sichtbar bleibt. + */ +import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures' + +const t = de.pages.vertraege + +test.describe('Verträge-Liste', () => { + test('importierter Vertrag wird gelistet; Word-Button ausgeblendet, PDF bleibt', async ({ + page, + }) => { + skipUnlessMock() + await gotoSection(page, de.nav.contracts) + await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible() + + const card = page.locator('.vertrag-card').filter({ hasText: 'Zoohandlung Meier' }) + await expect(card).toBeVisible() + // Tieranzahl + Preis + Übergabedatum erscheinen in der Meta-Zeile. + await expect(card).toContainText(t.animalsCount(1)) + await expect(card).toContainText('30,00 €') + await expect(card).toContainText('29.09.2023') + + // PDF-Button vorhanden, Word-Button NICHT (importiert, keine Datei). + await expect(card.getByRole('link', { name: t.downloadPdf })).toBeVisible() + await expect(card.getByRole('link', { name: t.download, exact: true })).toHaveCount(0) + await expect(card.getByText(t.noWordFileShort)).toBeVisible() + }) +}) diff --git a/gerbil-manager-web/src/api/contracts.ts b/gerbil-manager-web/src/api/contracts.ts index 790ded7..fd28135 100644 --- a/gerbil-manager-web/src/api/contracts.ts +++ b/gerbil-manager-web/src/api/contracts.ts @@ -14,6 +14,9 @@ export interface SaleContract { gerbilIds: string[] /** API-relativer Download-Pfad ("/contracts/{id}/file"). */ url: string + /** Ob die Word-Datei im Vertrags-Dateiroot liegt. Importierte Verträge haben + * keine Datei (Word-Download 404) — das PDF wird trotzdem neu erzeugt. */ + hasFile: boolean } export interface CreateSaleContract { diff --git a/gerbil-manager-web/src/pages/VertraegeListPage.tsx b/gerbil-manager-web/src/pages/VertraegeListPage.tsx index f13939e..e648933 100644 --- a/gerbil-manager-web/src/pages/VertraegeListPage.tsx +++ b/gerbil-manager-web/src/pages/VertraegeListPage.tsx @@ -84,9 +84,15 @@ export default function VertraegeListPage() { {t.downloadPdf} - - {t.download} - + {c.hasFile ? ( + + {t.download} + + ) : ( + + {t.noWordFileShort} + + )}