|
|
<?php
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
use App\Models\Application;
|
|
|
use App\Models\ApplicationFile;
|
|
|
use App\Models\ApplicationReviewRecord;
|
|
|
use App\Models\ApplicationReviewScore;
|
|
|
use App\Models\Competition;
|
|
|
use App\Models\CompetitionTrack;
|
|
|
use App\Models\FormSchemaDefinition;
|
|
|
use App\Models\Reviewer;
|
|
|
use App\Models\ReviewerScope;
|
|
|
use App\Support\DefaultReviewSchema;
|
|
|
use Carbon\CarbonInterface;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
|
|
class ReviewApplicationController extends Controller
|
|
|
{
|
|
|
/** 与中国赛区运营/选手感知一致(库内可为 UTC,接口展示统一东八区) */
|
|
|
private const DISPLAY_TIMEZONE = 'Asia/Shanghai';
|
|
|
|
|
|
/**
|
|
|
* 评审员可见的报名列表(按 reviewer_scopes 限制赛道;仅已提交)。
|
|
|
*/
|
|
|
public function index(Request $request): JsonResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
'keyword' => ['nullable', 'string', 'max:200'],
|
|
|
'track_code' => ['nullable', 'string', 'max:64'],
|
|
|
'page' => ['nullable', 'integer', 'min:1'],
|
|
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
|
]);
|
|
|
|
|
|
$competition = Competition::query()
|
|
|
->where('slug', $data['competition_slug'])
|
|
|
->where('published', true)
|
|
|
->firstOrFail();
|
|
|
|
|
|
$allowedCodes = ReviewerScope::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->pluck('track_code')
|
|
|
->unique()
|
|
|
->filter()
|
|
|
->values();
|
|
|
|
|
|
if ($allowedCodes->isEmpty()) {
|
|
|
abort(403, '您暂无本场赛事的评审范围');
|
|
|
}
|
|
|
|
|
|
$tracksMeta = CompetitionTrack::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->whereIn('track_code', $allowedCodes)
|
|
|
->orderBy('sort')
|
|
|
->orderBy('id')
|
|
|
->get(['id', 'track_code', 'title']);
|
|
|
|
|
|
$trackTitleByCode = $tracksMeta->keyBy('track_code');
|
|
|
|
|
|
$q = Application::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('status', 'submitted')
|
|
|
->whereIn('track', $allowedCodes);
|
|
|
|
|
|
if (! empty($data['track_code'])) {
|
|
|
if (! $allowedCodes->contains($data['track_code'])) {
|
|
|
abort(422, '无效赛道');
|
|
|
}
|
|
|
$q->where('track', $data['track_code']);
|
|
|
}
|
|
|
|
|
|
if (isset($data['keyword']) && $data['keyword'] !== null && trim($data['keyword']) !== '') {
|
|
|
$raw = trim($data['keyword']);
|
|
|
$kw = '%'.addcslashes($raw, '%_\\').'%';
|
|
|
$q->where(function ($w) use ($kw): void {
|
|
|
$w->where('player_name', 'like', $kw)
|
|
|
->orWhere('project_name', 'like', $kw)
|
|
|
->orWhere('school', 'like', $kw)
|
|
|
->orWhere('company_name', 'like', $kw)
|
|
|
->orWhere('contact_mobile', 'like', $kw)
|
|
|
->orWhereRaw(
|
|
|
"CONCAT_WS(' ', COALESCE(location_country,''), COALESCE(location_province,''), COALESCE(location_city,''), COALESCE(oversea_country,'')) LIKE ?",
|
|
|
[$kw]
|
|
|
);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
$perPage = isset($data['per_page']) ? (int) $data['per_page'] : 20;
|
|
|
$paginator = $q->orderByDesc('submitted_at')
|
|
|
->orderByDesc('id')
|
|
|
->paginate($perPage);
|
|
|
|
|
|
$appIds = collect($paginator->items())->pluck('id')->all();
|
|
|
$scoresByAppId = ApplicationReviewScore::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->whereIn('application_id', $appIds)
|
|
|
->get()
|
|
|
->keyBy('application_id');
|
|
|
|
|
|
$rows = [];
|
|
|
foreach ($paginator->items() as $app) {
|
|
|
/** @var Application $app */
|
|
|
$rows[] = $this->serializeListRow($app, $trackTitleByCode, $scoresByAppId->get($app->id));
|
|
|
}
|
|
|
|
|
|
return response()->json([
|
|
|
'data' => $rows,
|
|
|
'meta' => [
|
|
|
'current_page' => $paginator->currentPage(),
|
|
|
'last_page' => $paginator->lastPage(),
|
|
|
'per_page' => $paginator->perPage(),
|
|
|
'total' => $paginator->total(),
|
|
|
],
|
|
|
'tracks' => $tracksMeta->map(fn (CompetitionTrack $t) => [
|
|
|
'track_code' => $t->track_code,
|
|
|
'title' => $t->title,
|
|
|
])->values(),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 单条报名详情(评审端只读)。
|
|
|
*/
|
|
|
public function show(Request $request, Application $application): JsonResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
]);
|
|
|
|
|
|
$competition = Competition::query()
|
|
|
->where('slug', $data['competition_slug'])
|
|
|
->where('published', true)
|
|
|
->firstOrFail();
|
|
|
|
|
|
if ($application->competition_id !== $competition->id) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if ($application->status !== 'submitted') {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
$hasScope = ReviewerScope::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->exists();
|
|
|
|
|
|
if (! $hasScope) {
|
|
|
abort(403, '您无权查看该项目');
|
|
|
}
|
|
|
|
|
|
$application->load(['files']);
|
|
|
|
|
|
$schema = $this->resolveEffectiveReviewSchema($competition);
|
|
|
|
|
|
$myScore = ApplicationReviewScore::query()
|
|
|
->where('application_id', $application->id)
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->first();
|
|
|
|
|
|
$trackTitle = CompetitionTrack::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->value('title');
|
|
|
|
|
|
return response()->json([
|
|
|
'data' => $this->serializeDetail(
|
|
|
$application,
|
|
|
(string) ($trackTitle ?? $application->track ?? ''),
|
|
|
$competition,
|
|
|
$schema,
|
|
|
$myScore
|
|
|
),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 评审员提交或更新本人对该报名的打分(payload 与赛事生效的评审 Schema 一致)。
|
|
|
*/
|
|
|
public function submitScore(Request $request, Application $application): JsonResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
'payload' => ['required', 'array'],
|
|
|
]);
|
|
|
|
|
|
$competition = Competition::query()
|
|
|
->where('slug', $data['competition_slug'])
|
|
|
->where('published', true)
|
|
|
->firstOrFail();
|
|
|
|
|
|
if ($application->competition_id !== $competition->id) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if ($application->status !== 'submitted') {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
$hasScope = ReviewerScope::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->exists();
|
|
|
|
|
|
if (! $hasScope) {
|
|
|
abort(403, '您无权评审该项目');
|
|
|
}
|
|
|
|
|
|
$schema = $this->resolveEffectiveReviewSchema($competition);
|
|
|
|
|
|
$normalized = $this->normalizePayload($schema['schema_json'], $data['payload']);
|
|
|
$lineTotal = $this->sumNumberScores($schema['schema_json'], $normalized);
|
|
|
|
|
|
DB::transaction(function () use ($application, $reviewer, $schema, $normalized, $lineTotal): void {
|
|
|
ApplicationReviewScore::query()->updateOrCreate(
|
|
|
[
|
|
|
'application_id' => $application->id,
|
|
|
'reviewer_id' => $reviewer->id,
|
|
|
],
|
|
|
[
|
|
|
'review_schema_id' => $schema['id'],
|
|
|
'payload_json' => $normalized,
|
|
|
'line_total' => $lineTotal,
|
|
|
]
|
|
|
);
|
|
|
|
|
|
ApplicationReviewRecord::query()->firstOrCreate(
|
|
|
['application_id' => $application->id]
|
|
|
);
|
|
|
});
|
|
|
|
|
|
$myScore = ApplicationReviewScore::query()
|
|
|
->where('application_id', $application->id)
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->first();
|
|
|
|
|
|
return response()->json([
|
|
|
'message' => '评分已保存',
|
|
|
'data' => [
|
|
|
'my_review_score' => $this->serializeMyReviewScore($myScore),
|
|
|
'score_display' => $this->formatScoreDisplay(true, $lineTotal),
|
|
|
'score_is_pending' => false,
|
|
|
],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 评审端下载附件(Content-Disposition 使用用户上传时的原始文件名)。
|
|
|
*/
|
|
|
public function downloadFile(Request $request, Application $application, ApplicationFile $file): StreamedResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
]);
|
|
|
|
|
|
$competition = Competition::query()
|
|
|
->where('slug', $data['competition_slug'])
|
|
|
->where('published', true)
|
|
|
->firstOrFail();
|
|
|
|
|
|
if ($application->competition_id !== $competition->id || $file->application_id !== $application->id) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if ($application->status !== 'submitted') {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
$hasScope = ReviewerScope::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->exists();
|
|
|
|
|
|
if (! $hasScope) {
|
|
|
abort(403, '您无权下载该附件');
|
|
|
}
|
|
|
|
|
|
return Storage::disk($file->disk)->download(
|
|
|
$file->path,
|
|
|
$this->safeDownloadFilename($file->original_name),
|
|
|
['Cache-Control' => 'private, no-store']
|
|
|
);
|
|
|
}
|
|
|
|
|
|
private function safeDownloadFilename(?string $originalName): string
|
|
|
{
|
|
|
$base = $originalName !== null && trim($originalName) !== '' ? basename(str_replace(['\\', '/'], '_', $originalName)) : '附件';
|
|
|
|
|
|
return $base !== '' ? $base : '附件';
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param \Illuminate\Support\Collection<string, CompetitionTrack> $trackTitleByCode
|
|
|
*/
|
|
|
private function serializeListRow(Application $app, $trackTitleByCode, ?ApplicationReviewScore $myScore): array
|
|
|
{
|
|
|
$code = $app->track ?? '';
|
|
|
$trackTitle = $trackTitleByCode->get($code)?->title ?? $code;
|
|
|
|
|
|
$hasMine = $myScore !== null;
|
|
|
$line = $myScore?->line_total;
|
|
|
|
|
|
return [
|
|
|
'id' => $app->id,
|
|
|
'project_code' => $this->formatProjectCode($app),
|
|
|
'project_name' => $app->project_name ?? '',
|
|
|
'player_name' => $app->player_name ?? '',
|
|
|
'location_label' => $this->formatLocation($app),
|
|
|
'school' => $app->school ?? '',
|
|
|
'degree' => $app->degree ?? '',
|
|
|
'track_code' => $code,
|
|
|
'track_title' => $trackTitle,
|
|
|
'score_display' => $this->formatScoreDisplay($hasMine, $line),
|
|
|
'score_is_pending' => ! $hasMine,
|
|
|
'manage_status' => null,
|
|
|
'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at),
|
|
|
'competition_id' => $app->competition_id,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array{id: int|null, schema_json: array<int, mixed>, is_default: bool} $schema
|
|
|
*/
|
|
|
private function serializeDetail(
|
|
|
Application $app,
|
|
|
string $trackTitle,
|
|
|
Competition $competition,
|
|
|
array $schema,
|
|
|
?ApplicationReviewScore $myScore
|
|
|
): array {
|
|
|
$hasMine = $myScore !== null;
|
|
|
$line = $myScore?->line_total;
|
|
|
|
|
|
return [
|
|
|
'id' => $app->id,
|
|
|
'project_code' => $this->formatProjectCode($app),
|
|
|
'project_name' => $app->project_name ?? '',
|
|
|
'player_name' => $app->player_name ?? '',
|
|
|
'school' => $app->school ?? '',
|
|
|
'degree' => $app->degree ?? '',
|
|
|
'contact_email' => $app->contact_email ?? '',
|
|
|
'contact_mobile' => $app->contact_mobile ?? '',
|
|
|
'company_name' => $app->company_name ?? '',
|
|
|
'track_code' => $app->track ?? '',
|
|
|
'track_title' => $trackTitle,
|
|
|
'location_country' => $app->location_country ?? '',
|
|
|
'location_province' => $app->location_province ?? '',
|
|
|
'location_city' => $app->location_city ?? '',
|
|
|
'oversea_country' => $app->oversea_country ?? '',
|
|
|
'location_label' => $this->formatLocation($app),
|
|
|
'intro' => $app->intro ?? '',
|
|
|
'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at),
|
|
|
'score_display' => $this->formatScoreDisplay($hasMine, $line),
|
|
|
'score_is_pending' => ! $hasMine,
|
|
|
'review_schema' => [
|
|
|
'id' => $schema['id'],
|
|
|
'schema_json' => $schema['schema_json'],
|
|
|
'is_default' => $schema['is_default'],
|
|
|
],
|
|
|
'my_review_score' => $this->serializeMyReviewScore($myScore),
|
|
|
'pledge_content_html' => $competition->pledge_content_html ?? '',
|
|
|
'promise_signature' => $app->promise_signature,
|
|
|
'files' => $app->files->map(fn ($f) => [
|
|
|
'id' => $f->id,
|
|
|
'kind' => $f->kind,
|
|
|
'original_name' => $f->original_name,
|
|
|
'size' => $f->size,
|
|
|
'url' => $f->publicUrl(),
|
|
|
])->values(),
|
|
|
'promise_signed_at' => $this->formatSubmittedAtForDisplay($app->promise_signed_at),
|
|
|
'promise_signed' => $app->promise_signed_at !== null,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array{id: int|null, schema_json: array<int, mixed>, is_default: bool}
|
|
|
*/
|
|
|
private function resolveEffectiveReviewSchema(Competition $competition): array
|
|
|
{
|
|
|
$id = null;
|
|
|
$isDefault = true;
|
|
|
$schemaJson = DefaultReviewSchema::schemaJson();
|
|
|
|
|
|
$rid = $competition->review_form_schema_id;
|
|
|
if ($rid !== null && (int) $rid > 0) {
|
|
|
$def = FormSchemaDefinition::query()
|
|
|
->where('id', (int) $rid)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('purpose', FormSchemaDefinition::PURPOSE_REVIEW)
|
|
|
->first();
|
|
|
|
|
|
$json = $def?->schema_json;
|
|
|
if (is_array($json) && count($json) > 0) {
|
|
|
$id = $def->id;
|
|
|
$schemaJson = $json;
|
|
|
$isDefault = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'id' => $id,
|
|
|
'schema_json' => $schemaJson,
|
|
|
'is_default' => $isDefault,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private function serializeMyReviewScore(?ApplicationReviewScore $myScore): ?array
|
|
|
{
|
|
|
if ($myScore === null) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'payload_json' => $myScore->payload_json,
|
|
|
'line_total' => (string) $myScore->line_total,
|
|
|
'updated_at' => $this->formatSubmittedAtForDisplay($myScore->updated_at),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private function formatScoreDisplay(bool $hasMine, mixed $lineTotal): string
|
|
|
{
|
|
|
if (! $hasMine) {
|
|
|
return '待评审';
|
|
|
}
|
|
|
|
|
|
if ($lineTotal === null) {
|
|
|
return '已评分';
|
|
|
}
|
|
|
|
|
|
$n = is_numeric($lineTotal) ? (float) $lineTotal : 0.0;
|
|
|
|
|
|
return '已评分 · '.rtrim(rtrim(number_format($n, 4, '.', ''), '0'), '.');
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, mixed> $schemaJson
|
|
|
* @param array<string, mixed> $payload
|
|
|
* @return array<string, mixed>
|
|
|
*/
|
|
|
private function normalizePayload(array $schemaJson, array $payload): array
|
|
|
{
|
|
|
$allowedKeys = [];
|
|
|
foreach ($schemaJson as $field) {
|
|
|
if (! is_array($field)) {
|
|
|
continue;
|
|
|
}
|
|
|
$k = $field['key'] ?? null;
|
|
|
if (is_string($k) && $k !== '') {
|
|
|
$allowedKeys[] = $k;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
foreach (array_keys($payload) as $rawKey) {
|
|
|
if (! is_string($rawKey) || ! in_array($rawKey, $allowedKeys, true)) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => ['存在未在评审表中的字段:'.(is_string($rawKey) ? $rawKey : '(无效键)')],
|
|
|
]);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach ($schemaJson as $field) {
|
|
|
if (! is_array($field)) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
$key = $field['key'] ?? null;
|
|
|
$type = $field['type'] ?? null;
|
|
|
if (! is_string($key) || $key === '') {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if (! in_array($type, ['number', 'text', 'textarea'], true)) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => ['评审表字段「'.$key.'」类型无效'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$label = is_string($field['label'] ?? null) ? (string) $field['label'] : $key;
|
|
|
$required = ! empty($field['required']);
|
|
|
$hasKey = array_key_exists($key, $payload);
|
|
|
|
|
|
if ($type === 'number') {
|
|
|
if (! $hasKey || $payload[$key] === null || $payload[$key] === '') {
|
|
|
if ($required) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => ['请填写'.$label],
|
|
|
]);
|
|
|
}
|
|
|
$out[$key] = null;
|
|
|
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if (! is_numeric($payload[$key])) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => [$label.'须为数字'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$out[$key] = (float) $payload[$key];
|
|
|
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if (! $hasKey) {
|
|
|
if ($required) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => ['请填写'.$label],
|
|
|
]);
|
|
|
}
|
|
|
$out[$key] = '';
|
|
|
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
$raw = $payload[$key];
|
|
|
if (is_string($raw)) {
|
|
|
$s = trim($raw);
|
|
|
} elseif ($raw === null) {
|
|
|
$s = '';
|
|
|
} elseif (is_scalar($raw)) {
|
|
|
$s = trim((string) $raw);
|
|
|
} else {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => [$label.'格式不正确'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
if ($required && $s === '') {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => ['请填写'.$label],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$out[$key] = $s;
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, mixed> $schemaJson
|
|
|
* @param array<string, mixed> $normalized
|
|
|
*/
|
|
|
private function sumNumberScores(array $schemaJson, array $normalized): float
|
|
|
{
|
|
|
$sum = 0.0;
|
|
|
|
|
|
foreach ($schemaJson as $field) {
|
|
|
if (! is_array($field)) {
|
|
|
continue;
|
|
|
}
|
|
|
if (($field['type'] ?? '') !== 'number') {
|
|
|
continue;
|
|
|
}
|
|
|
$key = $field['key'] ?? null;
|
|
|
if (! is_string($key) || $key === '') {
|
|
|
continue;
|
|
|
}
|
|
|
$v = $normalized[$key] ?? null;
|
|
|
if ($v === null) {
|
|
|
continue;
|
|
|
}
|
|
|
$sum += (float) $v;
|
|
|
}
|
|
|
|
|
|
return $sum;
|
|
|
}
|
|
|
|
|
|
private function formatSubmittedAtForDisplay(?CarbonInterface $dt): string
|
|
|
{
|
|
|
if ($dt === null) {
|
|
|
return '';
|
|
|
}
|
|
|
|
|
|
return $dt->copy()->timezone(self::DISPLAY_TIMEZONE)->format('Y-m-d H:i');
|
|
|
}
|
|
|
|
|
|
private function formatProjectCode(Application $app): string
|
|
|
{
|
|
|
return sprintf('P%s-%04d', date('Y'), $app->id);
|
|
|
}
|
|
|
|
|
|
private function formatLocation(Application $app): string
|
|
|
{
|
|
|
$country = trim((string) ($app->location_country ?? ''));
|
|
|
if ($country === '海外' && ($app->oversea_country ?? '') !== '') {
|
|
|
return '海外 / '.$app->oversea_country;
|
|
|
}
|
|
|
$parts = array_values(array_filter([
|
|
|
$app->location_country !== null && $app->location_country !== '' ? $app->location_country : null,
|
|
|
$app->location_province !== null && $app->location_province !== '' ? $app->location_province : null,
|
|
|
$app->location_city !== null && $app->location_city !== '' ? $app->location_city : null,
|
|
|
], fn ($v) => $v !== null && $v !== ''));
|
|
|
|
|
|
return count($parts) ? implode(' / ', $parts) : '—';
|
|
|
}
|
|
|
}
|