phase 4
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Models\JobCategory;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JobEnrichmentService
|
||||
{
|
||||
/**
|
||||
* Category keywords mapping
|
||||
*/
|
||||
private array $categoryKeywords = [
|
||||
'Software Development' => ['software', 'developer', 'engineer', 'programming', 'coding', 'web', 'mobile', 'frontend', 'backend', 'fullstack', 'full-stack'],
|
||||
'Data Science' => ['data', 'analytics', 'scientist', 'machine learning', 'ml', 'ai', 'artificial intelligence', 'statistician'],
|
||||
'Design' => ['designer', 'ux', 'ui', 'graphic', 'creative', 'figma', 'sketch', 'product design'],
|
||||
'Marketing' => ['marketing', 'seo', 'social media', 'content', 'growth', 'digital marketing', 'brand'],
|
||||
'Sales' => ['sales', 'account executive', 'business development', 'account manager', 'sales representative'],
|
||||
'Customer Support' => ['support', 'customer service', 'help desk', 'technical support', 'customer success'],
|
||||
'Human Resources' => ['hr', 'human resources', 'recruiter', 'talent', 'recruiting'],
|
||||
'Finance' => ['finance', 'accountant', 'accounting', 'financial', 'controller', 'analyst'],
|
||||
'Operations' => ['operations', 'logistics', 'supply chain', 'project manager', 'program manager'],
|
||||
'DevOps' => ['devops', 'infrastructure', 'cloud', 'aws', 'kubernetes', 'docker', 'sre'],
|
||||
'Product Management' => ['product manager', 'product owner', 'product lead'],
|
||||
'Quality Assurance' => ['qa', 'quality', 'testing', 'test engineer', 'sdet'],
|
||||
'Writing' => ['writer', 'content writer', 'copywriter', 'technical writer', 'editor'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Auto-categorize a job based on title and description
|
||||
*/
|
||||
public function categorizeJob(Job $job): ?JobCategory
|
||||
{
|
||||
if ($job->job_category_id) {
|
||||
return $job->category;
|
||||
}
|
||||
|
||||
$text = strtolower($job->title . ' ' . $job->description);
|
||||
|
||||
foreach ($this->categoryKeywords as $categoryName => $keywords) {
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($text, $keyword)) {
|
||||
$category = JobCategory::firstOrCreate(
|
||||
['slug' => \Str::slug($categoryName)],
|
||||
['name' => $categoryName, 'description' => "Jobs related to {$categoryName}"]
|
||||
);
|
||||
|
||||
$job->update(['job_category_id' => $category->id]);
|
||||
|
||||
Log::debug("Auto-categorized job #{$job->id} as '{$categoryName}'");
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich job with additional data
|
||||
*/
|
||||
public function enrichJob(Job $job): void
|
||||
{
|
||||
// Auto-categorize if not categorized
|
||||
if (!$job->job_category_id) {
|
||||
$this->categorizeJob($job);
|
||||
}
|
||||
|
||||
// Detect if remote from title/description
|
||||
if (!$job->remote_allowed) {
|
||||
$this->detectRemote($job);
|
||||
}
|
||||
|
||||
// Parse salary if not set
|
||||
if (!$job->salary_min && !$job->salary_max) {
|
||||
$this->parseSalaryFromDescription($job);
|
||||
}
|
||||
|
||||
// Infer experience level if not set
|
||||
if (!$job->experience_level) {
|
||||
$this->inferExperienceLevel($job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if job is remote based on title/description
|
||||
*/
|
||||
private function detectRemote(Job $job): void
|
||||
{
|
||||
$text = strtolower($job->title . ' ' . $job->description . ' ' . ($job->location ?? ''));
|
||||
$remoteKeywords = ['remote', 'work from home', 'wfh', 'telecommute', 'distributed', 'anywhere'];
|
||||
|
||||
foreach ($remoteKeywords as $keyword) {
|
||||
if (str_contains($text, $keyword)) {
|
||||
$job->update(['remote_allowed' => true]);
|
||||
Log::debug("Detected remote job #{$job->id}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse salary from description
|
||||
*/
|
||||
private function parseSalaryFromDescription(Job $job): void
|
||||
{
|
||||
$description = $job->description;
|
||||
|
||||
// Match patterns like $50,000-$80,000, $50k-80k, 50-80k, etc.
|
||||
$patterns = [
|
||||
'/\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i',
|
||||
'/salary:\s*\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i',
|
||||
'/compensation:\s*\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $description, $matches)) {
|
||||
$min = (float) str_replace(',', '', $matches[1]);
|
||||
$max = (float) str_replace(',', '', $matches[2]);
|
||||
|
||||
// If values look like they're in 'k' format (e.g., 50 instead of 50000)
|
||||
if ($min < 1000) {
|
||||
$min *= 1000;
|
||||
$max *= 1000;
|
||||
}
|
||||
|
||||
$job->update([
|
||||
'salary_min' => $min,
|
||||
'salary_max' => $max,
|
||||
]);
|
||||
|
||||
Log::debug("Parsed salary for job #{$job->id}: {$min}-{$max}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer experience level from title/description
|
||||
*/
|
||||
private function inferExperienceLevel(Job $job): void
|
||||
{
|
||||
$text = strtolower($job->title);
|
||||
|
||||
$levelKeywords = [
|
||||
'entry' => ['junior', 'entry', 'associate', 'intern', 'graduate', 'trainee'],
|
||||
'mid' => ['mid', 'intermediate', 'regular'],
|
||||
'senior' => ['senior', 'sr', 'lead developer', 'staff'],
|
||||
'lead' => ['lead', 'principal', 'architect', 'head of'],
|
||||
'executive' => ['director', 'vp', 'vice president', 'chief', 'cto', 'ceo', 'cfo', 'coo'],
|
||||
];
|
||||
|
||||
foreach ($levelKeywords as $level => $keywords) {
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($text, $keyword)) {
|
||||
$job->update(['experience_level' => $level]);
|
||||
Log::debug("Inferred experience level for job #{$job->id}: {$level}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to mid-level if no keywords found
|
||||
$job->update(['experience_level' => 'mid']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch enrich multiple jobs
|
||||
*/
|
||||
public function enrichJobs(int $batchSize = 100): int
|
||||
{
|
||||
$jobs = Job::whereNull('job_category_id')
|
||||
->orWhereNull('experience_level')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
$enrichedCount = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
try {
|
||||
$this->enrichJob($job);
|
||||
$enrichedCount++;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to enrich job #{$job->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $enrichedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize salary to USD (if we had currency conversion)
|
||||
*/
|
||||
public function normalizeSalary(Job $job): void
|
||||
{
|
||||
// Placeholder for currency conversion logic
|
||||
// Would use an API like exchangerate-api.com or similar
|
||||
|
||||
if ($job->salary_currency !== 'USD') {
|
||||
// Convert to USD
|
||||
// $job->update(['salary_min' => $converted, 'salary_max' => $converted]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Job;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class JobSearchService
|
||||
{
|
||||
/**
|
||||
* Search jobs with advanced filters
|
||||
*/
|
||||
public function search(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = Job::query()
|
||||
->with(['source', 'category', 'jobLocation'])
|
||||
->active();
|
||||
|
||||
// Apply filters
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
// Apply sorting
|
||||
$this->applySorting($query, $filters['sort'] ?? 'date');
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to the query
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
// Keyword search (title, company, description)
|
||||
if (!empty($filters['keywords'])) {
|
||||
$keywords = $filters['keywords'];
|
||||
$query->where(function ($q) use ($keywords) {
|
||||
$q->where('title', 'LIKE', "%{$keywords}%")
|
||||
->orWhere('company', 'LIKE', "%{$keywords}%")
|
||||
->orWhere('description', 'LIKE', "%{$keywords}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Full-text search (if supported)
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->whereRaw(
|
||||
"MATCH(title, company, description) AGAINST(? IN BOOLEAN MODE)",
|
||||
[$search]
|
||||
);
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if (!empty($filters['location'])) {
|
||||
$location = $filters['location'];
|
||||
$query->where(function ($q) use ($location) {
|
||||
$q->where('location', 'LIKE', "%{$location}%")
|
||||
->orWhereHas('jobLocation', function ($q2) use ($location) {
|
||||
$q2->where('city', 'LIKE', "%{$location}%")
|
||||
->orWhere('state', 'LIKE', "%{$location}%")
|
||||
->orWhere('country', 'LIKE', "%{$location}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (!empty($filters['category_id'])) {
|
||||
$query->where('job_category_id', $filters['category_id']);
|
||||
}
|
||||
|
||||
// Job type filter (can be array for multiple types)
|
||||
if (!empty($filters['job_type'])) {
|
||||
$types = is_array($filters['job_type']) ? $filters['job_type'] : [$filters['job_type']];
|
||||
$query->whereIn('job_type', $types);
|
||||
}
|
||||
|
||||
// Experience level filter (can be array)
|
||||
if (!empty($filters['experience_level'])) {
|
||||
$levels = is_array($filters['experience_level']) ? $filters['experience_level'] : [$filters['experience_level']];
|
||||
$query->whereIn('experience_level', $levels);
|
||||
}
|
||||
|
||||
// Remote filter
|
||||
if (isset($filters['remote']) && $filters['remote'] !== '') {
|
||||
$query->where('remote_allowed', (bool) $filters['remote']);
|
||||
}
|
||||
|
||||
// Salary range filter
|
||||
if (!empty($filters['salary_min'])) {
|
||||
$query->where(function ($q) use ($filters) {
|
||||
$q->where('salary_max', '>=', $filters['salary_min'])
|
||||
->orWhereNull('salary_max');
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($filters['salary_max'])) {
|
||||
$query->where(function ($q) use ($filters) {
|
||||
$q->where('salary_min', '<=', $filters['salary_max'])
|
||||
->orWhereNull('salary_min');
|
||||
});
|
||||
}
|
||||
|
||||
// Posted date filter (e.g., last 24 hours, 7 days, 30 days)
|
||||
if (!empty($filters['posted_within'])) {
|
||||
$days = (int) $filters['posted_within'];
|
||||
$query->where(function ($q) use ($days) {
|
||||
$q->where('posted_at', '>=', now()->subDays($days))
|
||||
->orWhere(function ($q2) use ($days) {
|
||||
$q2->whereNull('posted_at')
|
||||
->where('created_at', '>=', now()->subDays($days));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Source filter
|
||||
if (!empty($filters['source_id'])) {
|
||||
$query->where('job_source_id', $filters['source_id']);
|
||||
}
|
||||
|
||||
// Company filter
|
||||
if (!empty($filters['company'])) {
|
||||
$query->where('company', 'LIKE', "%{$filters['company']}%");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sorting to the query
|
||||
*/
|
||||
private function applySorting(Builder $query, string $sort): void
|
||||
{
|
||||
match ($sort) {
|
||||
'date' => $query->latest('posted_at'),
|
||||
'date_asc' => $query->oldest('posted_at'),
|
||||
'salary_desc' => $query->orderBy('salary_max', 'desc')->orderBy('salary_min', 'desc'),
|
||||
'salary_asc' => $query->orderBy('salary_min', 'asc')->orderBy('salary_max', 'asc'),
|
||||
'title' => $query->orderBy('title', 'asc'),
|
||||
'company' => $query->orderBy('company', 'asc'),
|
||||
'relevance' => $query->latest('created_at'), // Default to newest if no full-text score
|
||||
default => $query->latest('posted_at'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for job titles
|
||||
*/
|
||||
public function getTitleSuggestions(string $query, int $limit = 10): array
|
||||
{
|
||||
return Job::select('title')
|
||||
->where('title', 'LIKE', "{$query}%")
|
||||
->active()
|
||||
->groupBy('title')
|
||||
->limit($limit)
|
||||
->pluck('title')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for companies
|
||||
*/
|
||||
public function getCompanySuggestions(string $query, int $limit = 10): array
|
||||
{
|
||||
return Job::select('company')
|
||||
->where('company', 'LIKE', "{$query}%")
|
||||
->active()
|
||||
->groupBy('company')
|
||||
->limit($limit)
|
||||
->pluck('company')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for locations
|
||||
*/
|
||||
public function getLocationSuggestions(string $query, int $limit = 10): array
|
||||
{
|
||||
return Job::select('location')
|
||||
->where('location', 'LIKE', "{$query}%")
|
||||
->whereNotNull('location')
|
||||
->active()
|
||||
->groupBy('location')
|
||||
->limit($limit)
|
||||
->pluck('location')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filter options
|
||||
*/
|
||||
public function getFilterOptions(): array
|
||||
{
|
||||
return [
|
||||
'job_types' => [
|
||||
'full-time' => 'Full-time',
|
||||
'part-time' => 'Part-time',
|
||||
'contract' => 'Contract',
|
||||
'temporary' => 'Temporary',
|
||||
'internship' => 'Internship',
|
||||
'freelance' => 'Freelance',
|
||||
],
|
||||
'experience_levels' => [
|
||||
'entry' => 'Entry Level',
|
||||
'mid' => 'Mid Level',
|
||||
'senior' => 'Senior Level',
|
||||
'lead' => 'Lead',
|
||||
'executive' => 'Executive',
|
||||
],
|
||||
'posted_within' => [
|
||||
1 => 'Last 24 hours',
|
||||
7 => 'Last 7 days',
|
||||
30 => 'Last 30 days',
|
||||
90 => 'Last 3 months',
|
||||
],
|
||||
'sort_options' => [
|
||||
'date' => 'Most Recent',
|
||||
'date_asc' => 'Oldest First',
|
||||
'salary_desc' => 'Highest Salary',
|
||||
'salary_asc' => 'Lowest Salary',
|
||||
'title' => 'Title A-Z',
|
||||
'company' => 'Company A-Z',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user