using System.Text.Json; using System.Text.Json.Serialization; using GerbilManagerWebAPI.Models; using Microsoft.EntityFrameworkCore; using GerbilManagerWebAPI.Services; namespace GerbilManagerWebAPI.Import { public sealed class IngestResolvedService { private readonly ApplicationContext _db; private readonly string _resolvedJsonPath; private readonly string _sourceDir; private readonly string _photoRoot; public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env) { _db = db; var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory(); _sourceDir = config["Import:SourcePath"] ?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output")); _resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json"); _photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage"); } public async Task RunAsync() { if (!File.Exists(_resolvedJsonPath)) { return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first."; } string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath); var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); var data = JsonSerializer.Deserialize(jsonContent, jsonOptions); if (data == null) { return "Error: Failed to deserialize resolved_import.json."; } // 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory") { // Break circular references first foreach (var l in _db.Litters) { l.FatherId = null; l.MotherId = null; } foreach (var g in _db.Gerbils) { g.LitterId = null; } await _db.SaveChangesAsync(); _db.GerbilPhotos.RemoveRange(_db.GerbilPhotos); _db.WeightRecords.RemoveRange(_db.WeightRecords); _db.HealthRecords.RemoveRange(_db.HealthRecords); _db.SaleContracts.RemoveRange(_db.SaleContracts); _db.Gerbils.RemoveRange(_db.Gerbils); _db.Litters.RemoveRange(_db.Litters); _db.Contacts.RemoveRange(_db.Contacts); await _db.SaveChangesAsync(); } else { // Break circular references first using ExecuteUpdateAsync await _db.Litters.ExecuteUpdateAsync(s => s .SetProperty(l => l.FatherId, (Guid?)null) .SetProperty(l => l.MotherId, (Guid?)null)); await _db.Gerbils.ExecuteUpdateAsync(s => s .SetProperty(g => g.LitterId, (Guid?)null)); await _db.GerbilPhotos.ExecuteDeleteAsync(); await _db.WeightRecords.ExecuteDeleteAsync(); await _db.HealthRecords.ExecuteDeleteAsync(); await _db.SaleContracts.ExecuteDeleteAsync(); await _db.Gerbils.ExecuteDeleteAsync(); await _db.Litters.ExecuteDeleteAsync(); await _db.Contacts.ExecuteDeleteAsync(); } // 2. Import Contacts (Add new ones, and update roles/details of existing ones) var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id); var addedContactIds = new HashSet(); int contactsAdded = 0; int contactsUpdated = 0; foreach (var c in data.Contacts) { if (existingContacts.TryGetValue(c.Id, out var existingContact)) { existingContact.Name = c.Name; existingContact.Email = c.Email; existingContact.Phone = c.Phone; existingContact.Address = c.Address; existingContact.Notes = c.Notes; existingContact.IsBreeder = c.IsBreeder; existingContact.IsReceiver = c.IsReceiver; existingContact.NameSuffix = c.NameSuffix; existingContact.Provenance = c.Provenance; contactsUpdated++; } else if (addedContactIds.Add(c.Id)) { _db.Contacts.Add(c); contactsAdded++; } } await _db.SaveChangesAsync(); // 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors var littersToInsert = new List(); foreach (var l in data.Litters) { littersToInsert.Add(new Litter { Id = l.Id, Name = l.Name, Date = l.Date, TotalBorn = l.TotalBorn, DeathsWithin8Weeks = l.DeathsWithin8Weeks, FatherId = null, // Set in Pass 2 MotherId = null, // Set in Pass 2 ExpectedGoHomeDate = l.ExpectedGoHomeDate, Notes = l.Notes, PairingCode = l.PairingCode, ExternalRef = l.ExternalRef, LitterLetter = l.LitterLetter, Provenance = l.Provenance }); } _db.Litters.AddRange(littersToInsert); await _db.SaveChangesAsync(); // 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors var gerbilsToInsert = new List(); foreach (var g in data.Gerbils) { var importedGerbil = new Gerbil { Id = g.Id, Name = g.Name, Gender = g.Gender, Status = g.Status, LitterId = null, // Set in Pass 2 OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId, EnclosureId = g.EnclosureId, ColorVarietyId = g.ColorVarietyId, DateOfBirth = g.DateOfBirth, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath, GoHomeDate = g.GoHomeDate, Genotype = g.Genotype, Notes = g.Notes, ImportSource = g.ImportSource, ExternalRef = g.ExternalRef, RawImportData = g.RawImportData, Provenance = g.Provenance, OriginBreeder = g.OriginBreeder, NameSearch = g.NameSearch, CharacterTraits = g.CharacterTraits, CharacterNote = g.CharacterNote, IsDeaf = g.IsDeaf, IsResident = g.IsResident }; GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow)); gerbilsToInsert.Add(importedGerbil); } _db.Gerbils.AddRange(gerbilsToInsert); await _db.SaveChangesAsync(); // 5. Pass 2: Set the actual foreign key relationships // Update Litters with their parent animal IDs var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id); foreach (var l in data.Litters) { if (littersInDb.TryGetValue(l.Id, out var dbLitter)) { dbLitter.FatherId = l.FatherId; dbLitter.MotherId = l.MotherId; } } // Update Gerbils with their actual LitterId var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id); foreach (var g in data.Gerbils) { if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil)) { dbGerbil.LitterId = g.LitterId; } } await _db.SaveChangesAsync(); // 6. Insert all Photos & copy physical files Directory.CreateDirectory(_photoRoot); foreach (var p in data.GerbilPhotos) { if (!string.IsNullOrEmpty(p.SourcePath)) { var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); var src = Path.Combine(_sourceDir, relPath); if (File.Exists(src)) { var dest = Path.Combine(_photoRoot, p.FileName); try { File.Copy(src, dest, overwrite: true); } catch (Exception ex) { Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}"); } } else { Console.WriteLine($"Warning: Source photo file not found at {src}"); } } _db.GerbilPhotos.Add(new GerbilPhoto { Id = p.Id, GerbilId = p.GerbilId, FileName = p.FileName, Caption = p.Caption, SortOrder = p.SortOrder, CreatedAt = DateTimeOffset.UtcNow }); } await _db.SaveChangesAsync(); // 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)."; } } public class ResolvedImportData { public List Contacts { get; set; } = new(); 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 { public Guid Id { get; set; } public Guid GerbilId { get; set; } public required string FileName { get; set; } public string? Caption { get; set; } public int SortOrder { get; set; } [JsonPropertyName("_source_path")] public string? SourcePath { get; set; } } }