|
|
<?php
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
use App\Support\ProjectCode;
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
|
|
class Application extends Model
|
|
|
{
|
|
|
public const EXCLUDE_SOFT_DELETED_SCOPE = 'excludeSoftDeleted';
|
|
|
|
|
|
protected $fillable = [
|
|
|
'project_code',
|
|
|
'user_id',
|
|
|
'competition_id',
|
|
|
'signup_channel_id',
|
|
|
'signup_channel_code',
|
|
|
'signup_channel_state',
|
|
|
'signup_channel_entered_at',
|
|
|
'recommend',
|
|
|
'status',
|
|
|
'signup_locale',
|
|
|
'player_name',
|
|
|
'school',
|
|
|
'degree',
|
|
|
'contact_email',
|
|
|
'contact_mobile',
|
|
|
'company_name',
|
|
|
'project_name',
|
|
|
'track',
|
|
|
'location_country',
|
|
|
'location_province',
|
|
|
'location_city',
|
|
|
'oversea_country',
|
|
|
'event_location',
|
|
|
'team_members',
|
|
|
'intro',
|
|
|
'answers_json',
|
|
|
'promise_signed_at',
|
|
|
'promise_signature',
|
|
|
'submitted_at',
|
|
|
'review_result',
|
|
|
'review_result_note',
|
|
|
'review_result_at',
|
|
|
'review_eligible',
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* 非选手报名表单项、由系统维护的 applications 列(其余 fillable 列均可作为 schema 标量字段)。
|
|
|
*
|
|
|
* @var list<string>
|
|
|
*/
|
|
|
public const SIGNUP_SCALAR_FILLABLE_EXCLUDE = [
|
|
|
'project_code',
|
|
|
'user_id',
|
|
|
'competition_id',
|
|
|
'signup_channel_id',
|
|
|
'signup_channel_code',
|
|
|
'signup_channel_state',
|
|
|
'signup_channel_entered_at',
|
|
|
'status',
|
|
|
'signup_locale',
|
|
|
'promise_signed_at',
|
|
|
'promise_signature',
|
|
|
'submitted_at',
|
|
|
'review_result',
|
|
|
'review_result_note',
|
|
|
'review_result_at',
|
|
|
'review_eligible',
|
|
|
'answers_json',
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* 报名表 schema 可绑定的标量列(与 $fillable 同步,新增列只需迁移 + fillable)。
|
|
|
*
|
|
|
* @return list<string>
|
|
|
*/
|
|
|
public static function signupScalarFillableKeys(): array
|
|
|
{
|
|
|
$exclude = array_flip(self::SIGNUP_SCALAR_FILLABLE_EXCLUDE);
|
|
|
$keys = [];
|
|
|
foreach ((new static)->getFillable() as $key) {
|
|
|
if (! isset($exclude[$key])) {
|
|
|
$keys[] = $key;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return $keys;
|
|
|
}
|
|
|
|
|
|
protected $casts = [
|
|
|
'promise_signed_at' => 'datetime',
|
|
|
'submitted_at' => 'datetime',
|
|
|
'signup_channel_entered_at' => 'datetime',
|
|
|
'deleted_at' => 'datetime',
|
|
|
'review_result_at' => 'datetime',
|
|
|
'review_eligible' => 'boolean',
|
|
|
'answers_json' => 'array',
|
|
|
];
|
|
|
|
|
|
/** 是否出现在评审端列表(未设置时默认参与) */
|
|
|
public function isReviewEligible(): bool
|
|
|
{
|
|
|
return (bool) ($this->review_eligible ?? true);
|
|
|
}
|
|
|
|
|
|
public static function hasDeletedAtColumn(): bool
|
|
|
{
|
|
|
return Schema::hasColumn((new static)->getTable(), 'deleted_at');
|
|
|
}
|
|
|
|
|
|
protected static function booted(): void
|
|
|
{
|
|
|
static::addGlobalScope(self::EXCLUDE_SOFT_DELETED_SCOPE, function (Builder $builder): void {
|
|
|
if (! static::hasDeletedAtColumn()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
$builder->whereNull($builder->getModel()->qualifyColumn('deleted_at'));
|
|
|
});
|
|
|
|
|
|
static::created(function (Application $application): void {
|
|
|
if (! Schema::hasColumn($application->getTable(), 'project_code')) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (trim((string) ($application->project_code ?? '')) === '') {
|
|
|
$application->forceFill([
|
|
|
'project_code' => ProjectCode::forApplication($application),
|
|
|
])->saveQuietly();
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
public static function firstOrCreateDraft(int $userId, int $competitionId): self
|
|
|
{
|
|
|
if (static::hasDeletedAtColumn()) {
|
|
|
$trashed = static::withoutGlobalScope(self::EXCLUDE_SOFT_DELETED_SCOPE)
|
|
|
->where('user_id', $userId)
|
|
|
->where('competition_id', $competitionId)
|
|
|
->whereNotNull('deleted_at')
|
|
|
->first();
|
|
|
|
|
|
if ($trashed !== null) {
|
|
|
$trashed->forceFill([
|
|
|
'deleted_at' => null,
|
|
|
'status' => 'draft',
|
|
|
])->save();
|
|
|
|
|
|
return $trashed->fresh();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return static::query()->firstOrCreate(
|
|
|
[
|
|
|
'user_id' => $userId,
|
|
|
'competition_id' => $competitionId,
|
|
|
],
|
|
|
['status' => 'draft']
|
|
|
);
|
|
|
}
|
|
|
|
|
|
public function user(): BelongsTo
|
|
|
{
|
|
|
return $this->belongsTo(User::class);
|
|
|
}
|
|
|
|
|
|
public function competition(): BelongsTo
|
|
|
{
|
|
|
return $this->belongsTo(Competition::class);
|
|
|
}
|
|
|
|
|
|
public function signupChannel(): BelongsTo
|
|
|
{
|
|
|
return $this->belongsTo(SignupChannel::class);
|
|
|
}
|
|
|
|
|
|
public function files(): HasMany
|
|
|
{
|
|
|
return $this->hasMany(ApplicationFile::class);
|
|
|
}
|
|
|
|
|
|
public function reviewRecords(): HasMany
|
|
|
{
|
|
|
return $this->hasMany(ApplicationReviewRecord::class);
|
|
|
}
|
|
|
|
|
|
public function reviewScores(): HasMany
|
|
|
{
|
|
|
return $this->hasMany(ApplicationReviewScore::class);
|
|
|
}
|
|
|
|
|
|
public function isSubmitted(): bool
|
|
|
{
|
|
|
return $this->status === 'submitted';
|
|
|
}
|
|
|
|
|
|
/** 评委已产生评审记录或打分后,选手端不可再修改报名/附件 */
|
|
|
public function isSignupLockedByReview(): bool
|
|
|
{
|
|
|
return $this->reviewRecords()->exists() || $this->reviewScores()->exists();
|
|
|
}
|
|
|
|
|
|
/** 已超过赛事设置的报名截止时间(未设置截止时间则不据此拦截) */
|
|
|
public function isPastSignupCloseForCompetition(): bool
|
|
|
{
|
|
|
$this->loadMissing('competition');
|
|
|
$close = $this->competition?->signup_close_at;
|
|
|
|
|
|
return $close !== null && now()->isAfter($close);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 选手是否仍可编辑报名表:报名截止时间内且评委尚未评审;草稿与已提交均可继续改报直至截止或评审落库。
|
|
|
*/
|
|
|
public function participantMayEditSignup(): bool
|
|
|
{
|
|
|
if ($this->isSignupLockedByReview()) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
return ! $this->isPastSignupCloseForCompetition();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 选手是否可上传/删除附件:截止前与表单一致;截止后仅当赛事开启补传且登录手机号在白名单。
|
|
|
* 已有评审/打分时始终不可改附件。
|
|
|
*/
|
|
|
public function participantMayEditFiles(): bool
|
|
|
{
|
|
|
if ($this->isSignupLockedByReview()) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
if (! $this->isPastSignupCloseForCompetition()) {
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
return $this->isAllowlistedForPostCloseFileEdit();
|
|
|
}
|
|
|
|
|
|
public function isAllowlistedForPostCloseFileEdit(): bool
|
|
|
{
|
|
|
$this->loadMissing(['competition', 'user']);
|
|
|
|
|
|
return $this->competition?->allowsPostCloseFileEditForMobile($this->user?->mobile) ?? false;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param 'status'|'file' $field 422 时 errors 字段名(报名表用 status,附件接口用 file)
|
|
|
*/
|
|
|
public function assertMayEditSignup(string $field = 'status'): void
|
|
|
{
|
|
|
if ($this->isSignupLockedByReview()) {
|
|
|
$msg = $field === 'file'
|
|
|
? '已有评委评审或打分记录,不可再上传或删除附件'
|
|
|
: '已有评委评审或打分记录,报名内容不可再修改';
|
|
|
|
|
|
throw ValidationException::withMessages([$field => [$msg]]);
|
|
|
}
|
|
|
|
|
|
if ($this->isPastSignupCloseForCompetition()) {
|
|
|
if ($field === 'file' && $this->isAllowlistedForPostCloseFileEdit()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
$msg = $field === 'file'
|
|
|
? '报名已截止,不可再上传或删除附件'
|
|
|
: '报名已截止,无法再修改或提交报名';
|
|
|
|
|
|
throw ValidationException::withMessages([$field => [$msg]]);
|
|
|
}
|
|
|
}
|
|
|
}
|