feat: [calendar] 달력 일정 관리 API 구현

- GET /api/v1/calendar-schedules — 연도별 일정 목록 조회
- GET /api/v1/calendar-schedules/stats — 통계 조회
- GET /api/v1/calendar-schedules/{id} — 단건 조회
- POST /api/v1/calendar-schedules — 등록
- PUT /api/v1/calendar-schedules/{id} — 수정
- DELETE /api/v1/calendar-schedules/{id} — 삭제
- POST /api/v1/calendar-schedules/bulk — 대량 등록
This commit is contained in:
김보곤
2026-02-26 14:29:12 +09:00
parent 7543054df3
commit 04bb990045
4 changed files with 366 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\CalendarScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CalendarScheduleController extends Controller
{
public function __construct(
private readonly CalendarScheduleService $service
) {}
/**
* 일정 목록 조회
*/
public function index(Request $request): JsonResponse
{
$request->validate([
'year' => 'required|integer|min:2000|max:2100',
'type' => 'nullable|string',
]);
return ApiResponse::handle(
fn () => $this->service->list(
(int) $request->input('year'),
$request->input('type')
),
__('message.fetched')
);
}
/**
* 통계 조회
*/
public function stats(Request $request): JsonResponse
{
$request->validate([
'year' => 'required|integer|min:2000|max:2100',
]);
return ApiResponse::handle(
fn () => $this->service->stats((int) $request->input('year')),
__('message.fetched')
);
}
/**
* 단건 조회
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
/**
* 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'is_recurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->store($validated),
__('message.created')
);
}
/**
* 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'is_recurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->update($id, $validated),
__('message.updated')
);
}
/**
* 삭제
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->delete($id),
__('message.deleted')
);
}
/**
* 대량 등록
*/
public function bulkStore(Request $request): JsonResponse
{
$validated = $request->validate([
'schedules' => 'required|array|min:1',
'schedules.*.name' => 'required|string|max:100',
'schedules.*.start_date' => 'required|date',
'schedules.*.end_date' => 'required|date|after_or_equal:schedules.*.start_date',
'schedules.*.type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'schedules.*.is_recurring' => 'boolean',
'schedules.*.memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->bulkStore($validated['schedules']),
__('message.created')
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Commons;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Holiday extends Model
{
use SoftDeletes;
protected $table = 'holidays';
protected $fillable = [
'tenant_id',
'start_date',
'end_date',
'name',
'type',
'is_recurring',
'memo',
'created_by',
'updated_by',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_recurring' => 'boolean',
];
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
public function scopeForYear($query, int $year)
{
return $query->whereYear('start_date', $year);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Services;
use App\Models\Commons\Holiday;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CalendarScheduleService extends Service
{
/**
* 연도별 일정 목록 조회
*/
public function list(int $year, ?string $type = null): array
{
$query = Holiday::forTenant($this->tenantId())
->forYear($year)
->orderBy('start_date');
if ($type) {
$query->where('type', $type);
}
return $query->get()->map(function ($h) {
return [
'id' => $h->id,
'name' => $h->name,
'type' => $h->type,
'start_date' => $h->start_date->format('Y-m-d'),
'end_date' => $h->end_date->format('Y-m-d'),
'days' => $h->start_date->diffInDays($h->end_date) + 1,
'is_recurring' => $h->is_recurring,
'memo' => $h->memo,
'created_at' => $h->created_at?->toIso8601String(),
'updated_at' => $h->updated_at?->toIso8601String(),
];
})->all();
}
/**
* 통계 조회
*/
public function stats(int $year): array
{
$tenantId = $this->tenantId();
$holidays = Holiday::forTenant($tenantId)->forYear($year)->get();
$totalDays = $holidays->sum(function ($h) {
return $h->start_date->diffInDays($h->end_date) + 1;
});
return [
'total_count' => $holidays->count(),
'total_holiday_days' => $totalDays,
'public_holiday_count' => $holidays->where('type', 'public_holiday')->count(),
];
}
/**
* 단건 조회
*/
public function show(int $id): array
{
$h = Holiday::forTenant($this->tenantId())->findOrFail($id);
return [
'id' => $h->id,
'name' => $h->name,
'type' => $h->type,
'start_date' => $h->start_date->format('Y-m-d'),
'end_date' => $h->end_date->format('Y-m-d'),
'days' => $h->start_date->diffInDays($h->end_date) + 1,
'is_recurring' => $h->is_recurring,
'memo' => $h->memo,
'created_at' => $h->created_at?->toIso8601String(),
'updated_at' => $h->updated_at?->toIso8601String(),
];
}
/**
* 등록
*/
public function store(array $data): array
{
$tenantId = $this->tenantId();
$exists = Holiday::forTenant($tenantId)
->where('start_date', $data['start_date'])
->where('end_date', $data['end_date'])
->where('name', $data['name'])
->exists();
if ($exists) {
throw new HttpException(422, __('error.duplicate'));
}
$holiday = Holiday::create([
'tenant_id' => $tenantId,
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'name' => $data['name'],
'type' => $data['type'] ?? 'public_holiday',
'is_recurring' => $data['is_recurring'] ?? false,
'memo' => $data['memo'] ?? null,
'created_by' => $this->apiUserId(),
]);
return $this->show($holiday->id);
}
/**
* 수정
*/
public function update(int $id, array $data): array
{
$holiday = Holiday::forTenant($this->tenantId())->findOrFail($id);
$holiday->update([
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'name' => $data['name'],
'type' => $data['type'],
'is_recurring' => $data['is_recurring'] ?? false,
'memo' => $data['memo'] ?? null,
'updated_by' => $this->apiUserId(),
]);
return $this->show($id);
}
/**
* 삭제
*/
public function delete(int $id): void
{
$holiday = Holiday::forTenant($this->tenantId())->findOrFail($id);
$holiday->delete();
}
/**
* 대량 등록
*/
public function bulkStore(array $schedules): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$count = 0;
$skipped = 0;
foreach ($schedules as $item) {
$exists = Holiday::forTenant($tenantId)
->where('start_date', $item['start_date'])
->where('end_date', $item['end_date'])
->where('name', $item['name'])
->exists();
if ($exists) {
$skipped++;
continue;
}
Holiday::create([
'tenant_id' => $tenantId,
'start_date' => $item['start_date'],
'end_date' => $item['end_date'],
'name' => $item['name'],
'type' => $item['type'] ?? 'public_holiday',
'is_recurring' => $item['is_recurring'] ?? false,
'memo' => $item['memo'] ?? null,
'created_by' => $userId,
]);
$count++;
}
return [
'created' => $count,
'skipped' => $skipped,
];
}
}