2026-01-18 19:20:50 +01:00
|
|
|
using Microsoft.Extensions.Logging;
|
2026-01-21 15:07:20 +00:00
|
|
|
using RealCV.Application.Interfaces;
|
|
|
|
|
using RealCV.Application.Models;
|
2026-01-18 19:20:50 +01:00
|
|
|
|
2026-01-21 15:07:20 +00:00
|
|
|
namespace RealCV.Infrastructure.Services;
|
2026-01-18 19:20:50 +01:00
|
|
|
|
|
|
|
|
public sealed class TimelineAnalyserService : ITimelineAnalyserService
|
|
|
|
|
{
|
|
|
|
|
private readonly ILogger<TimelineAnalyserService> _logger;
|
|
|
|
|
|
|
|
|
|
private const int MinimumGapMonths = 3;
|
|
|
|
|
private const int AllowedOverlapMonths = 2;
|
|
|
|
|
|
|
|
|
|
public TimelineAnalyserService(ILogger<TimelineAnalyserService> logger)
|
|
|
|
|
{
|
|
|
|
|
_logger = logger;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory)
|
|
|
|
|
{
|
|
|
|
|
ArgumentNullException.ThrowIfNull(employmentHistory);
|
|
|
|
|
|
|
|
|
|
if (employmentHistory.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug("No employment history to analyse");
|
|
|
|
|
return new TimelineAnalysisResult
|
|
|
|
|
{
|
|
|
|
|
TotalGapMonths = 0,
|
|
|
|
|
TotalOverlapMonths = 0,
|
|
|
|
|
Gaps = [],
|
|
|
|
|
Overlaps = []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter entries with valid dates and sort by start date
|
|
|
|
|
var sortedEmployment = employmentHistory
|
|
|
|
|
.Where(e => e.StartDate.HasValue)
|
|
|
|
|
.OrderBy(e => e.StartDate!.Value)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
if (sortedEmployment.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug("No employment entries with valid dates to analyse");
|
|
|
|
|
return new TimelineAnalysisResult
|
|
|
|
|
{
|
|
|
|
|
TotalGapMonths = 0,
|
|
|
|
|
TotalOverlapMonths = 0,
|
|
|
|
|
Gaps = [],
|
|
|
|
|
Overlaps = []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var gaps = DetectGaps(sortedEmployment);
|
|
|
|
|
var overlaps = DetectOverlaps(sortedEmployment);
|
|
|
|
|
|
|
|
|
|
var totalGapMonths = gaps.Sum(g => g.Months);
|
|
|
|
|
var totalOverlapMonths = overlaps.Sum(o => o.Months);
|
|
|
|
|
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Timeline analysis complete: {GapCount} gaps ({TotalGapMonths} months), {OverlapCount} overlaps ({TotalOverlapMonths} months)",
|
|
|
|
|
gaps.Count, totalGapMonths, overlaps.Count, totalOverlapMonths);
|
|
|
|
|
|
|
|
|
|
return new TimelineAnalysisResult
|
|
|
|
|
{
|
|
|
|
|
TotalGapMonths = totalGapMonths,
|
|
|
|
|
TotalOverlapMonths = totalOverlapMonths,
|
|
|
|
|
Gaps = gaps,
|
|
|
|
|
Overlaps = overlaps
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<TimelineGap> DetectGaps(List<EmploymentEntry> sortedEmployment)
|
|
|
|
|
{
|
|
|
|
|
var gaps = new List<TimelineGap>();
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < sortedEmployment.Count - 1; i++)
|
|
|
|
|
{
|
|
|
|
|
var current = sortedEmployment[i];
|
|
|
|
|
var next = sortedEmployment[i + 1];
|
|
|
|
|
|
|
|
|
|
// Get the effective end date for the current position
|
|
|
|
|
var currentEndDate = GetEffectiveEndDate(current);
|
|
|
|
|
var nextStartDate = next.StartDate!.Value;
|
|
|
|
|
|
|
|
|
|
// Skip if there's no gap or overlap
|
|
|
|
|
if (currentEndDate >= nextStartDate)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var gapMonths = CalculateMonthsDifference(currentEndDate, nextStartDate);
|
|
|
|
|
|
|
|
|
|
// Only report gaps of 3+ months
|
|
|
|
|
if (gapMonths >= MinimumGapMonths)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug(
|
|
|
|
|
"Detected {Months} month gap between {EndDate} and {StartDate}",
|
|
|
|
|
gapMonths, currentEndDate, nextStartDate);
|
|
|
|
|
|
|
|
|
|
gaps.Add(new TimelineGap
|
|
|
|
|
{
|
|
|
|
|
StartDate = currentEndDate,
|
|
|
|
|
EndDate = nextStartDate,
|
|
|
|
|
Months = gapMonths
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return gaps;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<TimelineOverlap> DetectOverlaps(List<EmploymentEntry> sortedEmployment)
|
|
|
|
|
{
|
|
|
|
|
var overlaps = new List<TimelineOverlap>();
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < sortedEmployment.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
for (var j = i + 1; j < sortedEmployment.Count; j++)
|
|
|
|
|
{
|
|
|
|
|
var earlier = sortedEmployment[i];
|
|
|
|
|
var later = sortedEmployment[j];
|
|
|
|
|
|
2026-01-22 19:45:37 +00:00
|
|
|
// Skip overlaps at the same company (internal promotions/transfers)
|
|
|
|
|
if (IsSameCompany(earlier.CompanyName, later.CompanyName))
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug(
|
|
|
|
|
"Ignoring overlap at same company: {Company1} -> {Company2}",
|
|
|
|
|
earlier.CompanyName, later.CompanyName);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 19:20:50 +01:00
|
|
|
var overlap = CalculateOverlap(earlier, later);
|
|
|
|
|
|
|
|
|
|
if (overlap is not null && overlap.Value.Months > AllowedOverlapMonths)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug(
|
|
|
|
|
"Detected {Months} month overlap between {Company1} and {Company2}",
|
|
|
|
|
overlap.Value.Months, earlier.CompanyName, later.CompanyName);
|
|
|
|
|
|
|
|
|
|
overlaps.Add(new TimelineOverlap
|
|
|
|
|
{
|
|
|
|
|
Company1 = earlier.CompanyName,
|
|
|
|
|
Company2 = later.CompanyName,
|
|
|
|
|
OverlapStart = overlap.Value.Start,
|
|
|
|
|
OverlapEnd = overlap.Value.End,
|
|
|
|
|
Months = overlap.Value.Months
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return overlaps;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 19:45:37 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// Determines if two company names refer to the same company.
|
|
|
|
|
/// Handles variations like "BMW" vs "BMW UK" vs "BMW Group".
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static bool IsSameCompany(string? company1, string? company2)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(company1) || string.IsNullOrWhiteSpace(company2))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normalize names for comparison
|
|
|
|
|
var name1 = NormalizeCompanyName(company1);
|
|
|
|
|
var name2 = NormalizeCompanyName(company2);
|
|
|
|
|
|
|
|
|
|
// Exact match after normalization
|
|
|
|
|
if (name1.Equals(name2, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if one contains the other (for "BMW" vs "BMW UK" cases)
|
|
|
|
|
if (name1.Length >= 3 && name2.Length >= 3)
|
|
|
|
|
{
|
|
|
|
|
if (name1.StartsWith(name2, StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
name2.StartsWith(name1, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string NormalizeCompanyName(string name)
|
|
|
|
|
{
|
|
|
|
|
// Remove common suffixes and normalize
|
|
|
|
|
var normalized = name.Trim();
|
|
|
|
|
|
|
|
|
|
string[] suffixes = ["Ltd", "Ltd.", "Limited", "PLC", "Plc", "Inc", "Inc.",
|
|
|
|
|
"Corporation", "Corp", "Corp.", "UK", "Group", "(UK)", "& Co", "& Co."];
|
|
|
|
|
|
|
|
|
|
foreach (var suffix in suffixes)
|
|
|
|
|
{
|
|
|
|
|
if (normalized.EndsWith(" " + suffix, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
normalized = normalized[..^(suffix.Length + 1)].Trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 19:20:50 +01:00
|
|
|
private static (DateOnly Start, DateOnly End, int Months)? CalculateOverlap(
|
|
|
|
|
EmploymentEntry earlier,
|
|
|
|
|
EmploymentEntry later)
|
|
|
|
|
{
|
|
|
|
|
if (!earlier.StartDate.HasValue || !later.StartDate.HasValue)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var earlierEnd = GetEffectiveEndDate(earlier);
|
|
|
|
|
var laterStart = later.StartDate.Value;
|
|
|
|
|
|
|
|
|
|
// No overlap if earlier job ended before later job started
|
|
|
|
|
if (earlierEnd <= laterStart)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var laterEnd = GetEffectiveEndDate(later);
|
|
|
|
|
|
|
|
|
|
// The overlap period
|
|
|
|
|
var overlapStart = laterStart;
|
|
|
|
|
var overlapEnd = earlierEnd < laterEnd ? earlierEnd : laterEnd;
|
|
|
|
|
|
|
|
|
|
if (overlapStart >= overlapEnd)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var months = CalculateMonthsDifference(overlapStart, overlapEnd);
|
|
|
|
|
|
|
|
|
|
return (overlapStart, overlapEnd, months);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static DateOnly GetEffectiveEndDate(EmploymentEntry entry)
|
|
|
|
|
{
|
|
|
|
|
if (entry.EndDate.HasValue)
|
|
|
|
|
{
|
|
|
|
|
return entry.EndDate.Value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If marked as current or no end date, use today
|
|
|
|
|
return DateOnly.FromDateTime(DateTime.UtcNow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int CalculateMonthsDifference(DateOnly startDate, DateOnly endDate)
|
|
|
|
|
{
|
|
|
|
|
var yearDiff = endDate.Year - startDate.Year;
|
|
|
|
|
var monthDiff = endDate.Month - startDate.Month;
|
|
|
|
|
var totalMonths = (yearDiff * 12) + monthDiff;
|
|
|
|
|
|
|
|
|
|
// Add a month if we've passed the day in the month
|
|
|
|
|
if (endDate.Day >= startDate.Day)
|
|
|
|
|
{
|
|
|
|
|
totalMonths++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.Max(0, totalMonths);
|
|
|
|
|
}
|
|
|
|
|
}
|