This commit is contained in:
Doar Mihai
2026-04-03 00:39:57 +03:00
parent 4e6b636787
commit 2b930a0636
6 changed files with 868 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Services\JobEnrichmentService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('jobs:enrich {--batch=100}')]
#[Description('Enrich job listings with additional data (categories, experience levels, etc.)')]
class EnrichJobs extends Command
{
public function __construct(
private JobEnrichmentService $enrichmentService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$batchSize = (int) $this->option('batch');
$this->info('Enriching job listings...');
$this->newLine();
$enrichedCount = $this->enrichmentService->enrichJobs($batchSize);
$this->newLine();
$this->info("Enriched {$enrichedCount} jobs successfully!");
return self::SUCCESS;
}
}
+79
View File
@@ -108,4 +108,83 @@ class Job extends Model
{ {
return $query->where('experience_level', $level); return $query->where('experience_level', $level);
} }
/**
* Scope a query to search by keywords in title, company, and description.
*/
public function scopeSearchKeywords($query, string $keywords)
{
return $query->where(function ($q) use ($keywords) {
$q->where('title', 'LIKE', "%{$keywords}%")
->orWhere('company', 'LIKE', "%{$keywords}%")
->orWhere('description', 'LIKE', "%{$keywords}%");
});
}
/**
* Scope a query to filter by category.
*/
public function scopeInCategory($query, int $categoryId)
{
return $query->where('job_category_id', $categoryId);
}
/**
* Scope a query to filter by location.
*/
public function scopeInLocation($query, string $location)
{
return $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}%");
});
});
}
/**
* Scope a query to filter by salary range.
*/
public function scopeWithinSalaryRange($query, ?float $minSalary = null, ?float $maxSalary = null)
{
if ($minSalary) {
$query->where(function ($q) use ($minSalary) {
$q->where('salary_max', '>=', $minSalary)
->orWhereNull('salary_max');
});
}
if ($maxSalary) {
$query->where(function ($q) use ($maxSalary) {
$q->where('salary_min', '<=', $maxSalary)
->orWhereNull('salary_min');
});
}
return $query;
}
/**
* Scope a query to filter by posted date.
*/
public function scopePostedWithin($query, int $days)
{
return $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));
});
});
}
/**
* Scope a query to filter by company.
*/
public function scopeByCompany($query, string $company)
{
return $query->where('company', 'LIKE', "%{$company}%");
}
} }
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Repositories;
use App\Models\Job;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class JobRepository
{
/**
* Get all active jobs with pagination
*/
public function getActiveJobs(int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->latest('posted_at')
->paginate($perPage);
}
/**
* Find a job by ID
*/
public function find(int $id): ?Job
{
return Job::with(['source', 'category', 'jobLocation'])->find($id);
}
/**
* Get jobs by category
*/
public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->where('job_category_id', $categoryId)
->latest('posted_at')
->paginate($perPage);
}
/**
* Get jobs by location
*/
public function getByLocation(int $locationId, int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->where('job_location_id', $locationId)
->latest('posted_at')
->paginate($perPage);
}
/**
* Get remote jobs
*/
public function getRemoteJobs(int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->remote()
->latest('posted_at')
->paginate($perPage);
}
/**
* Get recent jobs (last N days)
*/
public function getRecentJobs(int $days = 7, int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->where(function ($query) use ($days) {
$query->where('posted_at', '>=', now()->subDays($days))
->orWhere(function ($q) use ($days) {
$q->whereNull('posted_at')
->where('created_at', '>=', now()->subDays($days));
});
})
->latest('posted_at')
->paginate($perPage);
}
/**
* Get jobs by experience level
*/
public function getByExperienceLevel(string $level, int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->experienceLevel($level)
->latest('posted_at')
->paginate($perPage);
}
/**
* Get jobs by type
*/
public function getByJobType(string $type, int $perPage = 15): LengthAwarePaginator
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->ofType($type)
->latest('posted_at')
->paginate($perPage);
}
/**
* Get similar jobs (same category, exclude current)
*/
public function getSimilarJobs(Job $job, int $limit = 5): Collection
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->where('id', '!=', $job->id)
->where('job_category_id', $job->job_category_id)
->latest('posted_at')
->limit($limit)
->get();
}
/**
* Get featured jobs (most recent from diverse sources)
*/
public function getFeaturedJobs(int $limit = 10): Collection
{
return Job::with(['source', 'category', 'jobLocation'])
->active()
->select('jobs.*')
->selectRaw('ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn')
->fromSub(function ($query) {
$query->from('jobs')
->whereNull('deleted_at')
->whereNull('expires_at')
->orWhere('expires_at', '>', now());
}, 'jobs')
->havingRaw('rn <= 2') // Max 2 jobs per source
->latest('posted_at')
->limit($limit)
->get();
}
/**
* Get job statistics
*/
public function getStatistics(): array
{
$total = Job::count();
$active = Job::active()->count();
$remote = Job::active()->remote()->count();
$recentlyAdded = Job::where('created_at', '>=', now()->subDays(7))->count();
$topCategories = Job::active()
->select('job_category_id', \DB::raw('COUNT(*) as count'))
->whereNotNull('job_category_id')
->groupBy('job_category_id')
->orderBy('count', 'desc')
->limit(5)
->with('category')
->get();
return [
'total_jobs' => $total,
'active_jobs' => $active,
'remote_jobs' => $remote,
'recently_added' => $recentlyAdded,
'top_categories' => $topCategories,
];
}
/**
* Create a new job
*/
public function create(array $data): Job
{
return Job::create($data);
}
/**
* Update a job
*/
public function update(Job $job, array $data): bool
{
return $job->update($data);
}
/**
* Delete a job
*/
public function delete(Job $job): bool
{
return $job->delete();
}
/**
* Get jobs count by source
*/
public function getCountBySource(): Collection
{
return Job::with('source')
->select('job_source_id', \DB::raw('COUNT(*) as count'))
->groupBy('job_source_id')
->orderBy('count', 'desc')
->get();
}
}
+206
View File
@@ -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]);
}
}
}
+223
View File
@@ -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',
],
];
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Database\Seeders;
use App\Models\JobCategory;
use Illuminate\Database\Seeder;
class JobCategorySeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$categories = [
[
'name' => 'Software Development',
'slug' => 'software-development',
'description' => 'Software engineering, web development, mobile development, and programming jobs',
'icon' => 'code',
],
[
'name' => 'Data Science',
'slug' => 'data-science',
'description' => 'Data analysis, machine learning, AI, and analytics positions',
'icon' => 'chart-bar',
],
[
'name' => 'Design',
'slug' => 'design',
'description' => 'UX/UI design, graphic design, product design, and creative roles',
'icon' => 'palette',
],
[
'name' => 'Marketing',
'slug' => 'marketing',
'description' => 'Digital marketing, content marketing, SEO, and growth positions',
'icon' => 'megaphone',
],
[
'name' => 'Sales',
'slug' => 'sales',
'description' => 'Sales representatives, account executives, and business development roles',
'icon' => 'trending-up',
],
[
'name' => 'Customer Support',
'slug' => 'customer-support',
'description' => 'Customer service, technical support, and customer success positions',
'icon' => 'headset',
],
[
'name' => 'Human Resources',
'slug' => 'human-resources',
'description' => 'HR, recruiting, talent acquisition, and people operations',
'icon' => 'users',
],
[
'name' => 'Finance',
'slug' => 'finance',
'description' => 'Accounting, financial analysis, and finance management roles',
'icon' => 'dollar-sign',
],
[
'name' => 'Operations',
'slug' => 'operations',
'description' => 'Operations management, logistics, and project management',
'icon' => 'settings',
],
[
'name' => 'DevOps',
'slug' => 'devops',
'description' => 'Infrastructure, cloud engineering, SRE, and DevOps positions',
'icon' => 'server',
],
[
'name' => 'Product Management',
'slug' => 'product-management',
'description' => 'Product managers, product owners, and product leads',
'icon' => 'package',
],
[
'name' => 'Quality Assurance',
'slug' => 'quality-assurance',
'description' => 'QA engineers, testers, and quality assurance specialists',
'icon' => 'check-circle',
],
[
'name' => 'Writing',
'slug' => 'writing',
'description' => 'Content writers, copywriters, technical writers, and editors',
'icon' => 'pen',
],
[
'name' => 'Legal',
'slug' => 'legal',
'description' => 'Legal counsel, paralegals, and compliance positions',
'icon' => 'scale',
],
[
'name' => 'Healthcare',
'slug' => 'healthcare',
'description' => 'Healthcare, medical, and health-related positions',
'icon' => 'heart',
],
];
foreach ($categories as $category) {
JobCategory::updateOrCreate(
['slug' => $category['slug']],
$category
);
}
$this->command->info('Job categories seeded successfully!');
}
}