diff --git a/src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStep.cs b/src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStep.cs index 33ed2e79..bafb2992 100644 --- a/src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStep.cs +++ b/src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStep.cs @@ -28,9 +28,8 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context, { if (context.SilverHoldings.Count > 0) { - var representative = context.SilverHoldings.Any(x => x.HoldingStatus == HoldingStatusType.Active.GetDescription()) - ? context.SilverHoldings.Where(x => x.HoldingStatus == HoldingStatusType.Active.GetDescription()).OrderByDescending(h => h.LastUpdatedDate).First() - : context.SilverHoldings.OrderByDescending(h => h.LastUpdatedDate).First(); + // Prefer SAM Holding over Common Land when selecting representative + var representative = SamHoldingMapper.SelectRepresentativeHolding(context.SilverHoldings); var existingHoldingFilter = Builders.Filter.ElemMatch( x => x.Identifiers, @@ -69,7 +68,7 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context, siteTypeDerivedCodeLookupService, cancellationToken); - await EnrichWithCommonLandDataAsync(context, representative, cancellationToken); + await EnrichWithCommonLandDataAsync(context, context.SilverHoldings, cancellationToken); logger.LogInformation("Associated main sites queued for update: {Count} for CPH {Cph}", context.AssociatedMainSites?.Count ?? 0, context.Cph); @@ -86,14 +85,21 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context, } } - private async Task EnrichWithCommonLandDataAsync(SamHoldingImportContext context, SamHoldingDocument representative, CancellationToken cancellationToken) + private async Task EnrichWithCommonLandDataAsync(SamHoldingImportContext context, List silverHoldings, CancellationToken cancellationToken) { var goldSite = context.GoldSite; if (goldSite == null) return; - goldSite.LocalAuthorityName = representative.LocalAuthorityName; + // Merge LocalAuthorityName - prefer non-null value from any holding + goldSite.LocalAuthorityName = silverHoldings + .Select(h => h.LocalAuthorityName) + .FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)); - goldSite.AssociatedMainHoldings = representative.AssociatedMainHoldings + // Merge AssociatedMainHoldings from all holdings, removing duplicates + var allMainHoldings = silverHoldings + .SelectMany(h => h.AssociatedMainHoldings) + .GroupBy(r => r.HoldingIdentifier) + .Select(g => g.OrderByDescending(r => r.StartDate).First()) .Select(r => new AssociatedHoldingDocument { HoldingIdentifier = r.HoldingIdentifier, @@ -103,13 +109,17 @@ private async Task EnrichWithCommonLandDataAsync(SamHoldingImportContext context }) .ToList(); + goldSite.AssociatedMainHoldings = allMainHoldings; + if (goldSite.AssociatedMainHoldings?.Count > 0) { - await FindAndUpdateMainSiteIfExists(context, representative, goldSite.AssociatedMainHoldings, cancellationToken); + // Get the CPH from any holding (they all have the same CPH) + var cph = silverHoldings.First().CountyParishHoldingNumber; + await FindAndUpdateMainSiteIfExists(context, cph, goldSite.AssociatedMainHoldings, cancellationToken); } } - private async Task FindAndUpdateMainSiteIfExists(SamHoldingImportContext context, SamHoldingDocument representative, List mainHoldings, CancellationToken cancellationToken) + private async Task FindAndUpdateMainSiteIfExists(SamHoldingImportContext context, string commonCph, List mainHoldings, CancellationToken cancellationToken) { foreach (var mainHolding in mainHoldings) { @@ -132,7 +142,7 @@ private async Task FindAndUpdateMainSiteIfExists(SamHoldingImportContext context var commonForMain = new AssociatedHoldingDocument { - HoldingIdentifier = representative.CountyParishHoldingNumber, + HoldingIdentifier = commonCph, ContiguousFlag = mainHolding.ContiguousFlag, StartDate = mainHolding.StartDate, EndDate = mainHolding.EndDate diff --git a/src/KeeperData.Application/Orchestration/Imports/Sam/Mappings/SamHoldingMapper.cs b/src/KeeperData.Application/Orchestration/Imports/Sam/Mappings/SamHoldingMapper.cs index 7a89f93a..bb6b60c5 100644 --- a/src/KeeperData.Application/Orchestration/Imports/Sam/Mappings/SamHoldingMapper.cs +++ b/src/KeeperData.Application/Orchestration/Imports/Sam/Mappings/SamHoldingMapper.cs @@ -92,7 +92,7 @@ public static async Task ToSilver( SiteTypeCode = null, SpeciesTypeCode = h.AnimalSpeciesCodeUnwrapped, - ProductionUsageCodeList = [.. h.AnimalProductionUsageCodeList.Select(ProductionUsageCodeFormatters.TrimProductionUsageCodeHolding)], + ProductionUsageCodeList = [.. h.AnimalProductionUsageCodeList.Select(ProductionUsageCodeFormatters.TrimProductionUsageCodeHolding).Distinct()], Location = new Core.Documents.Silver.LocationDocument { @@ -129,6 +129,42 @@ public static async Task ToSilver( return result; } + internal static SamHoldingDocument SelectRepresentativeHolding(List silverHoldings) + { + const string commonLandBusinessUsage = "Common Land"; + var activeStatus = HoldingStatusType.Active.GetDescription(); + + // Priority 1: Active SAM Holding (not Common Land) + var activeSamHolding = silverHoldings + .Where(x => x.HoldingStatus == activeStatus && x.SourceFacilitySubBusinessActivityCode != commonLandBusinessUsage) + .OrderByDescending(h => h.LastUpdatedDate) + .FirstOrDefault(); + + if (activeSamHolding != null) + return activeSamHolding; + + // Priority 2: Any SAM Holding (not Common Land) + var samHolding = silverHoldings + .Where(x => x.SourceFacilitySubBusinessActivityCode != commonLandBusinessUsage) + .OrderByDescending(h => h.LastUpdatedDate) + .FirstOrDefault(); + + if (samHolding != null) + return samHolding; + + // Priority 3: Active Common Land + var activeCommonLand = silverHoldings + .Where(x => x.HoldingStatus == activeStatus) + .OrderByDescending(h => h.LastUpdatedDate) + .FirstOrDefault(); + + if (activeCommonLand != null) + return activeCommonLand; + + // Priority 4: Any holding (fallback) + return silverHoldings.OrderByDescending(h => h.LastUpdatedDate).First(); + } + public static async Task ToGold( string goldSiteId, SiteDocument? existingSite, @@ -146,9 +182,8 @@ public static async Task ToSilver( if (silverHoldings == null || silverHoldings.Count == 0) return null; - var representative = silverHoldings.Any(x => x.HoldingStatus == HoldingStatusType.Active.GetDescription()) - ? silverHoldings.Where(x => x.HoldingStatus == HoldingStatusType.Active.GetDescription()).OrderByDescending(h => h.LastUpdatedDate).First() - : silverHoldings.OrderByDescending(h => h.LastUpdatedDate).First(); + // Prefer SAM Holding over Common Land when selecting representative + var representative = SelectRepresentativeHolding(silverHoldings); var distinctSpecies = await GetDistinctReferenceDataAsync( silverHoldings.Select(h => h.SpeciesTypeCode), @@ -315,8 +350,8 @@ private static async Task CreateSiteAsync( SiteIdentifierType? siteIdentifierType, CancellationToken cancellationToken) { - var address = await LocationMapper.AddressToGold(representative.Location?.Address, getCountryById, cancellationToken); - var communication = LocationMapper.CommunicationToGold(representative.Communication); + var (address, communication) = await ResolveLocationPartsAsync(representative, getCountryById, cancellationToken); + var isPermanentLandHolding = representative.CphRelationshipType.IsPermanentLandHolding(); var location = Location.Create( representative.Location?.OsMapReference, @@ -325,13 +360,6 @@ private static async Task CreateSiteAsync( address, communication: [communication]); - var groupMarks = ToGroupMarks(goldSiteGroupMarks); - - var siteParties = goldParties - .Where(p => !p.Deleted && !string.IsNullOrWhiteSpace(p.CustomerNumber)) - .Select(p => p.ToSitePartyDomain(representative.LastUpdatedDate)) - .ToList(); - var site = Site.Create( goldSiteId, representative.CreatedDate, @@ -343,26 +371,13 @@ private static async Task CreateSiteAsync( SourceSystemType.SAM.ToString(), null, representative.Deleted, - representative.CphRelationshipType.IsPermanentLandHolding() ? null : representative.SecondaryCph, + isPermanentLandHolding ? null : representative.SecondaryCph, representative.CphTypeIdentifier, siteType, location, - representative.CphRelationshipType.IsPermanentLandHolding() ? representative.SecondaryCph : null); - - if (siteIdentifierType != null) - { - site.SetSiteIdentifier( - identifierLastUpdatedDate: representative.LastUpdatedDate, - identifier: representative.CountyParishHoldingNumber, - type: siteIdentifierType, - id: null, - siteLastUpdatedDate: representative.LastUpdatedDate); - } + isPermanentLandHolding ? representative.SecondaryCph : null); - site.SetSpecies(species, representative.LastUpdatedDate); - site.SetActivities(activities, representative.LastUpdatedDate); - site.SetGroupMarks(groupMarks, representative.LastUpdatedDate); - site.SetSiteParties(goldSiteId, siteParties, representative.LastUpdatedDate); + ApplySiteData(site, goldSiteId, representative, goldSiteGroupMarks, goldParties, species, activities, siteIdentifierType); return site; } @@ -379,15 +394,9 @@ private static async Task UpdateSiteAsync( SiteIdentifierType? siteIdentifierType, CancellationToken cancellationToken) { + var isPermanentLandHolding = representative.CphRelationshipType.IsPermanentLandHolding(); var site = existing.ToDomain(); - var groupMarks = ToGroupMarks(goldSiteGroupMarks); - - var siteParties = goldParties - .Where(p => !p.Deleted && !string.IsNullOrWhiteSpace(p.CustomerNumber)) - .Select(p => p.ToSitePartyDomain(representative.LastUpdatedDate)) - .ToList(); - site.Update( representative.LastUpdatedDate, representative.LocationName ?? string.Empty, @@ -397,12 +406,11 @@ private static async Task UpdateSiteAsync( SourceSystemType.SAM.ToString(), null, representative.Deleted, - representative.CphRelationshipType.IsPermanentLandHolding() ? null : representative.SecondaryCph, + isPermanentLandHolding ? null : representative.SecondaryCph, representative.CphTypeIdentifier, - representative.CphRelationshipType.IsPermanentLandHolding() ? representative.SecondaryCph : null); + isPermanentLandHolding ? representative.SecondaryCph : null); - var updatedAddress = await LocationMapper.AddressToGold(representative.Location?.Address, getCountryById, cancellationToken); - var updatedCommunication = LocationMapper.CommunicationToGold(representative.Communication); + var (updatedAddress, updatedCommunication) = await ResolveLocationPartsAsync(representative, getCountryById, cancellationToken); // Always set the derived site type (may be null if no mapping found). site.SetSiteType(siteType, representative.LastUpdatedDate); @@ -415,25 +423,22 @@ private static async Task UpdateSiteAsync( updatedAddress, [updatedCommunication]); - if (siteIdentifierType != null) - { - site.SetSiteIdentifier( - identifierLastUpdatedDate: representative.LastUpdatedDate, - identifier: representative.CountyParishHoldingNumber, - type: siteIdentifierType, - id: null, - siteLastUpdatedDate: representative.LastUpdatedDate); - } - - site.SetSpecies(species, representative.LastUpdatedDate); - site.SetActivities(activities, representative.LastUpdatedDate); - site.SetGroupMarks(groupMarks, representative.LastUpdatedDate); - site.SetSiteParties(existing.Id, siteParties, representative.LastUpdatedDate); + ApplySiteData(site, existing.Id, representative, goldSiteGroupMarks, goldParties, species, activities, siteIdentifierType); return site; } + private static async Task<(Address address, Communication communication)> ResolveLocationPartsAsync( + SamHoldingDocument representative, + Func> getCountryById, + CancellationToken cancellationToken) + { + var address = await LocationMapper.AddressToGold(representative.Location?.Address, getCountryById, cancellationToken); + var communication = LocationMapper.CommunicationToGold(representative.Communication); + return (address, communication); + } + private static async Task> GetDistinctReferenceDataAsync( IEnumerable rawCodes, Func> findAsync, @@ -455,6 +460,38 @@ private static async Task UpdateSiteAsync( return [.. results]; } + private static void ApplySiteData( + Site site, + string siteId, + SamHoldingDocument representative, + List goldSiteGroupMarks, + List goldParties, + List species, + List activities, + SiteIdentifierType? siteIdentifierType) + { + var groupMarks = ToGroupMarks(goldSiteGroupMarks); + var siteParties = goldParties + .Where(p => !p.Deleted && !string.IsNullOrWhiteSpace(p.CustomerNumber)) + .Select(p => p.ToSitePartyDomain(representative.LastUpdatedDate)) + .ToList(); + + if (siteIdentifierType != null) + { + site.SetSiteIdentifier( + identifierLastUpdatedDate: representative.LastUpdatedDate, + identifier: representative.CountyParishHoldingNumber, + type: siteIdentifierType, + id: null, + siteLastUpdatedDate: representative.LastUpdatedDate); + } + + site.SetSpecies(species, representative.LastUpdatedDate); + site.SetActivities(activities, representative.LastUpdatedDate); + site.SetGroupMarks(groupMarks, representative.LastUpdatedDate); + site.SetSiteParties(siteId, siteParties, representative.LastUpdatedDate); + } + private static List ToGroupMarks(List relationships) { return diff --git a/src/KeeperData.Infrastructure/ApiClients/Fakes/FakeDataBridgeClient.cs b/src/KeeperData.Infrastructure/ApiClients/Fakes/FakeDataBridgeClient.cs index 5171c8bc..1a33602f 100644 --- a/src/KeeperData.Infrastructure/ApiClients/Fakes/FakeDataBridgeClient.cs +++ b/src/KeeperData.Infrastructure/ApiClients/Fakes/FakeDataBridgeClient.cs @@ -279,17 +279,47 @@ private static DataBridgeResponse GetDataBridgeResponse(List data, int private List GetSamCphHolding(string? id = null) { return [ - new SamCphHolding { + new SamCphHolding + { + ANIMAL_PRODUCTION_USAGE_CODE = "MEAT", + ANIMAL_SPECIES_CODE = "CTT", BATCH_ID = 1, CHANGE_TYPE = "I", - IsDeleted = false, - UpdatedAtUtc = DateTime.UtcNow, - CreatedAtUtc = DateTime.UtcNow, - CPH = id ?? $"{_random.Next(10, 99)}{_random.Next(100, 999)}{_random.Next(1000, 9999)}", - FEATURE_NAME = Guid.NewGuid().ToString(), + COUNTRY_CODE = "GB", + CPH = id ?? $"{_random.Next(10, 99)}/{_random.Next(100, 999):000}/{_random.Next(1000, 9999)}", + CPH_RELATIONSHIP_TYPE = "MAIN", CPH_TYPE = "PERMANENT", + CreatedAtUtc = DateTime.UtcNow, + DISEASE_TYPE = null, + EASTING = 400022, + FACILITY_BUSINSS_ACTVTY_CODE = "FACACT", + FACILITY_TYPE_CODE = "CL", + FCLTY_SUB_BSNSS_ACTVTY_CODE = "FACSUB", FEATURE_ADDRESS_FROM_DATE = DateTime.Today.AddDays(-1), - FCLTY_SUB_BSNSS_ACTVTY_CODE = "SLG-RM-NA" + FEATURE_ADDRESS_TO_DATE = null, + FEATURE_NAME = "Feature 22", + INTERVAL = 12m, + INTERVAL_UNIT_OF_TIME = "Months", + IsDeleted = false, + LOCALITY = "Locality22", + MOVEMENT_RSTRCTN_RSN_CODE = null, + NORTHING = 500022, + OS_MAP_REFERENCE = null, + PAON_END_NUMBER = 20, + PAON_END_NUMBER_SUFFIX = 'D', + PAON_START_NUMBER = 2, + PAON_START_NUMBER_SUFFIX = 'C', + POSTCODE = "CPH22 222", + SAON_END_NUMBER = 10, + SAON_END_NUMBER_SUFFIX = 'B', + SAON_START_NUMBER = 1, + SAON_START_NUMBER_SUFFIX = 'A', + SECONDARY_CPH = "00/000/9267", + STREET = "Holding Street 22", + TOWN = "Town22", + UDPRN = "25000022", + UK_INTERNAL_CODE = "ENGLAND", + UpdatedAtUtc = DateTime.UtcNow }]; } diff --git a/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStepTests.cs b/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStepTests.cs index 6037a63a..b32148b8 100644 --- a/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStepTests.cs +++ b/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStepTests.cs @@ -194,4 +194,242 @@ public async Task FindAndUpdateMainSiteIfExists_ReplacesExistingContextEntry_Whe Assert.NotNull(replaced.AssociatedCommonLands); Assert.Contains(replaced.AssociatedCommonLands, a => a.HoldingIdentifier == "CPH-1"); } + + [Fact] + public async Task EnrichWithCommonLandData_MergesLocalAuthorityName_WhenMultipleSilverHoldings() + { + var goldSiteRepoMock = new Mock>(); + var partiesRepoMock = new Mock(); + + var samHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Sheep Farm", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow, + LocalAuthorityName = null + }; + + var commonLandHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow.AddDays(-1), + LocalAuthorityName = "Devon County Council" + }; + + goldSiteRepoMock.Setup(r => r.FindOneByFilterAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((SiteDocument?)null); + + var context = new SamHoldingImportContext + { + Cph = "CPH-1", + SilverHoldings = new List { samHolding, commonLandHolding }, + SilverHerds = new List(), + SilverParties = new List(), + GoldSite = new SiteDocument { Id = "gold-site-1" } + }; + + var mappingStep = new SamHoldingImportGoldMappingStep( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + goldSiteRepoMock.Object, + partiesRepoMock.Object, + new Mock>().Object); + + await mappingStep.ExecuteAsync(context, CancellationToken.None); + + Assert.Equal("Devon County Council", context.GoldSite.LocalAuthorityName); + } + + [Fact] + public async Task EnrichWithCommonLandData_MergesAssociatedMainHoldings_WhenMultipleSilverHoldings() + { + var goldSiteRepoMock = new Mock>(); + var partiesRepoMock = new Mock(); + + var samHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Sheep Farm", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow, + AssociatedMainHoldings = new List() + }; + + var commonLandHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow.AddDays(-1), + AssociatedMainHoldings = new List + { + new AssociatedHoldingRelationship { HoldingIdentifier = "MAIN-1", ContiguousFlag = true, StartDate = "2024-01-01" }, + new AssociatedHoldingRelationship { HoldingIdentifier = "MAIN-2", ContiguousFlag = false, StartDate = "2024-02-01" } + } + }; + + goldSiteRepoMock.Setup(r => r.FindOneByFilterAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((SiteDocument?)null); + + var context = new SamHoldingImportContext + { + Cph = "CPH-1", + SilverHoldings = new List { samHolding, commonLandHolding }, + SilverHerds = new List(), + SilverParties = new List(), + GoldSite = new SiteDocument { Id = "gold-site-1" } + }; + + var mappingStep = new SamHoldingImportGoldMappingStep( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + goldSiteRepoMock.Object, + partiesRepoMock.Object, + new Mock>().Object); + + await mappingStep.ExecuteAsync(context, CancellationToken.None); + + Assert.NotNull(context.GoldSite.AssociatedMainHoldings); + Assert.Equal(2, context.GoldSite.AssociatedMainHoldings.Count); + Assert.Contains(context.GoldSite.AssociatedMainHoldings, h => h.HoldingIdentifier == "MAIN-1"); + Assert.Contains(context.GoldSite.AssociatedMainHoldings, h => h.HoldingIdentifier == "MAIN-2"); + } + + [Fact] + public async Task SelectRepresentativeHolding_ReturnsActiveCommonLand_WhenAllHoldingsAreCommonLandAndOneIsActive() + { + // Arrange: all holdings are Common Land (Priorities 1 & 2 fail), one is active + var goldSiteRepoMock = new Mock>(); + var partiesRepoMock = new Mock(); + + var activeCommonLand = new SamHoldingDocument + { + CountyParishHoldingNumber = "ACTIVE-CPH", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "active", + LastUpdatedDate = DateTime.UtcNow + }; + + var inactiveCommonLand = new SamHoldingDocument + { + CountyParishHoldingNumber = "INACTIVE-CPH", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "inactive", + LastUpdatedDate = DateTime.UtcNow.AddDays(-1) + }; + + goldSiteRepoMock + .Setup(r => r.FindOneByFilterAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((SiteDocument?)null); + + var context = new SamHoldingImportContext + { + Cph = "ACTIVE-CPH", + SilverHoldings = new List { inactiveCommonLand, activeCommonLand }, + SilverHerds = new List(), + SilverParties = new List(), + GoldSite = new SiteDocument { Id = "pre-set" } + }; + + var mappingStep = new SamHoldingImportGoldMappingStep( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + goldSiteRepoMock.Object, + partiesRepoMock.Object, + new Mock>().Object); + + // Act + await mappingStep.ExecuteAsync(context, CancellationToken.None); + + // Assert: the active Common Land holding was selected as representative, + // so the existing-site filter used its CPH and GoldSiteId was assigned. + // FindOneByFilterAsync is called once, meaning representative selection completed. + goldSiteRepoMock.Verify( + r => r.FindOneByFilterAsync(It.IsAny>(), It.IsAny()), + Times.Once); + + // The GoldSiteId is always assigned when SilverHoldings is non-empty + Assert.NotNull(context.GoldSiteId); + + // The representative's CPH drives the context Cph; confirm it matches the active holding + Assert.Equal("ACTIVE-CPH", context.Cph); + } + + [Fact] + public async Task EnrichWithCommonLandData_DeduplicatesAssociatedMainHoldings_WhenSameIdentifierInMultipleHoldings() + { + var goldSiteRepoMock = new Mock>(); + var partiesRepoMock = new Mock(); + + var samHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Sheep Farm", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow, + AssociatedMainHoldings = new List + { + new AssociatedHoldingRelationship { HoldingIdentifier = "MAIN-1", ContiguousFlag = true, StartDate = "2024-01-01" } + } + }; + + var commonLandHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow.AddDays(-1), + AssociatedMainHoldings = new List + { + new AssociatedHoldingRelationship { HoldingIdentifier = "MAIN-1", ContiguousFlag = false, StartDate = "2024-03-01" } + } + }; + + goldSiteRepoMock.Setup(r => r.FindOneByFilterAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((SiteDocument?)null); + + var context = new SamHoldingImportContext + { + Cph = "CPH-1", + SilverHoldings = new List { samHolding, commonLandHolding }, + SilverHerds = new List(), + SilverParties = new List(), + GoldSite = new SiteDocument { Id = "gold-site-1" } + }; + + var mappingStep = new SamHoldingImportGoldMappingStep( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + goldSiteRepoMock.Object, + partiesRepoMock.Object, + new Mock>().Object); + + await mappingStep.ExecuteAsync(context, CancellationToken.None); + + Assert.NotNull(context.GoldSite.AssociatedMainHoldings); + Assert.Single(context.GoldSite.AssociatedMainHoldings); + var mainHolding = context.GoldSite.AssociatedMainHoldings[0]; + Assert.Equal("MAIN-1", mainHolding.HoldingIdentifier); + // Should prefer the most recent StartDate (2024-03-01) + Assert.Equal("2024-03-01", mainHolding.StartDate); + } } \ No newline at end of file diff --git a/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Mappings/SamHoldingMapperTests.cs b/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Mappings/SamHoldingMapperTests.cs index 435e2713..9a03a72e 100644 --- a/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Mappings/SamHoldingMapperTests.cs +++ b/tests/KeeperData.Application.Tests.Unit/Orchestration/Imports/Sam/Mappings/SamHoldingMapperTests.cs @@ -239,4 +239,205 @@ private static List GenerateSamCphHolding(int quantity) } return records; } + + [Fact] + public async Task ToGold_PrefersActiveSamHolding_OverCommonLand() + { + var activeSamHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Sheep Farm", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow, + LocationName = "SAM Farm Location", + SpeciesTypeCode = "SHE" + }; + + var activeCommonLand = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow.AddDays(1), // More recent, but should not be selected + LocationName = "Common Land Location", + SpeciesTypeCode = null + }; + + var siteIdentifierType = new SiteIdentifierTypeDocument + { + IdentifierId = "type-id", + Code = "CPHN", + Name = "CPH Number", + LastModifiedDate = DateTime.UtcNow + }; + + var result = await SamHoldingMapper.ToGold( + "gold-site-id", + null, + [activeSamHolding, activeCommonLand], + [], + [], + (_, _) => Task.FromResult(null), + (_, _) => Task.FromResult(null), + (code, _) => Task.FromResult( + code == "CPHN" ? siteIdentifierType : null), + (code, _) => Task.FromResult<(string?, string?)>(code == "SHE" ? ("species-id", "Sheep") : (null, null)), + (_, _) => Task.FromResult(null), + Mock.Of(), + CancellationToken.None); + + result.Should().NotBeNull(); + // Verify that SAM Holding was used as representative by checking species (only SAM has it) + result!.Species.Should().NotBeNull(); + result.Species.Should().ContainSingle(s => s.Code == "SHE"); + } + + [Fact] + public async Task ToGold_PrefersInactiveSamHolding_OverActiveCommonLand() + { + var inactiveSamHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Cattle Farm", + HoldingStatus = "Inactive", + LastUpdatedDate = DateTime.UtcNow, + LocationName = "SAM Farm Location", + SpeciesTypeCode = "CAT" + }; + + var activeCommonLand = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow.AddDays(1), + LocationName = "Common Land Location", + SpeciesTypeCode = null + }; + + var siteIdentifierType = new SiteIdentifierTypeDocument + { + IdentifierId = "type-id", + Code = "CPHN", + Name = "CPH Number", + LastModifiedDate = DateTime.UtcNow + }; + + var result = await SamHoldingMapper.ToGold( + "gold-site-id", + null, + [inactiveSamHolding, activeCommonLand], + [], + [], + (_, _) => Task.FromResult(null), + (_, _) => Task.FromResult(null), + (code, _) => Task.FromResult( + code == "CPHN" ? siteIdentifierType : null), + (code, _) => Task.FromResult<(string?, string?)>(code == "CAT" ? ("species-id", "Cattle") : (null, null)), + (_, _) => Task.FromResult(null), + Mock.Of(), + CancellationToken.None); + + result.Should().NotBeNull(); + // Verify that SAM Holding was used (Priority 2 beats Priority 3) + result!.Species.Should().NotBeNull(); + result.Species.Should().ContainSingle(s => s.Code == "CAT"); + } + + [Fact] + public async Task ToGold_SelectsCommonLand_WhenOnlyCommonLandPresent() + { + var activeCommonLand = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Common Land", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow, + LocationName = "Common Land Location", + LocalAuthorityName = "Devon County Council", + CreatedDate = DateTime.UtcNow + }; + + var siteIdentifierType = new SiteIdentifierTypeDocument + { + IdentifierId = "type-id", + Code = "CPHN", + Name = "CPH Number", + LastModifiedDate = DateTime.UtcNow + }; + + var result = await SamHoldingMapper.ToGold( + "gold-site-id", + null, + [activeCommonLand], + [], + [], + (_, _) => Task.FromResult(null), + (_, _) => Task.FromResult(null), + (code, _) => Task.FromResult( + code == "CPHN" ? siteIdentifierType : null), + (_, _) => Task.FromResult<(string?, string?)>((null, null)), + (_, _) => Task.FromResult(null), + Mock.Of(), + CancellationToken.None); + + result.Should().NotBeNull(); + // Verify Common Land was selected as representative (fallback when no SAM Holding) + result!.Name.Should().Be("Common Land Location"); + } + + [Fact] + public async Task ToGold_SelectsMostRecentActiveSamHolding_WhenMultipleActiveSamHoldings() + { + var olderSamHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Pig Farm", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow.AddDays(-5), + LocationName = "Old Location", + SpeciesTypeCode = "PIG" + }; + + var newerSamHolding = new SamHoldingDocument + { + CountyParishHoldingNumber = "CPH-1", + SourceFacilitySubBusinessActivityCode = "Sheep Farm", + HoldingStatus = "Active", + LastUpdatedDate = DateTime.UtcNow, + LocationName = "New Location", + SpeciesTypeCode = "SHE" + }; + + var siteIdentifierType = new SiteIdentifierTypeDocument + { + IdentifierId = "type-id", + Code = "CPHN", + Name = "CPH Number", + LastModifiedDate = DateTime.UtcNow + }; + + var result = await SamHoldingMapper.ToGold( + "gold-site-id", + null, + [olderSamHolding, newerSamHolding], + [], + [], + (_, _) => Task.FromResult(null), + (_, _) => Task.FromResult(null), + (code, _) => Task.FromResult( + code == "CPHN" ? siteIdentifierType : null), + (code, _) => Task.FromResult<(string?, string?)>( + code == "SHE" ? ("sheep-id", "Sheep") : + code == "PIG" ? ("pig-id", "Pig") : (null, null)), + (_, _) => Task.FromResult(null), + Mock.Of(), + CancellationToken.None); + + result.Should().NotBeNull(); + // Should have both species (aggregated from all), but verify the newer one is present + result!.Species.Should().NotBeNull(); + result.Species.Should().Contain(s => s.Code == "SHE"); + result.Species.Should().Contain(s => s.Code == "PIG"); + } } \ No newline at end of file