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:
@@ -5,6 +5,7 @@ namespace TrueCV.Application.Interfaces;
|
||||
|
||||
public interface ICompanyVerifierService
|
||||
{
|
||||
Task<CompanyVerificationResult> VerifyCompanyAsync(string companyName, DateOnly? startDate, DateOnly? endDate);
|
||||
Task<CompanyVerificationResult> VerifyCompanyAsync(string companyName, DateOnly? startDate, DateOnly? endDate, string? jobTitle = null);
|
||||
Task<List<CompanySearchResult>> SearchCompaniesAsync(string query);
|
||||
Task<bool?> VerifyDirectorAsync(string companyNumber, string candidateName, DateOnly? startDate, DateOnly? endDate);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,26 @@ public sealed record CompanyVerificationResult
|
||||
public string? VerificationNotes { get; init; }
|
||||
public DateOnly? ClaimedStartDate { get; init; }
|
||||
public DateOnly? ClaimedEndDate { get; init; }
|
||||
public string? CompanyType { get; init; }
|
||||
public string? ClaimedJobTitle { get; init; }
|
||||
public bool? JobTitlePlausible { get; init; }
|
||||
public string? JobTitleNotes { get; init; }
|
||||
|
||||
// Additional company data for verification checks
|
||||
public string? CompanyStatus { get; init; }
|
||||
public DateOnly? IncorporationDate { get; init; }
|
||||
public DateOnly? DissolutionDate { get; init; }
|
||||
public string? AccountsCategory { get; init; }
|
||||
public List<string>? SicCodes { get; init; }
|
||||
|
||||
// Additional verification flags
|
||||
public List<CompanyVerificationFlag> Flags { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record CompanyVerificationFlag
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public int ScoreImpact { get; init; }
|
||||
}
|
||||
|
||||
@@ -16,9 +16,18 @@ public class CompanyCache
|
||||
[MaxLength(64)]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? CompanyType { get; set; }
|
||||
|
||||
public DateOnly? IncorporationDate { get; set; }
|
||||
|
||||
public DateOnly? DissolutionDate { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? AccountsCategory { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? SicCodesJson { get; set; }
|
||||
|
||||
public DateTime CachedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -117,6 +117,50 @@ public sealed class CompaniesHouseClient
|
||||
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CompaniesHouseOfficersResponse?> GetOfficersAsync(
|
||||
string companyNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(companyNumber);
|
||||
|
||||
var requestUrl = $"/company/{companyNumber}/officers";
|
||||
|
||||
_logger.LogDebug("Fetching officers for company: {CompanyNumber}", companyNumber);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No officers found for company: {CompanyNumber}", companyNumber);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
_logger.LogWarning("Rate limit exceeded for Companies House API");
|
||||
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseOfficersResponse>(
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogDebug("Retrieved {Count} officers for company {CompanyNumber}",
|
||||
result?.Items?.Count ?? 0, companyNumber);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
_logger.LogWarning("Rate limit exceeded for Companies House API");
|
||||
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs for Companies House API responses
|
||||
@@ -138,6 +182,7 @@ public sealed record CompaniesHouseSearchItem
|
||||
public string? DateOfCessation { get; init; }
|
||||
public CompaniesHouseAddress? Address { get; init; }
|
||||
public string? AddressSnippet { get; init; }
|
||||
public List<string>? SicCodes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CompaniesHouseCompany
|
||||
@@ -149,6 +194,34 @@ public sealed record CompaniesHouseCompany
|
||||
public string? DateOfCreation { get; init; }
|
||||
public string? DateOfCessation { get; init; }
|
||||
public CompaniesHouseAddress? RegisteredOfficeAddress { get; init; }
|
||||
public List<string>? SicCodes { get; init; }
|
||||
public CompaniesHouseAccounts? Accounts { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CompaniesHouseAccounts
|
||||
{
|
||||
public string? AccountingReferenceDate { get; init; }
|
||||
public CompaniesHouseLastAccounts? LastAccounts { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CompaniesHouseLastAccounts
|
||||
{
|
||||
public string? MadeUpTo { get; init; }
|
||||
public string? Type { get; init; } // e.g., "micro-entity", "small", "medium", "dormant", etc.
|
||||
}
|
||||
|
||||
public sealed record CompaniesHouseOfficersResponse
|
||||
{
|
||||
public int TotalResults { get; init; }
|
||||
public List<CompaniesHouseOfficer> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record CompaniesHouseOfficer
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? OfficerRole { get; init; }
|
||||
public string? AppointedOn { get; init; }
|
||||
public string? ResignedOn { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CompaniesHouseAddress
|
||||
|
||||
@@ -22,6 +22,10 @@ public sealed class ProcessCVCheckJob
|
||||
|
||||
private const int BaseScore = 100;
|
||||
private const int UnverifiedCompanyPenalty = 10;
|
||||
private const int ImplausibleJobTitlePenalty = 15;
|
||||
private const int CompanyVerificationFlagPenalty = 5; // Base penalty for company flags, actual from flag.ScoreImpact
|
||||
private const int RapidProgressionPenalty = 10;
|
||||
private const int EarlyCareerSeniorRolePenalty = 10;
|
||||
private const int GapMonthPenalty = 1;
|
||||
private const int MaxGapPenalty = 10;
|
||||
private const int OverlapMonthPenalty = 2;
|
||||
@@ -86,22 +90,46 @@ public sealed class ProcessCVCheckJob
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Step 5: Verify each employment entry (parallelized with rate limiting)
|
||||
var verificationTasks = cvData.Employment.Select(async employment =>
|
||||
{
|
||||
var result = await _companyVerifierService.VerifyCompanyAsync(
|
||||
employment.CompanyName,
|
||||
employment.StartDate,
|
||||
employment.EndDate);
|
||||
// Skip freelance entries as they cannot be verified against company registries
|
||||
var verificationTasks = cvData.Employment
|
||||
.Where(e => !IsFreelance(e.CompanyName))
|
||||
.Select(async employment =>
|
||||
{
|
||||
var result = await _companyVerifierService.VerifyCompanyAsync(
|
||||
employment.CompanyName,
|
||||
employment.StartDate,
|
||||
employment.EndDate,
|
||||
employment.JobTitle);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verified {Company}: {IsVerified} (Score: {Score}%)",
|
||||
employment.CompanyName, result.IsVerified, result.MatchScore);
|
||||
_logger.LogDebug(
|
||||
"Verified {Company}: {IsVerified} (Score: {Score}%), JobTitle: {JobTitle}, Plausible: {Plausible}",
|
||||
employment.CompanyName, result.IsVerified, result.MatchScore,
|
||||
employment.JobTitle, result.JobTitlePlausible);
|
||||
|
||||
return result;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
var verificationResults = (await Task.WhenAll(verificationTasks)).ToList();
|
||||
|
||||
// Add freelance entries as auto-verified (skipped)
|
||||
foreach (var employment in cvData.Employment.Where(e => IsFreelance(e.CompanyName)))
|
||||
{
|
||||
verificationResults.Add(new CompanyVerificationResult
|
||||
{
|
||||
ClaimedCompany = employment.CompanyName,
|
||||
IsVerified = true,
|
||||
MatchScore = 100,
|
||||
VerificationNotes = "Freelance/self-employed - verification skipped",
|
||||
ClaimedJobTitle = employment.JobTitle,
|
||||
JobTitlePlausible = true
|
||||
});
|
||||
|
||||
_logger.LogDebug("Skipped verification for freelance entry: {Company}", employment.CompanyName);
|
||||
}
|
||||
|
||||
// Step 5b: Verify director claims against Companies House officers
|
||||
await VerifyDirectorClaims(cvData.FullName, verificationResults, cancellationToken);
|
||||
|
||||
// Step 6: Verify education entries
|
||||
var educationResults = _educationVerifierService.VerifyAll(
|
||||
cvData.Education,
|
||||
@@ -122,7 +150,7 @@ public sealed class ProcessCVCheckJob
|
||||
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
|
||||
|
||||
// Step 8: Calculate veracity score
|
||||
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis);
|
||||
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis, cvData);
|
||||
|
||||
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
|
||||
|
||||
@@ -194,7 +222,8 @@ public sealed class ProcessCVCheckJob
|
||||
private static (int Score, List<FlagResult> Flags) CalculateVeracityScore(
|
||||
List<CompanyVerificationResult> verifications,
|
||||
List<EducationVerificationResult> educationResults,
|
||||
TimelineAnalysisResult timeline)
|
||||
TimelineAnalysisResult timeline,
|
||||
CVData cvData)
|
||||
{
|
||||
var score = BaseScore;
|
||||
var flags = new List<FlagResult>();
|
||||
@@ -214,6 +243,48 @@ public sealed class ProcessCVCheckJob
|
||||
});
|
||||
}
|
||||
|
||||
// Process company verification flags (incorporation date, dissolution, dormant, etc.)
|
||||
foreach (var verification in verifications.Where(v => v.Flags.Count > 0))
|
||||
{
|
||||
foreach (var companyFlag in verification.Flags)
|
||||
{
|
||||
var penalty = Math.Abs(companyFlag.ScoreImpact);
|
||||
score -= penalty;
|
||||
|
||||
var severity = companyFlag.Severity switch
|
||||
{
|
||||
"Critical" => FlagSeverity.Critical,
|
||||
"Warning" => FlagSeverity.Warning,
|
||||
_ => FlagSeverity.Info
|
||||
};
|
||||
|
||||
flags.Add(new FlagResult
|
||||
{
|
||||
Category = FlagCategory.Employment.ToString(),
|
||||
Severity = severity.ToString(),
|
||||
Title = companyFlag.Type switch
|
||||
{
|
||||
"EmploymentBeforeIncorporation" => "Employment Before Company Existed",
|
||||
"EmploymentAtDissolvedCompany" => "Employment at Dissolved Company",
|
||||
"CurrentEmploymentAtDissolvedCompany" => "Current Employment at Dissolved Company",
|
||||
"EmploymentAtDormantCompany" => "Employment at Dormant Company",
|
||||
"SeniorRoleAtMicroCompany" => "Senior Role at Micro Company",
|
||||
"SicCodeMismatch" => "Role/Industry Mismatch",
|
||||
"ImplausibleJobTitle" => "Implausible Job Title",
|
||||
_ => companyFlag.Type
|
||||
},
|
||||
Description = companyFlag.Message,
|
||||
ScoreImpact = -penalty
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rapid career progression
|
||||
CheckRapidCareerProgression(cvData.Employment, flags, ref score);
|
||||
|
||||
// Check for early career senior roles (relative to education end date)
|
||||
CheckEarlyCareerSeniorRoles(cvData.Employment, cvData.Education, flags, ref score);
|
||||
|
||||
// Penalty for diploma mills (critical)
|
||||
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
|
||||
{
|
||||
@@ -328,4 +399,223 @@ public sealed class ProcessCVCheckJob
|
||||
_ => "Very Poor"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsFreelance(string companyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyName)) return false;
|
||||
|
||||
var name = companyName.Trim().ToLowerInvariant();
|
||||
return name == "freelance" ||
|
||||
name == "freelancer" ||
|
||||
name == "self-employed" ||
|
||||
name == "self employed" ||
|
||||
name.StartsWith("freelance ") ||
|
||||
name.StartsWith("self-employed ") ||
|
||||
name.Contains("(freelance)") ||
|
||||
name.Contains("(self-employed)");
|
||||
}
|
||||
|
||||
private async Task VerifyDirectorClaims(
|
||||
string candidateName,
|
||||
List<CompanyVerificationResult> verificationResults,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Find all director claims at verified companies
|
||||
foreach (var result in verificationResults.Where(v => v.IsVerified && !string.IsNullOrEmpty(v.MatchedCompanyNumber)))
|
||||
{
|
||||
var jobTitle = result.ClaimedJobTitle?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Check if this is a director claim
|
||||
var isDirectorClaim = jobTitle.Contains("director") ||
|
||||
jobTitle.Contains("company secretary") ||
|
||||
jobTitle == "md" ||
|
||||
jobTitle.Contains("managing director");
|
||||
|
||||
if (!isDirectorClaim) continue;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verifying director claim for {Candidate} at {Company}",
|
||||
candidateName, result.MatchedCompanyName);
|
||||
|
||||
var isVerifiedDirector = await _companyVerifierService.VerifyDirectorAsync(
|
||||
result.MatchedCompanyNumber!,
|
||||
candidateName,
|
||||
result.ClaimedStartDate,
|
||||
result.ClaimedEndDate);
|
||||
|
||||
if (isVerifiedDirector == false)
|
||||
{
|
||||
// Add a flag for unverified director claim
|
||||
var flags = result.Flags.ToList();
|
||||
flags.Add(new CompanyVerificationFlag
|
||||
{
|
||||
Type = "UnverifiedDirectorClaim",
|
||||
Severity = "Critical",
|
||||
Message = $"Claimed director role at '{result.MatchedCompanyName}' but candidate name not found in Companies House officers list",
|
||||
ScoreImpact = -20
|
||||
});
|
||||
|
||||
// Update the result with the new flag
|
||||
var index = verificationResults.IndexOf(result);
|
||||
verificationResults[index] = result with { Flags = flags };
|
||||
|
||||
_logger.LogWarning(
|
||||
"Director claim not verified for {Candidate} at {Company}",
|
||||
candidateName, result.MatchedCompanyName);
|
||||
}
|
||||
else if (isVerifiedDirector == true)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Director claim verified for {Candidate} at {Company}",
|
||||
candidateName, result.MatchedCompanyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckRapidCareerProgression(
|
||||
List<EmploymentEntry> employment,
|
||||
List<FlagResult> flags,
|
||||
ref int score)
|
||||
{
|
||||
// Group employment by company and check for rapid promotions
|
||||
var byCompany = employment
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.CompanyName) && e.StartDate.HasValue)
|
||||
.GroupBy(e => e.CompanyName.ToLowerInvariant())
|
||||
.Where(g => g.Count() > 1);
|
||||
|
||||
foreach (var companyGroup in byCompany)
|
||||
{
|
||||
var orderedRoles = companyGroup.OrderBy(e => e.StartDate).ToList();
|
||||
|
||||
for (int i = 1; i < orderedRoles.Count; i++)
|
||||
{
|
||||
var prevRole = orderedRoles[i - 1];
|
||||
var currRole = orderedRoles[i];
|
||||
|
||||
var prevSeniority = GetSeniorityLevel(prevRole.JobTitle);
|
||||
var currSeniority = GetSeniorityLevel(currRole.JobTitle);
|
||||
|
||||
// Check for jump of 3+ seniority levels
|
||||
var seniorityJump = currSeniority - prevSeniority;
|
||||
if (seniorityJump >= 3)
|
||||
{
|
||||
// Calculate time between roles
|
||||
var monthsBetween = ((currRole.StartDate!.Value.Year - prevRole.StartDate!.Value.Year) * 12) +
|
||||
(currRole.StartDate!.Value.Month - prevRole.StartDate!.Value.Month);
|
||||
|
||||
// If jumped 3+ levels in less than 2 years, flag it
|
||||
if (monthsBetween < 24)
|
||||
{
|
||||
score -= RapidProgressionPenalty;
|
||||
|
||||
flags.Add(new FlagResult
|
||||
{
|
||||
Category = FlagCategory.Employment.ToString(),
|
||||
Severity = FlagSeverity.Warning.ToString(),
|
||||
Title = "Rapid Career Progression",
|
||||
Description = $"Promoted from '{prevRole.JobTitle}' to '{currRole.JobTitle}' at '{companyGroup.First().CompanyName}' in {monthsBetween} months - unusually fast progression",
|
||||
ScoreImpact = -RapidProgressionPenalty
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckEarlyCareerSeniorRoles(
|
||||
List<EmploymentEntry> employment,
|
||||
List<EducationEntry> education,
|
||||
List<FlagResult> flags,
|
||||
ref int score)
|
||||
{
|
||||
// Find the latest education end date to estimate career start
|
||||
var latestEducationEnd = education
|
||||
.Where(e => e.EndDate.HasValue)
|
||||
.Select(e => e.EndDate!.Value)
|
||||
.DefaultIfEmpty(DateOnly.MinValue)
|
||||
.Max();
|
||||
|
||||
if (latestEducationEnd == DateOnly.MinValue)
|
||||
{
|
||||
// No education dates available, skip check
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var emp in employment.Where(e => e.StartDate.HasValue))
|
||||
{
|
||||
var monthsAfterEducation = ((emp.StartDate!.Value.Year - latestEducationEnd.Year) * 12) +
|
||||
(emp.StartDate!.Value.Month - latestEducationEnd.Month);
|
||||
|
||||
// Check if this is a senior role started within 2 years of finishing education
|
||||
if (monthsAfterEducation < 24 && monthsAfterEducation >= 0)
|
||||
{
|
||||
var seniority = GetSeniorityLevel(emp.JobTitle);
|
||||
|
||||
// Flag if they're claiming a senior role (level 4+) very early in career
|
||||
if (seniority >= 4)
|
||||
{
|
||||
score -= EarlyCareerSeniorRolePenalty;
|
||||
|
||||
flags.Add(new FlagResult
|
||||
{
|
||||
Category = FlagCategory.Employment.ToString(),
|
||||
Severity = FlagSeverity.Warning.ToString(),
|
||||
Title = "Early Career Senior Role",
|
||||
Description = $"Claimed senior role '{emp.JobTitle}' at '{emp.CompanyName}' only {monthsAfterEducation} months after completing education",
|
||||
ScoreImpact = -EarlyCareerSeniorRolePenalty
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetSeniorityLevel(string? jobTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobTitle)) return 0;
|
||||
|
||||
var title = jobTitle.ToLowerInvariant();
|
||||
|
||||
// Level 6: C-suite
|
||||
if (title.Contains("ceo") || title.Contains("cto") || title.Contains("cfo") ||
|
||||
title.Contains("coo") || title.Contains("cio") || title.Contains("chief") ||
|
||||
title.Contains("managing director") || title == "md" ||
|
||||
title.Contains("president") || title.Contains("chairman") ||
|
||||
title.Contains("chairwoman") || title.Contains("chairperson"))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
|
||||
// Level 5: VP / Executive
|
||||
if (title.Contains("vice president") || title.Contains("vp ") ||
|
||||
title.StartsWith("vp") || title.Contains("svp") ||
|
||||
title.Contains("executive director") || title.Contains("executive vice"))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
// Level 4: Director / Head
|
||||
if (title.Contains("director") || title.Contains("head of"))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Level 3: Senior / Lead / Principal / Manager
|
||||
if (title.Contains("senior") || title.Contains("lead") ||
|
||||
title.Contains("principal") || title.Contains("manager") ||
|
||||
title.Contains("team lead") || title.Contains("staff"))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Level 2: Mid-level (no junior, no senior)
|
||||
if (!title.Contains("junior") && !title.Contains("trainee") &&
|
||||
!title.Contains("intern") && !title.Contains("graduate") &&
|
||||
!title.Contains("entry") && !title.Contains("assistant"))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Level 1: Junior / Entry-level
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/dashboard"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -250,10 +251,54 @@
|
||||
private bool _isExporting;
|
||||
private string? _errorMessage;
|
||||
private Guid _userId;
|
||||
private System.Threading.Timer? _pollingTimer;
|
||||
private bool _isPolling;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadChecks();
|
||||
StartPollingIfNeeded();
|
||||
}
|
||||
|
||||
private void StartPollingIfNeeded()
|
||||
{
|
||||
if (HasProcessingChecks() && !_isPolling)
|
||||
{
|
||||
_isPolling = true;
|
||||
_pollingTimer = new System.Threading.Timer(async _ =>
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await LoadChecks();
|
||||
if (!HasProcessingChecks())
|
||||
{
|
||||
StopPolling();
|
||||
}
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasProcessingChecks()
|
||||
{
|
||||
foreach (var c in _checks)
|
||||
{
|
||||
if (c.Status == "Processing" || c.Status == "Pending") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void StopPolling()
|
||||
{
|
||||
_isPolling = false;
|
||||
_pollingTimer?.Dispose();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopPolling();
|
||||
}
|
||||
|
||||
private async Task LoadChecks()
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<div class="card-body p-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4 text-center border-end">
|
||||
<div class="score-circle @GetScoreColorClass(_report.OverallScore) mx-auto mb-2">
|
||||
<div class="score-circle @GetScoreColorClass(_report!.OverallScore) mx-auto mb-2">
|
||||
<span class="score-value">@_report.OverallScore</span>
|
||||
</div>
|
||||
<h5 class="mb-0">@_report.ScoreLabel</h5>
|
||||
|
||||
Reference in New Issue
Block a user