*/ 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 */ 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]]); } } }