Completed
Branch job-vuejs (8df734)
by Adam
17:32
created

Job::boot()   B

Complexity

Conditions 4
Paths 1

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 11
nc 1
nop 0
dl 0
loc 22
rs 8.9197
c 0
b 0
f 0
1
<?php
2
3
namespace Coyote;
4
5
use Carbon\Carbon;
6
use Coyote\Job\Location;
7
use Coyote\Models\Scopes\ForUser;
8
use Coyote\Services\Elasticsearch\CharFilters\JobFilter;
9
use Illuminate\Database\Eloquent\Model;
10
use Illuminate\Database\Eloquent\SoftDeletes;
11
use Illuminate\Queue\SerializesModels;
12
13
/**
14
 * @property int $id
15
 * @property int $user_id
16
 * @property int $firm_id
17
 * @property \Carbon\Carbon $created_at
18
 * @property \Carbon\Carbon $updated_at
19
 * @property \Carbon\Carbon $deleted_at
20
 * @property \Carbon\Carbon $deadline_at
21
 * @property int $deadline
22
 * @property int $salary_from
23
 * @property int $salary_to
24
 * @property int $country_id
25
 * @property int $currency_id
26
 * @property int $is_remote
27
 * @property int $enable_apply
28
 * @property int $visits
29
 * @property int $rate_id
30
 * @property int $employment_id
31
 * @property int $views
32
 * @property float $score
33
 * @property float $rank
34
 * @property string $slug
35
 * @property string $title
36
 * @property string $description
37
 * @property string $recruitment
38
 * @property string $requirements
39
 * @property string $email
40
 * @property User $user
41
 * @property Firm $firm
42
 * @property Tag[] $tags
43
 * @property Location[] $locations
44
 */
45
class Job extends Model
46
{
47
    use SoftDeletes, ForUser;
48
    use Searchable {
49
        getIndexBody as parentGetIndexBody;
50
    }
51
52
    const MONTH           = 1;
53
    const YEAR            = 2;
54
    const WEEK            = 3;
55
    const HOUR            = 4;
56
57
    /**
58
     * Filling each field adds points to job offer score.
59
     */
60
    const SCORE_CONFIG = [
61
        'job' => ['description' => 10, 'salary_from' => 25, 'salary_to' => 25, 'city' => 15],
62
        'firm' => ['name' => 15, 'logo' => 5, 'website' => 1, 'description' => 5]
63
    ];
64
65
    /**
66
     * The attributes that are mass assignable.
67
     *
68
     * @var array
69
     */
70
    protected $fillable = [
71
        'title',
72
        'description',
73
        'requirements',
74
        'recruitment',
75
        'is_remote',
76
        'remote_range',
77
        'country_id',
78
        'salary_from',
79
        'salary_to',
80
        'currency_id',
81
        'rate_id',
82
        'employment_id',
83
        'deadline_at',
84
        'email',
85
        'enable_apply'
86
    ];
87
88
    /**
89
     * Default fields values.
90
     *
91
     * @var array
92
     */
93
    protected $attributes = [
94
        'enable_apply' => true,
95
        'is_remote' => false,
96
        'title' => ''
97
    ];
98
99
    /**
100
     * Cast to when calling toArray() (for example before index in elasticsearch).
101
     *
102
     * @var array
103
     */
104
    protected $casts = ['is_remote' => 'boolean', 'enable_apply' => 'boolean'];
105
106
    /**
107
     * @var string
108
     */
109
    protected $dateFormat = 'Y-m-d H:i:se';
110
111
    /**
112
     * @var array
113
     */
114
    protected $appends = ['deadline'];
115
116
    /**
117
     * Elasticsearch type mapping
118
     *
119
     * @var array
120
     */
121
    protected $mapping = [
122
        "id" => [
123
            "type" => "long"
124
        ],
125
        "locations" => [
126
            "type" => "nested",
127
            "properties" => [
128
                "city" => [
129
                    "type" => "string",
130
                    "analyzer" => "keyword_asciifolding_analyzer",
131
                    "fields" => [
132
                        "original" => ["type" => "text", "analyzer" => "keyword_analyzer", "fielddata" => true]
133
                    ]
134
                ],
135
                "coordinates" => [
136
                    "type" => "geo_point"
137
                ]
138
            ]
139
        ],
140
        "title" => [
141
            "type" => "text",
142
            "analyzer" => "default_analyzer"
143
        ],
144
        "description" => [
145
            "type" => "text",
146
            "analyzer" => "default_analyzer"
147
        ],
148
        "requirements" => [
149
            "type" => "text",
150
            "analyzer" => "default_analyzer"
151
        ],
152
        "is_remote" => [
153
            "type" => "boolean"
154
        ],
155
        "remote_range" => [
156
            "type" => "integer"
157
        ],
158
        "tags" => [
159
            "type" => "text",
160
            "fields" => [
161
                "original" => ["type" => "keyword"]
162
            ]
163
        ],
164
        "firm" => [
165
            "type" => "object",
166
            "properties" => [
167
                "name" => [
168
                    "type" => "text",
169
                    "analyzer" => "default_analyzer",
170
                    "fields" => [
171
                        // filtrujemy firmy po tym polu
172
                        "original" => ["type" => "text", "analyzer" => "keyword_analyzer", "fielddata" => true]
173
                    ]
174
                ]
175
            ]
176
        ],
177
        "created_at" => [
178
            "type" => "date",
179
            "format" => "yyyy-MM-dd HH:mm:ss"
180
        ],
181
        "updated_at" => [
182
            "type" => "date",
183
            "format" => "yyyy-MM-dd HH:mm:ss"
184
        ],
185
        "deadline_at" => [
186
            "type" => "date",
187
            "format" => "yyyy-MM-dd HH:mm:ss"
188
        ],
189
        "salary" => [
190
            "type" => "float"
191
        ],
192
        "score" => [
193
            "type" => "long"
194
        ],
195
        "rank" => [
196
            "type" => "float"
197
        ]
198
    ];
199
200
    /**
201
     * We need to set firm id to null offer is private
202
     */
203
    public static function boot()
204
    {
205
        parent::boot();
206
207
        static::saving(function (Job $model) {
208
            // nullable column
209
            foreach (['firm_id', 'salary_from', 'salary_to', 'remote_range'] as $column) {
210
                if (empty($model->{$column})) {
211
                    $model->{$column} = null;
212
                }
213
            }
214
215
            $model->score = $model->getScore();
216
            $timestamp = $model->created_at ? strtotime($model->created_at) : time();
217
218
            $seconds = ($timestamp - 1380585600) / 35000;
219
            $model->rank = number_format($model->score + $seconds, 6, '.', '');
220
221
            // field must not be null
222
            $model->is_remote = (int) $model->is_remote;
223
        });
224
    }
225
226
    /**
227
     * @return string[]
228
     */
229
    public static function getRatesList()
230
    {
231
        return [self::MONTH => 'miesięcznie', self::YEAR => 'rocznie', self::WEEK => 'tygodniowo', self::HOUR => 'godzinowo'];
232
    }
233
234
    /**
235
     * @return string[]
236
     */
237
    public static function getEmploymentList()
238
    {
239
        return [1 => 'Umowa o pracę', 2 => 'Umowa zlecenie', 3 => 'Umowa o dzieło', 4 => 'Kontrakt'];
240
    }
241
242
    /**
243
     * @return array
244
     */
245
    public static function getRemoteRangeList()
246
    {
247
        $list = [];
248
249
        for ($i = 100; $i >= 0; $i -= 10) {
250
            $list[$i] = "$i%";
251
        }
252
253
        return $list;
254
    }
255
256
    /**
257
     * @return int
258
     */
259
    public function getScore()
260
    {
261
        $score = 0;
262
263 View Code Duplication
        foreach (self::SCORE_CONFIG['job'] as $column => $point) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
264
            if (!empty($this->{$column})) {
265
                $score += $point;
266
            }
267
        }
268
269
        // 30 points maximum...
270
        $score += min(30, (count($this->tags()->get()) * 10));
271
272
        if ($this->firm_id) {
273
            $firm = $this->firm;
274
275 View Code Duplication
            foreach (self::SCORE_CONFIG['firm'] as $column => $point) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
276
                if (!empty($firm->{$column})) {
277
                    $score += $point;
278
                }
279
            }
280
281
            $score += min(25, $firm->benefits()->count() * 5);
282
            $score -= ($firm->is_agency * 15);
283
        } else {
284
            $score -= 15;
285
        }
286
287
        return max(0, $score); // score can't be negative
288
    }
289
290
    /**
291
     * Scope for currently active job offers
292
     *
293
     * @param \Illuminate\Database\Query\Builder $query
294
     * @return \Illuminate\Database\Query\Builder
295
     */
296
    public function scopePriorDeadline($query)
297
    {
298
        return $query->where('deadline_at', '>', date('Y-m-d H:i:s'));
299
    }
300
301
    /**
302
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
303
     */
304
    public function locations()
305
    {
306
        return $this->hasMany('Coyote\Job\Location');
307
    }
308
309
    /**
310
     * @return \Illuminate\Database\Eloquent\Relations\MorphOne
311
     */
312
    public function page()
313
    {
314
        return $this->morphOne('Coyote\Page', 'content');
315
    }
316
317
    /**
318
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
319
     */
320
    public function firm()
321
    {
322
        return $this->belongsTo('Coyote\Firm');
323
    }
324
325
    /**
326
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
327
     */
328
    public function currency()
329
    {
330
        return $this->belongsTo('Coyote\Currency');
331
    }
332
333
    /**
334
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
335
     */
336
    public function referers()
337
    {
338
        return $this->hasMany('Coyote\Job\Referer');
339
    }
340
341
    /**
342
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
343
     */
344
    public function tags()
345
    {
346
        return $this->belongsToMany('Coyote\Tag', 'job_tags')->orderBy('order')->withPivot(['priority', 'order']);
347
    }
348
349
    /**
350
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
351
     */
352
    public function subscribers()
353
    {
354
        return $this->hasMany('Coyote\Job\Subscriber');
355
    }
356
357
    /**
358
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
359
     */
360
    public function applications()
361
    {
362
        return $this->hasMany('Coyote\Job\Application');
363
    }
364
365
    /**
366
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
367
     */
368
    public function user()
369
    {
370
        return $this->belongsTo('Coyote\User');
371
    }
372
373
    /**
374
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
375
     */
376
    public function country()
377
    {
378
        return $this->belongsTo('Coyote\Country');
379
    }
380
381
    /**
382
     * @param string $title
383
     */
384
    public function setTitleAttribute($title)
385
    {
386
        $title = trim($title);
387
388
        $this->attributes['title'] = $title;
389
        $this->attributes['slug'] = str_slug($title, '_');
390
    }
391
392
    /**
393
     * @param string $value
394
     */
395
    public function setSalaryFromAttribute($value)
396
    {
397
        $this->attributes['salary_from'] = $value === null ? null : (int) trim($value);
398
    }
399
400
    /**
401
     * @param string $value
402
     */
403
    public function setSalaryToAttribute($value)
404
    {
405
        $this->attributes['salary_to'] = $value === null ? null : (int) trim($value);
406
    }
407
408
    /**
409
     * @param int $value
410
     */
411
    public function setDeadlineAttribute($value)
412
    {
413
        $this->attributes['deadline_at'] = Carbon::now()->addDay($value);
414
    }
415
416
    /**
417
     * @return int
418
     */
419
    public function getDeadlineAttribute()
420
    {
421
        return $this->deadline_at ? (new Carbon($this->deadline_at))->diff(Carbon::now())->days : 90;
422
    }
423
424
    /**
425
     * @return mixed
426
     */
427
    public function getCityAttribute()
428
    {
429
        return $this->locations->implode('city', ', ');
0 ignored issues
show
Bug introduced by
The method implode cannot be called on $this->locations (of type array<integer,object<Coyote\Job\Location>>).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
430
    }
431
432
    /**
433
     * @param int $userId
434
     */
435
    public function setDefaultUserId($userId)
436
    {
437
        if (empty($this->user_id)) {
438
            $this->user_id = $userId;
439
        }
440
    }
441
442
    /**
443
     * @param string $url
444
     */
445
    public function addReferer($url)
446
    {
447
        if ($url && mb_strlen($url) < 200) {
448
            $referer = $this->referers()->firstOrNew(['url' => $url]);
449
450
            if (!$referer->id) {
451
                $referer->save();
452
            } else {
453
                $referer->increment('count');
454
            }
455
        }
456
    }
457
458
    /**
459
     * Check if user has applied for this job offer.
460
     *
461
     * @param int|null $userId
462
     * @param string $sessionId
463
     * @return boolean
464
     */
465
    public function hasApplied($userId, $sessionId)
466
    {
467
        if ($userId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $userId of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
468
            return $this->applications()->forUser($userId)->exists();
469
        }
470
471
        return $this->applications()->where('session_id', $sessionId)->exists();
472
    }
473
474
    /**
475
     * @return array
476
     */
477
    protected function getIndexBody()
478
    {
479
        $this->setCharFilter(JobFilter::class);
480
        $body = $this->parentGetIndexBody();
481
482
        // maximum offered salary
483
        $salary = $this->monthlySalary(max($this->salary_from, $this->salary_to));
484
        $body = array_except($body, ['deleted_at', 'enable_apply']);
485
486
        $locations = [];
487
488
        // We need to transform locations to format acceptable by elasticsearch.
489
        // I'm talking here about the coordinates
490
        /** @var \Coyote\Job\Location $location */
491
        foreach ($this->locations()->get(['city', 'longitude', 'latitude']) as $location) {
492
            $nested = ['city' => $location->city];
493
494
            if ($location->latitude && $location->longitude) {
495
                $nested['coordinates'] = [
496
                    'lat' => $location->latitude,
497
                    'lon' => $location->longitude
498
                ];
499
            }
500
501
            $locations[] = $nested;
502
        }
503
504
        $body['score'] = intval($body['score']);
505
506
        $body = array_merge($body, [
507
            'locations'         => $locations,
508
            'salary'            => $salary,
509
            'salary_from'       => $this->monthlySalary($this->salary_from),
510
            'salary_to'         => $this->monthlySalary($this->salary_to),
511
            // yes, we index currency name so we don't have to look it up in database during search process
512
            'currency_name'     => $this->currency()->value('name'),
513
            // higher tag's priorities first
514
            'tags'              => $this->tags()->get(['name', 'priority'])->sortByDesc('pivot.priority')->pluck('name')
515
        ]);
516
517
        if (!empty($body['firm'])) {
518
            // logo is instance of File object. casting to string returns file name.
519
            // cast to (array) if firm is empty.
520
            $body['firm'] = array_map('strval', (array) array_only($body['firm'], ['name', 'logo']));
521
        }
522
523
        return $body;
524
    }
525
526
    /**
527
     * @param float|null $salary
528
     * @return float|null
529
     */
530
    private function monthlySalary($salary)
531
    {
532
        if (empty($salary) || $this->rate_id === self::MONTH) {
533
            return $salary;
534
        }
535
536
        // we need to calculate monthly salary in order to sorting data by salary
537
        if ($this->rate_id == self::YEAR) {
538
            $salary = round($salary / 12);
539
        } elseif ($this->rate_id == self::WEEK) {
540
            $salary = round($salary * 4);
541
        } elseif ($this->rate_id == self::HOUR) {
542
            $salary = round($salary * 8 * 5 * 4);
543
        }
544
545
        return $salary;
546
    }
547
}
548