Completed
Branch feature/job-plans (e266db)
by Adam
05:31
created

Job::getPlanLengthAttribute()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
rs 10
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 Coyote\Services\Eloquent\HasMany;
10
use Illuminate\Database\Eloquent\Model;
11
use Illuminate\Database\Eloquent\SoftDeletes;
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 \Carbon\Carbon $boost_at
22
 * @property int $deadline
23
 * @property bool $is_expired
24
 * @property int $salary_from
25
 * @property int $salary_to
26
 * @property int $country_id
27
 * @property int $currency_id
28
 * @property int $is_remote
29
 * @property int $remote_range
30
 * @property int $enable_apply
31
 * @property int $visits
32
 * @property int $rate_id
33
 * @property bool $is_gross
34
 * @property int $employment_id
35
 * @property int $views
36
 * @property float $score
37
 * @property float $rank
38
 * @property string $slug
39
 * @property string $title
40
 * @property string $description
41
 * @property string $recruitment
42
 * @property string $requirements
43
 * @property string $email
44
 * @property User $user
45
 * @property Firm $firm
46
 * @property Tag[] $tags
47
 * @property Location[] $locations
48
 * @property Currency[] $currency
49
 * @property Feature[] $features
50
 * @property int $plan_id
51
 * @property bool $is_boost
52
 * @property bool $is_publish
53
 * @property bool $is_ads
54
 * @property bool $is_highlight
55
 * @property bool $is_on_top
56
 * @property Plan $plan
57
 */
58
class Job extends Model
59
{
60
    use SoftDeletes, ForUser;
61
    use Searchable {
62
        getIndexBody as parentGetIndexBody;
63
    }
64
65
    const MONTH           = 1;
66
    const YEAR            = 2;
67
    const WEEK            = 3;
68
    const HOUR            = 4;
69
70
    const STUDENT         = 1;
71
    const JUNIOR          = 2;
72
    const MID             = 3;
73
    const SENIOR          = 4;
74
    const LEAD            = 5;
75
    const MANAGER         = 6;
76
77
    const NET             = 0;
78
    const GROSS           = 1;
79
80
    /**
81
     * Filling each field adds points to job offer score.
82
     */
83
    const SCORE_CONFIG = [
84
        'job'             => ['salary_from' => 25, 'salary_to' => 25, 'city' => 15, 'seniority_id' => 5],
85
        'firm'            => ['name' => 15, 'logo' => 5, 'website' => 1, 'description' => 5]
86
    ];
87
88
    /**
89
     * The attributes that are mass assignable.
90
     *
91
     * @var array
92
     */
93
    protected $fillable = [
94
        'title',
95
        'description',
96
        'requirements',
97
        'recruitment',
98
        'is_remote',
99
        'is_gross',
100
        'remote_range',
101
        'country_id',
102
        'salary_from',
103
        'salary_to',
104
        'currency_id',
105
        'rate_id',
106
        'employment_id',
107
        'deadline', // column does not really exist in db (model attribute instead)
108
        'deadline_at',
109
        'email',
110
        'enable_apply',
111
        'seniority_id',
112
        'plan_id'
113
    ];
114
115
    /**
116
     * Default fields values.
117
     *
118
     * @var array
119
     */
120
    protected $attributes = [
121
        'enable_apply'      => true,
122
        'is_remote'         => false,
123
        'title'             => ''
124
    ];
125
126
    /**
127
     * Cast to when calling toArray() (for example before index in elasticsearch).
128
     *
129
     * @var array
130
     */
131
    protected $casts = [
132
        'is_remote'         => 'boolean',
133
        'is_boost'          => 'boolean',
134
        'is_gross'          => 'boolean',
135
        'is_publish'        => 'boolean',
136
        'is_ads'            => 'boolean',
137
        'is_highlight'      => 'boolean',
138
        'is_on_top'         => 'boolean'
139
    ];
140
141
    /**
142
     * @var string
143
     */
144
    protected $dateFormat = 'Y-m-d H:i:se';
145
146
    /**
147
     * @var array
148
     */
149
    protected $dates = ['created_at', 'updated_at', 'deadline_at', 'boost_at'];
150
151
    /**
152
     * @var array
153
     */
154
    protected $appends = ['deadline'];
155
156
    /**
157
     * Elasticsearch type mapping
158
     *
159
     * @var array
160
     */
161
    protected $mapping = [
162
        "id" => [
163
            "type" => "long"
164
        ],
165
        "locations" => [
166
            "type" => "nested",
167
            "properties" => [
168
                "city" => [
169
                    "type" => "string",
170
                    "analyzer" => "keyword_asciifolding_analyzer",
171
                    "fields" => [
172
                        "original" => ["type" => "text", "analyzer" => "keyword_analyzer", "fielddata" => true]
173
                    ]
174
                ],
175
                "coordinates" => [
176
                    "type" => "geo_point"
177
                ]
178
            ]
179
        ],
180
        "title" => [
181
            "type" => "text",
182
            "analyzer" => "default_analyzer"
183
        ],
184
        "description" => [
185
            "type" => "text",
186
            "analyzer" => "default_analyzer"
187
        ],
188
        "requirements" => [
189
            "type" => "text",
190
            "analyzer" => "default_analyzer"
191
        ],
192
        "is_remote" => [
193
            "type" => "boolean"
194
        ],
195
        "remote_range" => [
196
            "type" => "integer"
197
        ],
198
        "tags" => [
199
            "type" => "text",
200
            "fields" => [
201
                "original" => ["type" => "keyword"]
202
            ]
203
        ],
204
        "firm" => [
205
            "type" => "object",
206
            "properties" => [
207
                "name" => [
208
                    "type" => "text",
209
                    "analyzer" => "default_analyzer",
210
                    "fields" => [
211
                        // filtrujemy firmy po tym polu
212
                        "original" => ["type" => "text", "analyzer" => "keyword_analyzer", "fielddata" => true]
213
                    ]
214
                ],
215
                "slug" => [
216
                    "type" => "text",
217
                    "analyzer" => "keyword_analyzer"
218
                ]
219
            ]
220
        ],
221
        "created_at" => [
222
            "type" => "date",
223
            "format" => "yyyy-MM-dd HH:mm:ss"
224
        ],
225
        "updated_at" => [
226
            "type" => "date",
227
            "format" => "yyyy-MM-dd HH:mm:ss"
228
        ],
229
        "deadline_at" => [
230
            "type" => "date",
231
            "format" => "yyyy-MM-dd HH:mm:ss"
232
        ],
233
        "boost_at" => [
234
            "type" => "date",
235
            "format" => "yyyy-MM-dd HH:mm:ss"
236
        ],
237
        "salary" => [
238
            "type" => "float"
239
        ],
240
        "score" => [
241
            "type" => "long"
242
        ],
243
        "is_boost" => [
244
            "type" => "boolean"
245
        ],
246
        "is_publish" => [
247
            "type" => "boolean"
248
        ],
249
        "is_ads" => [
250
            "type" => "boolean"
251
        ],
252
        "is_on_top" => [
253
            "type" => "boolean"
254
        ],
255
        "is_highlight" => [
256
            "type" => "boolean"
257
        ]
258
    ];
259
260
    /**
261
     * We need to set firm id to null offer is private
262
     */
263
    public static function boot()
264
    {
265
        parent::boot();
266
267
        static::saving(function (Job $model) {
268
            // nullable column
269
            foreach (['firm_id', 'salary_from', 'salary_to', 'remote_range', 'seniority_id'] as $column) {
270
                if (empty($model->{$column})) {
271
                    $model->{$column} = null;
272
                }
273
            }
274
275
            $model->score = $model->getScore();
276
277
            // field must not be null
278
            $model->is_remote = (int) $model->is_remote;
279
        });
280
281
        static::creating(function (Job $model) {
282
            $model->boost_at = $model->freshTimestamp();
283
        });
284
    }
285
286
    /**
287
     * @return string[]
288
     */
289
    public static function getRatesList()
290
    {
291
        return [self::MONTH => 'miesięcznie', self::YEAR => 'rocznie', self::WEEK => 'tygodniowo', self::HOUR => 'godzinowo'];
292
    }
293
294
    /**
295
     * @return string[]
296
     */
297
    public static function getTaxList()
298
    {
299
        return [self::NET => 'netto', self::GROSS => 'brutto'];
300
    }
301
302
    /**
303
     * @return string[]
304
     */
305
    public static function getEmploymentList()
306
    {
307
        return [1 => 'Umowa o pracę', 2 => 'Umowa zlecenie', 3 => 'Umowa o dzieło', 4 => 'Kontrakt'];
308
    }
309
310
    /**
311
     * @return string[]
312
     */
313
    public static function getSeniorityList()
314
    {
315
        return [self::STUDENT => 'Stażysta', self::JUNIOR => 'Junior', self::MID => 'Mid-Level', self::SENIOR => 'Senior', self::LEAD => 'Lead', self::MANAGER => 'Manager'];
316
    }
317
318
    /**
319
     * @return array
320
     */
321
    public static function getRemoteRangeList()
322
    {
323
        $list = [];
324
325
        for ($i = 100; $i > 0; $i -= 10) {
326
            $list[$i] = "$i%";
327
        }
328
329
        return $list;
330
    }
331
332
    /**
333
     * @return int
334
     */
335
    public function getScore()
336
    {
337
        $score = 0;
338
339 View Code Duplication
        foreach (self::SCORE_CONFIG['job'] as $column => $point) {
340
            if (!empty($this->{$column})) {
341
                $score += $point;
342
            }
343
        }
344
345
        // 30 points maximum...
346
        $score += min(30, (count($this->tags()->get()) * 10));
347
        $score += min(50, count($this->features()->wherePivot('checked', true)->get()) * 5);
348
349
        if ($this->firm_id) {
350
            $firm = $this->firm;
351
352 View Code Duplication
            foreach (self::SCORE_CONFIG['firm'] as $column => $point) {
353
                if (!empty($firm->{$column})) {
354
                    $score += $point;
355
                }
356
            }
357
358
            $score += min(25, $firm->benefits()->count() * 5);
0 ignored issues
show
Documentation Bug introduced by
The method count does not exist on object<Coyote\Services\Eloquent\HasMany>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
359
            $score -= ($firm->is_agency * 15);
360
        } else {
361
            $score -= 15;
362
        }
363
364
        return max(1, $score); // score can't be negative. 1 point min for elasticsearch algorithm
365
    }
366
367
    /**
368
     * Scope for currently active job offers
369
     *
370
     * @param \Illuminate\Database\Query\Builder $query
371
     * @return \Illuminate\Database\Query\Builder
372
     */
373
    public function scopePriorDeadline($query)
374
    {
375
        return $query->where('deadline_at', '>', date('Y-m-d H:i:s'));
376
    }
377
378
    /**
379
     * @return HasMany
380
     */
381
    public function locations()
382
    {
383
        $instance = new Job\Location();
384
385
        return new HasMany($instance->newQuery(), $this, $instance->getTable() . '.' . $this->getForeignKey(), $this->getKeyName());
386
    }
387
388
    /**
389
     * @return \Illuminate\Database\Eloquent\Relations\MorphOne
390
     */
391
    public function page()
392
    {
393
        return $this->morphOne('Coyote\Page', 'content');
394
    }
395
396
    /**
397
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
398
     */
399
    public function firm()
400
    {
401
        return $this->belongsTo('Coyote\Firm');
402
    }
403
404
    /**
405
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
406
     */
407
    public function currency()
408
    {
409
        return $this->belongsTo('Coyote\Currency');
410
    }
411
412
    /**
413
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
414
     */
415
    public function referers()
416
    {
417
        return $this->hasMany('Coyote\Job\Referer');
418
    }
419
420
    /**
421
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
422
     */
423
    public function tags()
424
    {
425
        return $this->belongsToMany('Coyote\Tag', 'job_tags')->orderBy('order')->withPivot(['priority', 'order']);
426
    }
427
428
    /**
429
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
430
     */
431
    public function features()
432
    {
433
        return $this->belongsToMany('Coyote\Feature', 'job_features')->orderBy('order')->withPivot(['checked', 'value']);
434
    }
435
436
    /**
437
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
438
     */
439
    public function subscribers()
440
    {
441
        return $this->hasMany('Coyote\Job\Subscriber');
442
    }
443
444
    /**
445
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
446
     */
447
    public function applications()
448
    {
449
        return $this->hasMany('Coyote\Job\Application');
450
    }
451
452
    /**
453
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
454
     */
455
    public function user()
456
    {
457
        return $this->belongsTo('Coyote\User');
458
    }
459
460
    /**
461
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
462
     */
463
    public function country()
464
    {
465
        return $this->belongsTo('Coyote\Country');
466
    }
467
468
    /**
469
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
470
     */
471
    public function payments()
472
    {
473
        return $this->hasMany('Coyote\Payment');
474
    }
475
476
    /**
477
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
478
     */
479
    public function plan()
480
    {
481
        return $this->belongsTo(Plan::class);
482
    }
483
484
    /**
485
     * @param string $title
486
     */
487 View Code Duplication
    public function setTitleAttribute($title)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in 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...
488
    {
489
        $title = trim($title);
490
491
        $this->attributes['title'] = $title;
492
        $this->attributes['slug'] = str_slug($title, '_');
493
    }
494
495
    /**
496
     * @param string $value
497
     */
498
    public function setSalaryFromAttribute($value)
499
    {
500
        $this->attributes['salary_from'] = $value === null ? null : (int) trim($value);
501
    }
502
503
    /**
504
     * @param string $value
505
     */
506
    public function setSalaryToAttribute($value)
507
    {
508
        $this->attributes['salary_to'] = $value === null ? null : (int) trim($value);
509
    }
510
511
    /**
512
     * @param int $value
513
     */
514
    public function setDeadlineAttribute($value)
515
    {
516
        $this->attributes['deadline_at'] = Carbon::now()->addDay($value);
517
    }
518
519
    /**
520
     * @return int
521
     */
522
    public function getDeadlineAttribute()
523
    {
524
        return $this->deadline_at ? (new Carbon($this->deadline_at))->diff(Carbon::now(), false)->days + 1 : 90;
525
    }
526
527
    /**
528
     * @return bool
529
     */
530
    public function getIsExpiredAttribute()
531
    {
532
        return (new Carbon($this->deadline_at))->isPast();
533
    }
534
535
    /**
536
     * @return mixed
537
     */
538
    public function getCityAttribute()
539
    {
540
        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...
541
    }
542
543
    /**
544
     * @return string
545
     */
546
    public function getCurrencySymbolAttribute()
547
    {
548
        return $this->currency()->value('symbol');
549
    }
550
551
    /**
552
     * @param int $userId
553
     */
554
    public function setDefaultUserId($userId)
555
    {
556
        if (empty($this->user_id)) {
557
            $this->user_id = $userId;
558
        }
559
    }
560
561
    /**
562
     * @param mixed $features
563
     */
564
    public function setDefaultFeatures($features)
565
    {
566
        if (!count($this->features)) {
567
            foreach ($features as $feature) {
568
                $pivot = $this->features()->newPivot(['checked' => $feature->checked, 'value' => $feature->value]);
569
                $this->features->add($feature->setRelation('pivot', $pivot));
0 ignored issues
show
Bug introduced by
The method add cannot be called on $this->features (of type array<integer,object<Coyote\Feature>>).

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...
570
            }
571
        }
572
    }
573
574
    /**
575
     * @param int $planId
576
     */
577
    public function setDefaultPlanId($planId)
578
    {
579
        if (empty($this->plan_id)) {
580
            $this->plan_id = $planId;
581
        }
582
    }
583
584
    /**
585
     * @return bool
586
     */
587
    public function isPlanOngoing()
588
    {
589
        if (!$this->exists) {
590
            return false;
591
        }
592
593
        return $this->payments()->where('status_id', Payment::PAID)->where('ends_at', '>', Carbon::now())->exists();
594
    }
595
596
    /**
597
     * @return Payment
598
     */
599
    public function getUnpaidPayment()
600
    {
601
        return $this->payments()->where('status_id', Payment::NEW)->orderBy('created_at', 'DESC')->first();
602
    }
603
604
    /**
605
     * @return string|null
606
     */
607
    public function getPaymentUuid()
608
    {
609
        return $this->payments()->where('status_id', Payment::NEW)->orderBy('created_at', 'DESC')->first(['id']);
610
    }
611
612
    /**
613
     * @param string $url
614
     */
615
    public function addReferer($url)
616
    {
617
        if ($url && mb_strlen($url) < 200) {
618
            $referer = $this->referers()->firstOrNew(['url' => $url]);
619
620
            if (!$referer->id) {
621
                $referer->save();
622
            } else {
623
                $referer->increment('count');
624
            }
625
        }
626
    }
627
628
    /**
629
     * Check if user has applied for this job offer.
630
     *
631
     * @param int|null $userId
632
     * @param string $sessionId
633
     * @return boolean
634
     */
635
    public function hasApplied($userId, $sessionId)
636
    {
637
        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...
638
            return $this->applications()->forUser($userId)->exists();
639
        }
640
641
        return $this->applications()->where('session_id', $sessionId)->exists();
642
    }
643
644
    /**
645
     * @return array
646
     */
647
    protected function getIndexBody()
648
    {
649
        $this->setCharFilter(JobFilter::class);
650
        $body = $this->parentGetIndexBody();
651
652
        // maximum offered salary
653
        $salary = $this->monthlySalary(max($this->salary_from, $this->salary_to));
654
        $body = array_except($body, ['deleted_at', 'features']);
655
656
        $locations = [];
657
658
        // We need to transform locations to format acceptable by elasticsearch.
659
        // I'm talking here about the coordinates
660
        /** @var \Coyote\Job\Location $location */
661
        foreach ($this->locations()->get(['city', 'longitude', 'latitude']) as $location) {
0 ignored issues
show
Documentation Bug introduced by
The method get does not exist on object<Coyote\Services\Eloquent\HasMany>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
662
            $nested = ['city' => $location->city];
663
664
            if ($location->latitude && $location->longitude) {
665
                $nested['coordinates'] = [
666
                    'lat' => $location->latitude,
667
                    'lon' => $location->longitude
668
                ];
669
            }
670
671
            $locations[] = $nested;
672
        }
673
674
        // I don't know why elasticsearch skips documents with empty locations field when we use function_score.
675
        // That's why I add empty object (workaround).
676
        if (empty($locations)) {
677
            $locations[] = (object) [];
678
        }
679
680
        $body = array_merge($body, [
681
            // score must be int
682
            'score'             => (int) $body['score'],
683
            'locations'         => $locations,
684
            'salary'            => $salary,
685
            'salary_from'       => $this->monthlySalary($this->salary_from),
686
            'salary_to'         => $this->monthlySalary($this->salary_to),
687
            // yes, we index currency name so we don't have to look it up in database during search process
688
            'currency_symbol'   => $this->currency()->value('symbol'),
689
            // higher tag's priorities first
690
            'tags'              => $this->tags()->get(['name', 'priority'])->sortByDesc('pivot.priority')->pluck('name')->toArray(),
691
            // index null instead of 100 is job is not remote
692
            'remote_range'      => $this->is_remote ? $this->remote_range : null
693
        ]);
694
695
        if ($this->firm_id) {
696
            // logo is instance of File object. casting to string returns file name.
697
            // cast to (array) if firm is empty.
698
            $body['firm'] = array_map('strval', (array) array_only($this->firm->toArray(), ['name', 'logo', 'slug']));
699
        }
700
701
        return $body;
702
    }
703
704
    /**
705
     * @param float|null $salary
706
     * @return float|null
707
     */
708
    private function monthlySalary($salary)
709
    {
710
        if (empty($salary) || $this->rate_id === self::MONTH) {
711
            return $salary;
712
        }
713
714
        // we need to calculate monthly salary in order to sorting data by salary
715
        if ($this->rate_id == self::YEAR) {
716
            $salary = round($salary / 12);
717
        } elseif ($this->rate_id == self::WEEK) {
718
            $salary = round($salary * 4);
719
        } elseif ($this->rate_id == self::HOUR) {
720
            $salary = round($salary * 8 * 5 * 4);
721
        }
722
723
        return $salary;
724
    }
725
}
726