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:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -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>
@@ -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;
}
}