Add comprehensive CV verification checks and dashboard auto-refresh
- Add dashboard auto-refresh polling to update when processing completes - Skip verification for freelance employers (but not contractors) - Add incorporation date check (flags employment before company existed) - Add dissolution date check (flags employment at dissolved companies) - Add dormant company check (flags non-director roles at dormant companies) - Add company size vs role check (flags senior roles at micro-entities) - Add SIC code mismatch check (flags tech roles at non-tech companies) - Add director verification against Companies House officers - Add rapid career progression detection (3+ seniority jumps in <2 years) - Add early career senior role detection (<2 years after education) - Extend CompanyVerificationResult with flags and company data - Add officers endpoint to Companies House client - Fix null reference warning in Report.razor Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using FuzzySharp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -20,6 +21,15 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
private const int FuzzyMatchThreshold = 70;
|
||||
private const int CacheExpirationDays = 30;
|
||||
|
||||
// SIC codes for tech/software companies
|
||||
private static readonly HashSet<string> TechSicCodes = new()
|
||||
{
|
||||
"62011", "62012", "62020", "62030", "62090", // Computer programming and consultancy
|
||||
"63110", "63120", // Data processing, hosting
|
||||
"58210", "58290", // Publishing of computer games, other software
|
||||
"61100", "61200", "61300", "61900" // Telecommunications
|
||||
};
|
||||
|
||||
public CompanyVerifierService(
|
||||
CompaniesHouseClient companiesHouseClient,
|
||||
IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
||||
@@ -33,18 +43,20 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
public async Task<CompanyVerificationResult> VerifyCompanyAsync(
|
||||
string companyName,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate)
|
||||
DateOnly? endDate,
|
||||
string? jobTitle = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(companyName);
|
||||
|
||||
_logger.LogDebug("Verifying company: {CompanyName}", companyName);
|
||||
var flags = new List<CompanyVerificationFlag>();
|
||||
|
||||
// Try to find a cached match first
|
||||
var cachedMatch = await FindCachedMatchAsync(companyName);
|
||||
if (cachedMatch is not null)
|
||||
{
|
||||
_logger.LogDebug("Found cached company match for: {CompanyName}", companyName);
|
||||
return CreateVerificationResult(companyName, cachedMatch, startDate, endDate);
|
||||
return CreateResultFromCache(cachedMatch, companyName, startDate, endDate, jobTitle, flags);
|
||||
}
|
||||
|
||||
// Search Companies House
|
||||
@@ -55,7 +67,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
if (searchResponse?.Items is null || searchResponse.Items.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No companies found for: {CompanyName}", companyName);
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate, "No matching company found in Companies House");
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle, "No matching company found in Companies House");
|
||||
}
|
||||
|
||||
// Find best fuzzy match
|
||||
@@ -64,18 +76,58 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
if (bestMatch is null)
|
||||
{
|
||||
_logger.LogDebug("No fuzzy match above threshold for: {CompanyName}", companyName);
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate,
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle,
|
||||
$"No company name matched above {FuzzyMatchThreshold}% threshold");
|
||||
}
|
||||
|
||||
// Cache the matched company
|
||||
var match = bestMatch.Value;
|
||||
await CacheCompanyAsync(match.Item);
|
||||
|
||||
// Fetch full company details for additional data
|
||||
var companyDetails = await _companiesHouseClient.GetCompanyAsync(match.Item.CompanyNumber);
|
||||
|
||||
// Cache the matched company with full details
|
||||
await CacheCompanyAsync(match.Item, companyDetails);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verified company {ClaimedName} matched to {MatchedName} with score {Score}%",
|
||||
companyName, match.Item.Title, match.Score);
|
||||
|
||||
// Run all verification checks
|
||||
var incorporationDate = DateHelpers.ParseDate(match.Item.DateOfCreation);
|
||||
var dissolutionDate = DateHelpers.ParseDate(match.Item.DateOfCessation);
|
||||
var companyStatus = match.Item.CompanyStatus;
|
||||
var companyType = match.Item.CompanyType;
|
||||
var sicCodes = companyDetails?.SicCodes ?? match.Item.SicCodes;
|
||||
var accountsCategory = companyDetails?.Accounts?.LastAccounts?.Type;
|
||||
|
||||
// Check 1: Employment before company incorporation
|
||||
CheckIncorporationDate(flags, startDate, incorporationDate, match.Item.Title);
|
||||
|
||||
// Check 2: Employment at dissolved company
|
||||
CheckDissolutionDate(flags, endDate, dissolutionDate, companyStatus, match.Item.Title);
|
||||
|
||||
// Check 3: Dormant company check
|
||||
CheckDormantCompany(flags, accountsCategory, jobTitle, match.Item.Title);
|
||||
|
||||
// Check 4: Company size vs job title
|
||||
CheckCompanySizeVsRole(flags, accountsCategory, jobTitle, match.Item.Title);
|
||||
|
||||
// Check 5: SIC code vs job title mismatch
|
||||
CheckSicCodeMismatch(flags, sicCodes, jobTitle, match.Item.Title);
|
||||
|
||||
// Check 6: Job title plausibility for PLCs
|
||||
var (jobPlausible, jobNotes) = CheckJobTitlePlausibility(jobTitle, companyType);
|
||||
if (jobPlausible == false)
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "ImplausibleJobTitle",
|
||||
Severity = "Critical",
|
||||
Message = jobNotes ?? "Job title requires verification",
|
||||
ScoreImpact = -15
|
||||
});
|
||||
}
|
||||
|
||||
return new CompanyVerificationResult
|
||||
{
|
||||
ClaimedCompany = companyName,
|
||||
@@ -85,13 +137,23 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
IsVerified = true,
|
||||
VerificationNotes = $"Matched with {match.Score}% confidence",
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate
|
||||
ClaimedEndDate = endDate,
|
||||
CompanyType = companyType,
|
||||
CompanyStatus = companyStatus,
|
||||
IncorporationDate = incorporationDate,
|
||||
DissolutionDate = dissolutionDate,
|
||||
AccountsCategory = accountsCategory,
|
||||
SicCodes = sicCodes,
|
||||
ClaimedJobTitle = jobTitle,
|
||||
JobTitlePlausible = jobPlausible,
|
||||
JobTitleNotes = jobNotes,
|
||||
Flags = flags
|
||||
};
|
||||
}
|
||||
catch (CompaniesHouseRateLimitException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Rate limit hit while verifying company: {CompanyName}", companyName);
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate,
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle,
|
||||
"Verification temporarily unavailable due to rate limiting");
|
||||
}
|
||||
}
|
||||
@@ -119,14 +181,364 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool?> VerifyDirectorAsync(
|
||||
string companyNumber,
|
||||
string candidateName,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyNumber) || string.IsNullOrWhiteSpace(candidateName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var officers = await _companiesHouseClient.GetOfficersAsync(companyNumber);
|
||||
|
||||
if (officers?.Items is null || officers.Items.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No officers found for company {CompanyNumber}", companyNumber);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize candidate name for comparison
|
||||
var normalizedCandidate = NormalizeName(candidateName);
|
||||
|
||||
foreach (var officer in officers.Items)
|
||||
{
|
||||
// Check if officer role is director-like
|
||||
var role = officer.OfficerRole?.ToLowerInvariant() ?? "";
|
||||
if (!role.Contains("director") && !role.Contains("secretary"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuzzy match the name
|
||||
var normalizedOfficer = NormalizeName(officer.Name);
|
||||
var matchScore = Fuzz.Ratio(normalizedCandidate, normalizedOfficer);
|
||||
|
||||
if (matchScore >= 80) // High threshold for name matching
|
||||
{
|
||||
// Check date overlap
|
||||
var appointedOn = DateHelpers.ParseDate(officer.AppointedOn);
|
||||
var resignedOn = DateHelpers.ParseDate(officer.ResignedOn);
|
||||
|
||||
// If no claimed dates, just check if names match
|
||||
if (!startDate.HasValue && !endDate.HasValue)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Found matching director {OfficerName} for candidate {CandidateName} at company {CompanyNumber}",
|
||||
officer.Name, candidateName, companyNumber);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if employment period overlaps with directorship
|
||||
var datesOverlap = DatesOverlap(
|
||||
startDate, endDate,
|
||||
appointedOn, resignedOn);
|
||||
|
||||
if (datesOverlap)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Verified director {OfficerName} matches candidate {CandidateName} with overlapping dates",
|
||||
officer.Name, candidateName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"No matching director found for candidate {CandidateName} at company {CompanyNumber}",
|
||||
candidateName, companyNumber);
|
||||
return false;
|
||||
}
|
||||
catch (CompaniesHouseRateLimitException)
|
||||
{
|
||||
_logger.LogWarning("Rate limit hit while verifying director for company {CompanyNumber}", companyNumber);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error verifying director for company {CompanyNumber}", companyNumber);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "";
|
||||
|
||||
// Companies House often stores names as "SURNAME, Firstname"
|
||||
// Convert to "Firstname Surname" format for comparison
|
||||
var normalized = name.ToUpperInvariant().Trim();
|
||||
|
||||
if (normalized.Contains(','))
|
||||
{
|
||||
var parts = normalized.Split(',', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
normalized = $"{parts[1].Trim()} {parts[0].Trim()}";
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool DatesOverlap(DateOnly? start1, DateOnly? end1, DateOnly? start2, DateOnly? end2)
|
||||
{
|
||||
// If no dates, assume overlap
|
||||
if (!start1.HasValue && !end1.HasValue) return true;
|
||||
if (!start2.HasValue && !end2.HasValue) return true;
|
||||
|
||||
// Use default dates for missing values
|
||||
var s1 = start1 ?? DateOnly.MinValue;
|
||||
var e1 = end1 ?? DateOnly.MaxValue;
|
||||
var s2 = start2 ?? DateOnly.MinValue;
|
||||
var e2 = end2 ?? DateOnly.MaxValue;
|
||||
|
||||
// Check overlap: periods overlap if one starts before the other ends
|
||||
return s1 <= e2 && s2 <= e1;
|
||||
}
|
||||
|
||||
#region Verification Checks
|
||||
|
||||
private static void CheckIncorporationDate(
|
||||
List<CompanyVerificationFlag> flags,
|
||||
DateOnly? claimedStartDate,
|
||||
DateOnly? incorporationDate,
|
||||
string companyName)
|
||||
{
|
||||
if (claimedStartDate.HasValue && incorporationDate.HasValue)
|
||||
{
|
||||
if (claimedStartDate.Value < incorporationDate.Value)
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "EmploymentBeforeIncorporation",
|
||||
Severity = "Critical",
|
||||
Message = $"Claimed employment at '{companyName}' starting {claimedStartDate:MMM yyyy} is before company incorporation date {incorporationDate:MMM yyyy}",
|
||||
ScoreImpact = -20
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckDissolutionDate(
|
||||
List<CompanyVerificationFlag> flags,
|
||||
DateOnly? claimedEndDate,
|
||||
DateOnly? dissolutionDate,
|
||||
string? companyStatus,
|
||||
string companyName)
|
||||
{
|
||||
var isDissolvedStatus = companyStatus?.ToLowerInvariant() is "dissolved" or "liquidation" or "administration";
|
||||
|
||||
if (dissolutionDate.HasValue && isDissolvedStatus)
|
||||
{
|
||||
// Allow 3 month buffer for wind-down
|
||||
var bufferDate = dissolutionDate.Value.AddMonths(3);
|
||||
|
||||
if (claimedEndDate.HasValue && claimedEndDate.Value > bufferDate)
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "EmploymentAtDissolvedCompany",
|
||||
Severity = "Critical",
|
||||
Message = $"Claimed employment at '{companyName}' until {claimedEndDate:MMM yyyy} but company was dissolved on {dissolutionDate:MMM yyyy}",
|
||||
ScoreImpact = -20
|
||||
});
|
||||
}
|
||||
else if (!claimedEndDate.HasValue) // Current employment at dissolved company
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "CurrentEmploymentAtDissolvedCompany",
|
||||
Severity = "Critical",
|
||||
Message = $"Claims current employment at '{companyName}' but company was dissolved on {dissolutionDate:MMM yyyy}",
|
||||
ScoreImpact = -25
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckDormantCompany(
|
||||
List<CompanyVerificationFlag> flags,
|
||||
string? accountsCategory,
|
||||
string? jobTitle,
|
||||
string companyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountsCategory)) return;
|
||||
|
||||
var isDormant = accountsCategory.ToLowerInvariant().Contains("dormant");
|
||||
if (!isDormant) return;
|
||||
|
||||
// Directors can maintain dormant companies, but other roles are suspicious
|
||||
var title = jobTitle?.ToLowerInvariant() ?? "";
|
||||
var isDirectorRole = title.Contains("director") || title.Contains("company secretary");
|
||||
|
||||
if (!isDirectorRole)
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "EmploymentAtDormantCompany",
|
||||
Severity = "Warning",
|
||||
Message = $"Claimed active employment as '{jobTitle}' at '{companyName}' which files dormant accounts",
|
||||
ScoreImpact = -10
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckCompanySizeVsRole(
|
||||
List<CompanyVerificationFlag> flags,
|
||||
string? accountsCategory,
|
||||
string? jobTitle,
|
||||
string companyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountsCategory) || string.IsNullOrWhiteSpace(jobTitle)) return;
|
||||
|
||||
var category = accountsCategory.ToLowerInvariant();
|
||||
var title = jobTitle.ToLowerInvariant();
|
||||
|
||||
// Micro-entity: < 10 employees, < £632k turnover
|
||||
var isMicroEntity = category.Contains("micro");
|
||||
|
||||
// Check for senior management roles at micro companies
|
||||
var isSeniorRole = title.Contains("vp") ||
|
||||
title.Contains("vice president") ||
|
||||
title.Contains("head of") ||
|
||||
title.Contains("chief") ||
|
||||
title.Contains("director of") ||
|
||||
title.Contains("senior director");
|
||||
|
||||
// At micro companies, having many senior roles is suspicious
|
||||
if (isMicroEntity && isSeniorRole)
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "SeniorRoleAtMicroCompany",
|
||||
Severity = "Warning",
|
||||
Message = $"Claimed senior role '{jobTitle}' at '{companyName}' which files micro-entity accounts (typically <10 employees)",
|
||||
ScoreImpact = -10
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckSicCodeMismatch(
|
||||
List<CompanyVerificationFlag> flags,
|
||||
List<string>? sicCodes,
|
||||
string? jobTitle,
|
||||
string companyName)
|
||||
{
|
||||
if (sicCodes is null || sicCodes.Count == 0 || string.IsNullOrWhiteSpace(jobTitle)) return;
|
||||
|
||||
var title = jobTitle.ToLowerInvariant();
|
||||
|
||||
// Check if this is a tech role
|
||||
var isTechRole = title.Contains("software") ||
|
||||
title.Contains("developer") ||
|
||||
title.Contains("engineer") ||
|
||||
title.Contains("programmer") ||
|
||||
title.Contains("data scientist") ||
|
||||
title.Contains("data analyst") ||
|
||||
title.Contains("devops") ||
|
||||
title.Contains("cloud") ||
|
||||
title.Contains("machine learning") ||
|
||||
title.Contains("ai ") ||
|
||||
title.Contains("frontend") ||
|
||||
title.Contains("backend") ||
|
||||
title.Contains("full stack") ||
|
||||
title.Contains("fullstack");
|
||||
|
||||
if (isTechRole)
|
||||
{
|
||||
// Check if company has any tech SIC codes
|
||||
var hasTechSic = sicCodes.Any(s => TechSicCodes.Contains(s));
|
||||
|
||||
if (!hasTechSic)
|
||||
{
|
||||
// Get the primary SIC code description (simplified - just show code)
|
||||
var primarySic = sicCodes.FirstOrDefault() ?? "Unknown";
|
||||
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "SicCodeMismatch",
|
||||
Severity = "Info",
|
||||
Message = $"Tech role '{jobTitle}' at '{companyName}' (SIC: {primarySic}) - company is not registered as a technology business",
|
||||
ScoreImpact = -5
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (bool? IsPlausible, string? Notes) CheckJobTitlePlausibility(string? jobTitle, string? companyType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobTitle) || string.IsNullOrWhiteSpace(companyType))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var title = jobTitle.Trim().ToLowerInvariant();
|
||||
var type = companyType.Trim().ToLowerInvariant();
|
||||
|
||||
// Check if this is a PLC (Public Limited Company) - these are large companies
|
||||
var isPlc = type.Contains("plc") || type.Contains("public limited");
|
||||
|
||||
// Check for C-suite / very senior roles
|
||||
var isCsuiteRole = title.Contains("ceo") ||
|
||||
title.Contains("chief executive") ||
|
||||
title.Contains("cto") ||
|
||||
title.Contains("chief technology") ||
|
||||
title.Contains("cfo") ||
|
||||
title.Contains("chief financial") ||
|
||||
title.Contains("coo") ||
|
||||
title.Contains("chief operating") ||
|
||||
title.Contains("cio") ||
|
||||
title.Contains("chief information") ||
|
||||
title.Contains("managing director") ||
|
||||
title == "md" ||
|
||||
title.Contains("chairman") ||
|
||||
title.Contains("chairwoman") ||
|
||||
title.Contains("chairperson") ||
|
||||
title.Contains("president");
|
||||
|
||||
// Check for board-level roles
|
||||
var isBoardRole = title.Contains("board member") ||
|
||||
title.Contains("non-executive director") ||
|
||||
title.Contains("executive director") ||
|
||||
(title == "director" && !title.Contains("of"));
|
||||
|
||||
if (isPlc && (isCsuiteRole || isBoardRole))
|
||||
{
|
||||
return (false, $"Claimed senior role '{jobTitle}' at a PLC requires verification - C-suite positions at public companies are publicly disclosed");
|
||||
}
|
||||
|
||||
// Check for VP/SVP at PLCs (also usually disclosed)
|
||||
var isVpRole = title.Contains("vice president") ||
|
||||
title.Contains("vp ") ||
|
||||
title.StartsWith("vp") ||
|
||||
title.Contains("svp") ||
|
||||
title.Contains("senior vice president") ||
|
||||
title.Contains("evp") ||
|
||||
title.Contains("executive vice president");
|
||||
|
||||
if (isPlc && isVpRole)
|
||||
{
|
||||
return (false, $"Claimed VP-level role '{jobTitle}' at a PLC - senior positions at public companies should be verifiable");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<CompanyCache?> FindCachedMatchAsync(string companyName)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays);
|
||||
|
||||
// Use factory to create a new DbContext for thread-safe parallel access
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Get recent cached companies
|
||||
var cachedCompanies = await dbContext.CompanyCache
|
||||
.Where(c => c.CachedAt >= cutoffDate)
|
||||
.ToListAsync();
|
||||
@@ -136,7 +548,6 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find best fuzzy match in cache
|
||||
var matches = cachedCompanies
|
||||
.Select(c => new { Company = c, Score = Fuzz.Ratio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
|
||||
.Where(m => m.Score >= FuzzyMatchThreshold)
|
||||
@@ -161,20 +572,26 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
return matches.Count > 0 ? matches[0] : null;
|
||||
}
|
||||
|
||||
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item)
|
||||
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item, CompaniesHouseCompany? details)
|
||||
{
|
||||
// Use factory to create a new DbContext for thread-safe parallel access
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var existingCache = await dbContext.CompanyCache
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
|
||||
|
||||
var sicCodes = details?.SicCodes ?? item.SicCodes;
|
||||
var sicCodesJson = sicCodes != null ? JsonSerializer.Serialize(sicCodes) : null;
|
||||
var accountsCategory = details?.Accounts?.LastAccounts?.Type;
|
||||
|
||||
if (existingCache is not null)
|
||||
{
|
||||
existingCache.CompanyName = item.Title;
|
||||
existingCache.Status = item.CompanyStatus ?? "Unknown";
|
||||
existingCache.CompanyType = item.CompanyType;
|
||||
existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation);
|
||||
existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation);
|
||||
existingCache.AccountsCategory = accountsCategory;
|
||||
existingCache.SicCodesJson = sicCodesJson;
|
||||
existingCache.CachedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
@@ -184,8 +601,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
CompanyNumber = item.CompanyNumber,
|
||||
CompanyName = item.Title,
|
||||
Status = item.CompanyStatus ?? "Unknown",
|
||||
CompanyType = item.CompanyType,
|
||||
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
|
||||
DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation),
|
||||
AccountsCategory = accountsCategory,
|
||||
SicCodesJson = sicCodesJson,
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -195,16 +615,41 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CompanyVerificationResult CreateVerificationResult(
|
||||
string claimedCompany,
|
||||
private CompanyVerificationResult CreateResultFromCache(
|
||||
CompanyCache cached,
|
||||
string claimedCompany,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate)
|
||||
DateOnly? endDate,
|
||||
string? jobTitle,
|
||||
List<CompanyVerificationFlag> flags)
|
||||
{
|
||||
var matchScore = Fuzz.Ratio(
|
||||
claimedCompany.ToUpperInvariant(),
|
||||
cached.CompanyName.ToUpperInvariant());
|
||||
|
||||
var sicCodes = !string.IsNullOrEmpty(cached.SicCodesJson)
|
||||
? JsonSerializer.Deserialize<List<string>>(cached.SicCodesJson)
|
||||
: null;
|
||||
|
||||
// Run all verification checks
|
||||
CheckIncorporationDate(flags, startDate, cached.IncorporationDate, cached.CompanyName);
|
||||
CheckDissolutionDate(flags, endDate, cached.DissolutionDate, cached.Status, cached.CompanyName);
|
||||
CheckDormantCompany(flags, cached.AccountsCategory, jobTitle, cached.CompanyName);
|
||||
CheckCompanySizeVsRole(flags, cached.AccountsCategory, jobTitle, cached.CompanyName);
|
||||
CheckSicCodeMismatch(flags, sicCodes, jobTitle, cached.CompanyName);
|
||||
|
||||
var (jobPlausible, jobNotes) = CheckJobTitlePlausibility(jobTitle, cached.CompanyType);
|
||||
if (jobPlausible == false)
|
||||
{
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "ImplausibleJobTitle",
|
||||
Severity = "Critical",
|
||||
Message = jobNotes ?? "Job title requires verification",
|
||||
ScoreImpact = -15
|
||||
});
|
||||
}
|
||||
|
||||
return new CompanyVerificationResult
|
||||
{
|
||||
ClaimedCompany = claimedCompany,
|
||||
@@ -214,7 +659,17 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
IsVerified = true,
|
||||
VerificationNotes = $"Matched from cache with {matchScore}% confidence",
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate
|
||||
ClaimedEndDate = endDate,
|
||||
CompanyType = cached.CompanyType,
|
||||
CompanyStatus = cached.Status,
|
||||
IncorporationDate = cached.IncorporationDate,
|
||||
DissolutionDate = cached.DissolutionDate,
|
||||
AccountsCategory = cached.AccountsCategory,
|
||||
SicCodes = sicCodes,
|
||||
ClaimedJobTitle = jobTitle,
|
||||
JobTitlePlausible = jobPlausible,
|
||||
JobTitleNotes = jobNotes,
|
||||
Flags = flags
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,6 +677,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
string companyName,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate,
|
||||
string? jobTitle,
|
||||
string reason)
|
||||
{
|
||||
return new CompanyVerificationResult
|
||||
@@ -233,8 +689,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
IsVerified = false,
|
||||
VerificationNotes = reason,
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate
|
||||
ClaimedEndDate = endDate,
|
||||
ClaimedJobTitle = jobTitle
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user