Skip to content

Commit 9b95df7

Browse files
Feature/ultip 5215 (#232)
* Add support for "Common Lands" processing Introduced functionality to handle "Common Lands" data, including: - Added `SamCommonLandsEnabled` flag in `appsettings.json`. - Updated `SamDailyScanContext` and `SamHoldingImportContext` to include "Common Lands". - Enhanced `SamHoldingImportAggregationStep` to fetch and process "Common Lands". - Added mapping logic for "Common Lands" in Silver and Gold models. - Introduced new API routes and queries for "Common Lands". - Extended `IDataBridgeClient` to support "Common Lands" API calls. - Updated `SiteDto`, `SamHoldingDocument`, and `SiteDocument` to include "Common Lands" fields. - Added anonymization support for "Common Lands" in `PiiAnonymizerHelper`. - Enhanced `FakeDataBridgeClient` to simulate "Common Lands" data. - Added `SamCommonLandDailyScanStep` for daily scanning of "Common Lands". - Introduced `SamCommonLandMapper` for mapping "Common Lands" data. - Added new classes for "Common Lands" relationships and identifiers. These changes enable the application to retrieve, process, and map "Common Lands" data while ensuring proper anonymization and testing support. * Add support for SamCommonLand data handling Introduced nullability support in `ToAssociatedCommonLands` to handle null or empty lists. Added `PermanentLandHoldingIdentifier` to `SiteDocumentExtensions` for enhanced data mapping. Updated `SamHoldingImportOrchestratorTests` and `SamBulkImportWithAccurateRawDataTests` to include `commonLands` in test setups, mocking, and verification. Modified `GetAllQueryUris` to support `commonLandsUri`. Added mock setup for `GetByCodeAsync` to return "Common Land" site type. Enhanced `SamTestScenarios` with `RawCommonLandsByCommonCph` for test data. Added `SamCommonLandMapperTests` to validate mapping logic and `SamCommonLandTests` to ensure proper behavior of `SamCommonLand`. These changes ensure robust handling, mapping, and testing of `SamCommonLand` data. * Replace null with string.Empty for MAIN_CPH in tests Updated test cases in `SamCommonLandMapperTests.cs` and `SamCommonLandTests.cs` to replace `null` values for the `MAIN_CPH` property with `string.Empty`. This aligns with the updated behavior where `MAIN_CPH` no longer supports `null` values. Removed a test case in `SamCommonLandTests.cs` that validated `null` `MAIN_CPH`, as it is no longer relevant. Other test cases remain to validate empty and whitespace values for `MAIN_CPH`. * Clean up redundant braces and improve test coverage * Ensure culture-invariant date parsing and formatting Updated the `NormaliseDate` method to use `CultureInfo.InvariantCulture` for both `DateTime.TryParse` and `ToString` to ensure consistent, culture-independent behavior. Added `System.Globalization` namespace to support these changes. * Add tests for Sam Common Lands functionality Enhanced `DataBridgeClientTests` with new unit tests to validate the behavior of `DataBridgeClient` methods for fetching "Sam Common Lands" data. Added tests for both enabled and disabled feature flag scenarios, covering paged responses and filtering by `COMMON_CPH`. Introduced helper methods in `MockSamData` to generate mock responses for "Sam Common Lands" data, ensuring realistic and consistent test data. These changes improve test coverage and verify functionality under various configurations. * Add unit tests for PII anonymization in SamCommonLand Added a new test class `PiiAnonymizerHelperTests` to validate the `PiiAnonymizerHelper` class. Introduced tests to ensure PII fields in `SamCommonLand` are anonymized correctly, including `ADDRESS_LINE_1`, `POSTCODE`, `PREMISES_NAME`, `EASTING`, and `NORTHING`. Verified deterministic behavior based on `COMMON_CPH` and ensured non-PII fields remain unchanged. Added tests for edge cases such as null or empty fields, placeholder values, and empty `DataBridgeResponse` objects. Included a comprehensive test to validate anonymization of all PII fields in a single call. * Refine filters and update object references Updated `VerifySilverDataTypesAsync` in both `SamImportHoldingAnonMessageTests` and `SamImportHoldingMessageTests` to add a filter excluding `SiteTypeCode` equal to "CL". Replaced `_localStackFixture` with `localStackFixture` in `SamImportHoldingMessageTests` for `CreateMessage` and `SendMessageAsync` calls to ensure consistent object usage. Updated `DisposeAsync` in `SamImportHoldingMessageTests` to use `mongoDbFixture` instead of `_mongoDbFixture` for purging data tables. These changes improve query precision, naming consistency, and maintainability. * Refactor tests and add SamCommonLandDailyScan tests Refactored `SamImportHoldingAnonMessageTests` and `SamImportHoldingMessageTests` to use private readonly fields for dependencies, improving encapsulation and maintainability. Updated method calls to align with the new structure. Added `SamCommonLandDailyScanStepTests` to test the functionality of `SamCommonLandDailyScanStep`. This includes mocking dependencies, configuring in-memory feature flags, and writing unit tests for various scenarios such as handling feature flags, API responses, pagination, and error handling. Introduced `FluentAssertions` for expressive assertions and improved test readability. Enhanced code consistency and maintainability through better naming conventions and encapsulation. * Replace null with empty string in COMMON_CPH property * Add tests for anonymization and edge case handling Added multiple test methods to validate anonymization logic: - `GetSamCommonLandsAsync_Generic_ShouldAnonymizeAddressAndLocationFields`: Ensures PII fields are anonymized while non-PII fields remain unchanged. - `GetSamCommonLandsByCommonCphAsync_ShouldAnonymizeAllCommonLands`: Verifies anonymization of all common lands for a given `COMMON_CPH`. - `GetSamCommonLandsByCommonCphAsync_ShouldHandleNullFields`: Confirms proper handling of null values in PII fields. - `GetSamCommonLandsByCommonCphAsync_ShouldNotAnonymizePlaceholderPremisesName`: Ensures placeholder values like `"-"` are not anonymized. - `GetSamCommonLandsAsync_ShouldProduceDeterministicResults`: Validates deterministic anonymization for the same `COMMON_CPH`. - `GetSamCommonLandsAsync_ShouldPreserveNonPiiFields`: Confirms non-PII fields are preserved while PII fields are anonymized. - `GetSamCommonLandsAsync_ShouldReturnNull_WhenInnerReturnsNull`: Ensures `null` is returned when the inner client returns `null`. - `GetSamCommonLandsByCommonCphAsync_ShouldHandleEmptyList`: Verifies proper handling of empty list responses. --------- Co-authored-by: Graham McVea <graham.mcvea@bartonkeys.com>
1 parent c4fa165 commit 9b95df7

37 files changed

Lines changed: 2698 additions & 33 deletions

File tree

src/KeeperData.Api/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
"SamHoldingsEnabled": true,
6767
"SamHoldersEnabled": true,
6868
"SamHerdsEnabled": true,
69-
"SamPartiesEnabled": true
69+
"SamPartiesEnabled": true,
70+
"SamCommonLandsEnabled": true
7071
},
7172
"DataBridgeScanConfiguration": {
7273
"QueryPageSize": 100,

src/KeeperData.Application/Orchestration/ChangeScanning/Sam/Daily/SamDailyScanContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ public class SamDailyScanContext : ScanContext
99
public EntityScanContext Holders { get; init; } = new();
1010
public EntityScanContext Herds { get; init; } = new();
1111
public EntityScanContext Parties { get; init; } = new();
12+
public EntityScanContext CommonLands { get; init; } = new();
1213
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using KeeperData.Application.Orchestration.ChangeScanning.BaseClasses;
2+
using KeeperData.Core.ApiClients.DataBridgeApi;
3+
using KeeperData.Core.ApiClients.DataBridgeApi.Configuration;
4+
using KeeperData.Core.ApiClients.DataBridgeApi.Contracts;
5+
using KeeperData.Core.Attributes;
6+
using KeeperData.Core.Messaging.Contracts.V1.Sam;
7+
using KeeperData.Core.Messaging.MessagePublishers;
8+
using KeeperData.Core.Messaging.MessagePublishers.Clients;
9+
using KeeperData.Core.Providers;
10+
using Microsoft.Extensions.Configuration;
11+
using Microsoft.Extensions.Logging;
12+
13+
namespace KeeperData.Application.Orchestration.ChangeScanning.Sam.Daily.Steps;
14+
15+
[StepOrder(5)]
16+
public class SamCommonLandDailyScanStep(
17+
IDataBridgeClient dataBridgeClient,
18+
IMessagePublisher<IntakeEventsQueueClient> intakeMessagePublisher,
19+
DataBridgeScanConfiguration dataBridgeScanConfiguration,
20+
IDelayProvider delayProvider,
21+
IConfiguration configuration,
22+
ILogger<SamCommonLandDailyScanStep> logger)
23+
: DailyScanStepBase<SamDailyScanContext, SamScanCommonLandIdentifier>(dataBridgeClient, intakeMessagePublisher, dataBridgeScanConfiguration,
24+
delayProvider, configuration, logger)
25+
{
26+
private const string SelectFields = "COMMON_CPH";
27+
private const string OrderBy = "COMMON_CPH asc";
28+
29+
protected override bool IsEntityEnabled()
30+
=> Configuration.GetValue<bool>("DataBridgeCollectionFlags:SamCommonLandsEnabled");
31+
32+
protected override EntityScanContext GetScanContext(SamDailyScanContext context)
33+
=> context.CommonLands;
34+
35+
protected override async Task<DataBridgeResponse<SamScanCommonLandIdentifier>?> QueryDataAsync(
36+
SamDailyScanContext context,
37+
CancellationToken cancellationToken)
38+
=> await DataBridgeClient.GetSamCommonLandsAsync<SamScanCommonLandIdentifier>(
39+
context.CommonLands.CurrentTop,
40+
context.CommonLands.CurrentSkip,
41+
SelectFields,
42+
context.UpdatedSinceDateTime,
43+
OrderBy,
44+
cancellationToken);
45+
46+
protected override async Task PublishMessagesAsync(
47+
DataBridgeResponse<SamScanCommonLandIdentifier> queryResponse,
48+
CancellationToken cancellationToken)
49+
{
50+
var identifiers = queryResponse.Data
51+
.Select(x => x.COMMON_CPH)
52+
.Where(x => !string.IsNullOrWhiteSpace(x))
53+
.Distinct()
54+
.ToList();
55+
56+
foreach (var id in identifiers)
57+
{
58+
var message = new SamUpdateHoldingMessage { Id = Guid.NewGuid(), Identifier = id };
59+
60+
await IntakeMessagePublisher.PublishAsync(message, cancellationToken);
61+
}
62+
}
63+
}

src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/SamHoldingImportContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public class SamHoldingImportContext
1515
public List<SamHerd> RawHerds { get; set; } = [];
1616
public List<SamCphHolder> RawHolders { get; set; } = [];
1717
public List<SamParty> RawParties { get; set; } = [];
18+
public List<SamCommonLand> RawCommonLandsByCommonCph { get; set; } = [];
1819

1920
public List<SamHoldingDocument> SilverHoldings { get; set; } = [];
2021
public List<SamPartyDocument> SilverParties { get; set; } = [];

src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportAggregationStep.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,22 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context,
1818
var getHoldingsTask = _dataBridgeClient.GetSamHoldingsAsync(context.Cph, cancellationToken);
1919
var getHoldersTask = _dataBridgeClient.GetSamHoldersByCphAsync(context.Cph, cancellationToken);
2020
var getHerdsTask = _dataBridgeClient.GetSamHerdsAsync(context.Cph, cancellationToken);
21+
var getCommonLandsByCommonCphTask = _dataBridgeClient.GetSamCommonLandsByCommonCphAsync(context.Cph, cancellationToken);
2122

2223
await Task.WhenAll(
2324
getHoldingsTask,
2425
getHoldersTask,
25-
getHerdsTask);
26+
getHerdsTask,
27+
getCommonLandsByCommonCphTask);
2628

2729
context.RawHoldings = getHoldingsTask.Result;
2830

2931
context.RawHerds = getHerdsTask.Result;
3032

3133
context.RawHolders = getHoldersTask.Result;
3234

35+
context.RawCommonLandsByCommonCph = getCommonLandsByCommonCphTask.Result;
36+
3337
var parties = await GetSamPartiesAsync(context, cancellationToken);
3438
context.RawParties = SamPartyMapper.AggregatePartyAndHolder(parties, context.RawHolders);
3539
}

src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportGoldMappingStep.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using KeeperData.Application.Orchestration.Imports.Sam.Mappings;
22
using KeeperData.Core.Attributes;
33
using KeeperData.Core.Documents;
4+
using KeeperData.Core.Documents.Silver;
45
using KeeperData.Core.Domain.Enums;
56
using KeeperData.Core.Extensions;
67
using KeeperData.Core.Repositories;
@@ -68,6 +69,8 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context,
6869
siteTypeDerivedCodeLookupService,
6970
cancellationToken);
7071

72+
EnrichWithCommonLandData(context.GoldSite, representative);
73+
7174
context.GoldSitePartyRoles = SitePartyRoleMapper.ToGold(
7275
context.GoldParties,
7376
context.GoldSiteGroupMarks,
@@ -79,4 +82,31 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context,
7982
context.GoldSite);
8083
}
8184
}
85+
86+
private static void EnrichWithCommonLandData(SiteDocument? goldSite, SamHoldingDocument representative)
87+
{
88+
if (goldSite == null) return;
89+
90+
goldSite.LocalAuthorityName = representative.LocalAuthorityName;
91+
92+
goldSite.AssociatedMainHoldings = representative.AssociatedMainHoldings
93+
.Select(r => new AssociatedHoldingDocument
94+
{
95+
HoldingIdentifier = r.HoldingIdentifier,
96+
ContiguousFlag = r.ContiguousFlag,
97+
StartDate = r.StartDate,
98+
EndDate = r.EndDate
99+
})
100+
.ToList();
101+
102+
goldSite.AssociatedCommonLands = representative.AssociatedCommonLands
103+
.Select(r => new AssociatedHoldingDocument
104+
{
105+
HoldingIdentifier = r.HoldingIdentifier,
106+
ContiguousFlag = r.ContiguousFlag,
107+
StartDate = r.StartDate,
108+
EndDate = r.EndDate
109+
})
110+
.ToList();
111+
}
82112
}

src/KeeperData.Application/Orchestration/Imports/Sam/Holdings/Steps/SamHoldingImportSilverMappingStep.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ protected override async Task ExecuteCoreAsync(SamHoldingImportContext context,
2525
countryIdentifierLookupService.FindAsync,
2626
cancellationToken);
2727

28+
var commonLandHoldings = SamCommonLandMapper.ToSilver(context.RawCommonLandsByCommonCph);
29+
if (commonLandHoldings.Count > 0)
30+
{
31+
context.SilverHoldings.AddRange(commonLandHoldings);
32+
}
33+
2834
context.SilverParties = [
2935
.. await SamPartyMapper.ToSilver(
3036
context.Cph,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using KeeperData.Core.ApiClients.DataBridgeApi.Contracts;
2+
using KeeperData.Core.Documents.Silver;
3+
using KeeperData.Core.Domain.Sites.Formatters;
4+
using System.Globalization;
5+
6+
namespace KeeperData.Application.Orchestration.Imports.Sam.Mappings;
7+
8+
public static class SamCommonLandMapper
9+
{
10+
private const string CommonLandSiteTypeCode = "CL";
11+
12+
public static List<SamHoldingDocument> ToSilver(List<SamCommonLand> rawCommonLands)
13+
{
14+
if (rawCommonLands == null || rawCommonLands.Count == 0)
15+
return [];
16+
17+
var definitionRecords = rawCommonLands
18+
.Where(r => r.IsDefinitionRecord && !string.IsNullOrWhiteSpace(r.COMMON_CPH))
19+
.GroupBy(r => r.COMMON_CPH)
20+
.ToList();
21+
22+
var relationshipRecords = rawCommonLands
23+
.Where(r => r.IsRelationshipRecord)
24+
.ToList();
25+
26+
var result = new List<SamHoldingDocument>();
27+
28+
foreach (var group in definitionRecords)
29+
{
30+
var representative = group.OrderByDescending(r => r.UpdatedAtUtc).First();
31+
var commonCph = group.Key;
32+
33+
var associatedMainHoldings = relationshipRecords
34+
.Where(r => r.COMMON_CPH == commonCph)
35+
.Select(r => new AssociatedHoldingRelationship
36+
{
37+
HoldingIdentifier = r.MAIN_CPH,
38+
ContiguousFlag = string.Equals(r.CONTIGUOUS_COMMON, "Yes", StringComparison.OrdinalIgnoreCase),
39+
StartDate = NormaliseDate(r.START_DATE),
40+
EndDate = NormaliseDate(r.END_DATE)
41+
})
42+
.ToList();
43+
44+
var holding = new SamHoldingDocument
45+
{
46+
LastUpdatedBatchId = representative.BATCH_ID,
47+
CreatedDate = representative.CreatedAtUtc ?? DateTime.UtcNow,
48+
LastUpdatedDate = representative.UpdatedAtUtc ?? DateTime.UtcNow,
49+
Deleted = representative.IsDeleted ?? false,
50+
51+
CountyParishHoldingNumber = commonCph,
52+
CphTypeIdentifier = string.Empty,
53+
LocationName = UnwrapPlaceholder(representative.PREMISES_NAME),
54+
55+
HoldingStartDate = default,
56+
HoldingEndDate = null,
57+
HoldingStatus = HoldingStatusFormatters.FormatHoldingStatus(representative.IsDeleted ?? false),
58+
59+
SiteTypeCode = CommonLandSiteTypeCode,
60+
61+
LocalAuthorityName = representative.LOCAL_AUTH_NAME,
62+
AssociatedMainHoldings = associatedMainHoldings,
63+
64+
Location = new LocationDocument
65+
{
66+
IdentifierId = Guid.NewGuid().ToString(),
67+
Easting = ParseNullableDouble(representative.EASTING),
68+
Northing = ParseNullableDouble(representative.NORTHING),
69+
Address = new AddressDocument
70+
{
71+
IdentifierId = Guid.NewGuid().ToString(),
72+
AddressLine = representative.ADDRESS_LINE_1,
73+
AddressLocality = representative.ADDRESS_LINE_2,
74+
AddressStreet = representative.ADDRESS_LINE_3,
75+
AddressPostCode = representative.POSTCODE,
76+
CountryCode = representative.COUNTRY
77+
}
78+
}
79+
};
80+
81+
result.Add(holding);
82+
}
83+
84+
return result;
85+
}
86+
87+
public static List<AssociatedHoldingRelationship> ToAssociatedCommonLands(List<SamCommonLand>? relationshipRecords)
88+
{
89+
if (relationshipRecords == null || relationshipRecords.Count == 0)
90+
return [];
91+
92+
return relationshipRecords
93+
.Where(r => r.IsRelationshipRecord && !string.IsNullOrWhiteSpace(r.COMMON_CPH))
94+
.Select(r => new AssociatedHoldingRelationship
95+
{
96+
HoldingIdentifier = r.COMMON_CPH,
97+
ContiguousFlag = string.Equals(r.CONTIGUOUS_COMMON, "Yes", StringComparison.OrdinalIgnoreCase),
98+
StartDate = NormaliseDate(r.START_DATE),
99+
EndDate = NormaliseDate(r.END_DATE)
100+
})
101+
.ToList();
102+
}
103+
104+
private static string? NormaliseDate(string? date)
105+
{
106+
if (string.IsNullOrWhiteSpace(date))
107+
return null;
108+
109+
if (DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
110+
{
111+
if (parsed.Year >= 2999)
112+
return null;
113+
114+
return parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
115+
}
116+
117+
return date;
118+
}
119+
120+
private static string? UnwrapPlaceholder(string? value)
121+
=> string.IsNullOrWhiteSpace(value) || value == "-" ? null : value;
122+
123+
private static double? ParseNullableDouble(string? value)
124+
=> double.TryParse(value, out var result) ? result : null;
125+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using KeeperData.Core.ApiClients.DataBridgeApi.Converters;
2+
using System.Text.Json.Serialization;
3+
4+
namespace KeeperData.Core.ApiClients.DataBridgeApi.Contracts;
5+
6+
public class SamCommonLand : BronzeBase
7+
{
8+
[JsonPropertyName("COMMON_LAND_PREMISE_ID")]
9+
public string? COMMON_LAND_PREMISE_ID { get; set; }
10+
11+
[JsonPropertyName("MAIN_CPH")]
12+
public string MAIN_CPH { get; set; } = string.Empty;
13+
14+
[JsonPropertyName("COMMON_CPH")]
15+
public string COMMON_CPH { get; set; } = string.Empty;
16+
17+
[JsonPropertyName("BUSINESS_USAGE")]
18+
public string? BUSINESS_USAGE { get; set; }
19+
20+
[JsonPropertyName("PREMISES_NAME")]
21+
public string? PREMISES_NAME { get; set; }
22+
23+
[JsonPropertyName("ADDRESS_LINE_1")]
24+
public string? ADDRESS_LINE_1 { get; set; }
25+
26+
[JsonPropertyName("ADDRESS_LINE_2")]
27+
public string? ADDRESS_LINE_2 { get; set; }
28+
29+
[JsonPropertyName("ADDRESS_LINE_3")]
30+
public string? ADDRESS_LINE_3 { get; set; }
31+
32+
[JsonPropertyName("LOCAL_AUTH_NAME")]
33+
public string? LOCAL_AUTH_NAME { get; set; }
34+
35+
[JsonPropertyName("COUNTRY")]
36+
public string? COUNTRY { get; set; }
37+
38+
[JsonPropertyName("POSTCODE")]
39+
public string? POSTCODE { get; set; }
40+
41+
[JsonPropertyName("EASTING")]
42+
public string? EASTING { get; set; }
43+
44+
[JsonPropertyName("NORTHING")]
45+
public string? NORTHING { get; set; }
46+
47+
[JsonPropertyName("LINK_ID")]
48+
public string? LINK_ID { get; set; }
49+
50+
[JsonPropertyName("CONTIGUOUS_COMMON")]
51+
public string? CONTIGUOUS_COMMON { get; set; }
52+
53+
[JsonPropertyName("START_DATE")]
54+
public string? START_DATE { get; set; }
55+
56+
[JsonPropertyName("END_DATE")]
57+
public string? END_DATE { get; set; }
58+
59+
public bool IsDefinitionRecord => MAIN_CPH == "-" || string.IsNullOrWhiteSpace(MAIN_CPH);
60+
public bool IsRelationshipRecord => !IsDefinitionRecord;
61+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace KeeperData.Core.ApiClients.DataBridgeApi.Contracts;
4+
5+
public class SamScanCommonLandIdentifier
6+
{
7+
[JsonPropertyName("COMMON_CPH")]
8+
public string COMMON_CPH { get; set; } = string.Empty;
9+
}

0 commit comments

Comments
 (0)