backend phase 4: geography, addresses & nurse service areas
Adds the province -> city -> district reference hierarchy (geo schema, seeded with 31 provinces + capital cities + Tehran's 22 districts), nurse service areas (district_id NULL = whole city, filtered-index-pair uniqueness -> 409), and encrypted, geocoded customer addresses with a single-primary invariant. Introduces the IGeocoder seam (mocked) and 409 Conflict on the result envelope. Public cascading lookups are cached behind a generation-token scheme with invalidate-on-admin-write. One EF migration (GeographyAddressesServiceAreas, applied). Contract + swagger snapshot + handoff/report/registry updated. 103 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic, network-free <see cref="IGeocoder"/> — the mock seam. It resolves an address to a
|
||||
/// stable point jittered around the known city centroid (an unknown city falls back to Iran's centroid),
|
||||
/// so the same address always yields the same coordinates without any external call. A configured global
|
||||
/// switch or a per-address marker forces the null-coordinate path so the "saved without a map pin" state
|
||||
/// is testable. The real implementation swaps to a Neshan/Google geocoding client behind this contract.
|
||||
/// </summary>
|
||||
public sealed class MockGeocoder(IOptions<SeamOptions> options) : IGeocoder
|
||||
{
|
||||
private readonly GeocodingOptions _options = options.Value.Geocoding;
|
||||
|
||||
// A few real city centroids for plausible pins; everything else falls back to Iran's centroid.
|
||||
private static readonly IReadOnlyDictionary<string, (decimal Lat, decimal Lng)> Centroids =
|
||||
new Dictionary<string, (decimal, decimal)>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tehran"] = (35.6892m, 51.3890m),
|
||||
["Karaj"] = (35.8400m, 50.9391m),
|
||||
["Mashhad"] = (36.2605m, 59.6168m),
|
||||
["Isfahan"] = (32.6539m, 51.6660m),
|
||||
["Shiraz"] = (29.5918m, 52.5837m),
|
||||
["Tabriz"] = (38.0800m, 46.2919m),
|
||||
["Ahvaz"] = (31.3183m, 48.6706m),
|
||||
["Qom"] = (34.6416m, 50.8746m),
|
||||
};
|
||||
|
||||
private static readonly (decimal Lat, decimal Lng) IranCentroid = (32.4279m, 53.6880m);
|
||||
|
||||
public ValueTask<GeocodeResult> GeocodeAsync(
|
||||
string addressText,
|
||||
string cityName,
|
||||
string? districtName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var formatted = FormatAddress(addressText, cityName, districtName);
|
||||
|
||||
var unresolved =
|
||||
_options.ReturnNullCoordinates ||
|
||||
(!string.IsNullOrEmpty(_options.LowConfidenceMarker) &&
|
||||
addressText is not null &&
|
||||
addressText.Contains(_options.LowConfidenceMarker, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (unresolved)
|
||||
return ValueTask.FromResult(new GeocodeResult(null, null, formatted, 0.2));
|
||||
|
||||
var centroid = Centroids.TryGetValue(cityName ?? string.Empty, out var c) ? c : IranCentroid;
|
||||
|
||||
// Deterministic ±~0.045° jitter (~5 km) derived from a stable FNV-1a hash of the full text —
|
||||
// never string.GetHashCode(), which is randomized per process and would break test determinism.
|
||||
var seed = StableHash($"{cityName}|{districtName}|{addressText}");
|
||||
var latOffset = Offset(seed);
|
||||
var lngOffset = Offset(seed >> 16 ^ seed);
|
||||
|
||||
var lat = decimal.Round(centroid.Lat + latOffset, 6);
|
||||
var lng = decimal.Round(centroid.Lng + lngOffset, 6);
|
||||
|
||||
return ValueTask.FromResult(new GeocodeResult(lat, lng, formatted, _options.ResolvedConfidence));
|
||||
}
|
||||
|
||||
private static string FormatAddress(string addressText, string cityName, string? districtName) =>
|
||||
string.Join("، ", new[] { cityName, districtName, addressText }
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
|
||||
// Maps the low 16 bits of the seed to a signed offset in [-0.045, +0.045] degrees.
|
||||
private static decimal Offset(uint seed) => ((seed & 0xFFFF) / 65535m - 0.5m) * 0.09m;
|
||||
|
||||
private static uint StableHash(string value)
|
||||
{
|
||||
const uint offsetBasis = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
var hash = offsetBasis;
|
||||
foreach (var b in Encoding.UTF8.GetBytes(value))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,25 @@ public sealed class SeamOptions
|
||||
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
|
||||
public ObjectStorageOptions ObjectStorage { get; set; } = new();
|
||||
public BankOwnershipOptions BankOwnership { get; set; } = new();
|
||||
public GeocodingOptions Geocoding { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IGeocoder</c>. By default it resolves every address to a deterministic point around
|
||||
/// the city centroid. Set <see cref="ReturnNullCoordinates"/> to force the unresolved path globally, or
|
||||
/// embed <see cref="LowConfidenceMarker"/> in a single address to exercise the "saved without a map pin"
|
||||
/// UI state per-request. The real vendor implementation ignores these.
|
||||
/// </summary>
|
||||
public sealed class GeocodingOptions
|
||||
{
|
||||
/// <summary>When true, every geocode returns null coordinates with low confidence.</summary>
|
||||
public bool ReturnNullCoordinates { get; set; }
|
||||
|
||||
/// <summary>An address whose text contains this marker resolves to null coordinates (testability).</summary>
|
||||
public string LowConfidenceMarker { get; set; } = "NO_GEO";
|
||||
|
||||
/// <summary>Confidence returned for a successfully resolved address.</summary>
|
||||
public double ResolvedConfidence { get; set; } = 0.9;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+4
@@ -32,6 +32,10 @@ public static class ServiceCollectionExtension
|
||||
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
|
||||
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
|
||||
|
||||
// Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city
|
||||
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
|
||||
services.AddSingleton<IGeocoder, MockGeocoder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user