๐ŸŽญ ๊ณต์—ฐ ํ”„๋กœ๋“€์„œ ์‹œ์ฆŒ ๊ด€๋ฆฌ ํ”Œ๋žซํผ - ๊ธฐํš๋ถ€ํ„ฐ ์ •์‚ฐ๊นŒ์ง€ ์˜ฌ์ธ์› ์‹œ์Šคํ…œ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
๊ณ ๊ธ‰ PHP Laravel AI: Claude

๐ŸŽญ ๊ณต์—ฐ ํ”„๋กœ๋“€์„œ ์‹œ์ฆŒ ๊ด€๋ฆฌ ํ”Œ๋žซํผ - ๊ธฐํš๋ถ€ํ„ฐ ์ •์‚ฐ๊นŒ์ง€ ์˜ฌ์ธ์› ์‹œ์Šคํ…œ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… theater-season-producer-platform
์กฐํšŒ์ˆ˜ 112 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽฏ ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

๊ณต์—ฐ ์ œ์ž‘์‚ฌ/ํ”„๋กœ๋“€์„œ๋ฅผ ์œ„ํ•œ ์‹œ์ฆŒ ํ†ตํ•ฉ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ๊ณต์—ฐ ์‹œ์ฆŒ์„ ๋™์‹œ์— ๊ด€๋ฆฌํ•˜๊ณ , ํ‹ฐ์ผ“ ํŒ๋งค๋ถ€ํ„ฐ ์ถœ์—ฐ์ง„ ๊ณ„์•ฝ, ์ˆ˜์ต ์ •์‚ฐ๊นŒ์ง€ ์ „ ๊ณผ์ •์„ ์ž๋™ํ™”ํ•ฉ๋‹ˆ๋‹ค.

โœจ ํ•ต์‹ฌ ๊ธฐ๋Šฅ

ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  1. ์‹œ์ฆŒ & ๊ณต์—ฐ ๊ด€๋ฆฌ

    • ์‹œ์ฆŒ๋ณ„ ๋‹ค์ค‘ ๊ณต์—ฐ ๋“ฑ๋ก (๋ฎค์ง€์ปฌ, ์—ฐ๊ทน, ์ฝ˜์„œํŠธ ๋“ฑ)
    • ๊ณต์—ฐ๋ณ„ ํšŒ์ฐจ/๋‚ ์งœ/์‹œ๊ฐ„ ์Šค์ผ€์ค„ ๊ด€๋ฆฌ
    • ๊ณต์—ฐ์žฅ ์ •๋ณด & ์ขŒ์„ ๋ฐฐ์น˜๋„ ์„ค์ •
    • ํ‹ฐ์ผ“ ๊ฐ€๊ฒฉ ๋“ฑ๊ธ‰๋ณ„ ์„ค์ • (VIP/R์„/S์„ ๋“ฑ)
  2. ํ‹ฐ์ผ“ ํŒ๋งค & ์˜ˆ๋งค

    • ์‹ค์‹œ๊ฐ„ ์ขŒ์„ ์„ ํƒ ์ธํ„ฐํŽ˜์ด์Šค (SVG ์ขŒ์„ ๋งต)
    • ๋‹ค์ค‘ ์ขŒ์„ ์„ ํƒ & ์žฅ๋ฐ”๊ตฌ๋‹ˆ
    • ๊ฒฐ์ œ ์—ฐ๋™ (PortOne/Toss Payments)
    • ์˜ˆ๋งค ํ™•์ธ & QR ์ฝ”๋“œ ํ‹ฐ์ผ“ ๋ฐœ๊ธ‰
    • ์˜ˆ๋งค ์ทจ์†Œ/ํ™˜๋ถˆ ์ฒ˜๋ฆฌ
  3. ์ถœ์—ฐ์ง„ & ์Šคํƒœํ”„ ๊ณ„์•ฝ ๊ด€๋ฆฌ

    • ์ถœ์—ฐ์ง„/์Šคํƒœํ”„ ํ”„๋กœํ•„ ๋“ฑ๋ก
    • ๊ณต์—ฐ๋ณ„ ์บ์ŠคํŒ… ๋ฐฐ์ •
    • ๊ณ„์•ฝ์„œ ํ…œํ”Œ๋ฆฟ & ์ „์ž์„œ๋ช… (PDF ์ƒ์„ฑ)
    • ์ถœ์—ฐ๋ฃŒ ์ •์‚ฐ ์Šค์ผ€์ค„ ๊ด€๋ฆฌ
  4. ์ˆ˜์ต ์ •์‚ฐ & ๋ฆฌํฌํŠธ

    • ๊ณต์—ฐ๋ณ„/์‹œ์ฆŒ๋ณ„ ๋งค์ถœ ๋Œ€์‹œ๋ณด๋“œ
    • ํ‹ฐ์ผ“ ํŒ๋งค ํ†ต๊ณ„ (์ผ๋ณ„/์ฃผ๋ณ„/์›”๋ณ„)
    • ์ถœ์—ฐ์ง„ ์ •์‚ฐ ๋‚ด์—ญ ์ž๋™ ๊ณ„์‚ฐ
    • ์ˆ˜์ต ๋ถ„๋ฐฐ ๋น„์œจ ์„ค์ • (ํˆฌ์ž์ž/์ œ์ž‘์‚ฌ/์ถœ์—ฐ์ง„)
    • Excel/PDF ๋ฆฌํฌํŠธ ๋‹ค์šด๋กœ๋“œ
  5. ๊ด€๊ฐ ๊ด€๋ฆฌ & ๋งˆ์ผ€ํŒ…

    • ๊ด€๊ฐ DB & ๊ตฌ๋งค ์ด๋ ฅ ์ถ”์ 
    • ์ด๋ฉ”์ผ ๋‰ด์Šค๋ ˆํ„ฐ ๋ฐœ์†ก (๊ณต์—ฐ ์•Œ๋ฆผ/ํ• ์ธ ์ฟ ํฐ)
    • VIP ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ ์‹œ์Šคํ…œ
    • ๋ฆฌ๋ทฐ & ๋ณ„์  ์ˆ˜์ง‘
  6. ์•Œ๋ฆผ & ์ž๋™ํ™”

    • ๊ณต์—ฐ D-7/D-1 ์˜ˆ๋งค์ž ์•Œ๋ฆผ (์ด๋ฉ”์ผ/SMS)
    • ํ‹ฐ์ผ“ ์˜คํ”ˆ ์•Œ๋ฆผ
    • ์ •์‚ฐ ๋งˆ๊ฐ์ผ ๋ฆฌ๋งˆ์ธ๋”
    • Laravel Queue๋กœ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ์ฒ˜๋ฆฌ

๐ŸŽ ๋ณด๋„ˆ์Šค ๊ธฐ๋Šฅ

  • Redis ์บ์‹ฑ์œผ๋กœ ์ขŒ์„ ์˜ˆ๋งค ๋™์‹œ์„ฑ ์ œ์–ด
  • Laravel Reverb/Pusher๋กœ ์‹ค์‹œ๊ฐ„ ์ขŒ์„ ์ ์œ  ํ‘œ์‹œ
  • ๋‹ค๊ตญ์–ด ์ง€์› (ํ•œ/์˜/์ผ)
  • ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ๋””์ž์ธ (Tailwind CSS)
  • ๊ณต์—ฐ ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ์ž๋™ ๋ฆฌ์‚ฌ์ด์ง• (Intervention Image)
  • ๊ด€๊ฐ ํ–‰๋™ ๋ถ„์„ (Google Analytics ์—ฐ๋™)
  • ํ• ์ธ ์ฟ ํฐ & ํ”„๋กœ๋ชจ์…˜ ์ฝ”๋“œ ์‹œ์Šคํ…œ
  • ๊ณต์—ฐ ๋ฆฌ๋ทฐ ๊ฐ์ • ๋ถ„์„ (OpenAI API)

๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ

  • ๋ฐฑ์—”๋“œ: Laravel 13.x
  • ํ”„๋ก ํŠธ์—”๋“œ: Blade ํ…œํ”Œ๋ฆฟ + Alpine.js + Tailwind CSS
  • DB: MySQL (ํ”„๋กœ๋•์…˜) / SQLite (๊ฐœ๋ฐœ)
  • ํ: Redis + Laravel Queue
  • ๊ฒฐ์ œ: PortOne (๊ตฌ ์•„์ž„ํฌํŠธ) / Toss Payments
  • PDF: barryvdh/laravel-dompdf
  • ์ด๋ฏธ์ง€: intervention/image
  • ์‹ค์‹œ๊ฐ„: Laravel Reverb (์„ ํƒ)

๐Ÿ“Š ์˜ˆ์ƒ ์†Œ์š” ์‹œ๊ฐ„

์•ฝ 20~30์‹œ๊ฐ„ (๊ณ ๊ธ‰ ๋‚œ์ด๋„)

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + Blade ํ…œํ”Œ๋ฆฟ ์‚ฌ์šฉ (React/Vue ๊ธˆ์ง€)
  • โ€ข Tailwind CSS๋กœ UI ๊ตฌ์„ฑ
  • โ€ข Alpine.js๋กœ ๋™์  ์ธํ„ฐ๋ž™์…˜ ๊ตฌํ˜„
  • โ€ข MySQL ๋˜๋Š” SQLite ์‚ฌ์šฉ
  • โ€ข Laravel Queue + Redis๋กœ ๋น„๋™๊ธฐ ์ž‘์—… ์ฒ˜๋ฆฌ
  • โ€ข PortOne ๋˜๋Š” Toss Payments ๊ฒฐ์ œ ์—ฐ๋™
  • โ€ข barryvdh/laravel-dompdf๋กœ PDF ์ƒ์„ฑ
  • โ€ข intervention/image๋กœ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ
  • โ€ข Laravel Policy/Gate๋กœ ๊ถŒํ•œ ๊ด€๋ฆฌ
  • โ€ข PHPUnit์œผ๋กœ ์ฃผ์š” ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ
  • โ€ข ์‹ค์‹œ๊ฐ„ ๊ธฐ๋Šฅ์€ Laravel Reverb ๋˜๋Š” Pusher ์‚ฌ์šฉ (์„ ํƒ)
  • โ€ข ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ๋””์ž์ธ ํ•„์ˆ˜

ํ”„๋กฌํ”„ํŠธ ๋ ˆ์‹œํ”ผ

์•„๋ž˜ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ AI์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
mkdir theater-season-producer-platform && cd theater-season-producer-platform

์ด ํด๋”์—์„œ Laravel 13.x ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

# ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐ ์„ค์ •

1. Laravel 13.x ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ:
```bash
composer create-project laravel/laravel . "13.*"
```

2. ํ•„์ˆ˜ ํŒจํ‚ค์ง€ ์„ค์น˜:
```bash
composer require barryvdh/laravel-dompdf intervention/image predis/predis
npm install alpinejs @tailwindcss/forms @tailwindcss/typography
```

3. Tailwind CSS ์„ค์ •:
- `tailwind.config.js` ์ƒ์„ฑ ๋ฐ content ๊ฒฝ๋กœ ์„ค์ •
- `resources/css/app.css`์— Tailwind ๋””๋ ‰ํ‹ฐ๋ธŒ ์ถ”๊ฐ€
- Alpine.js๋ฅผ `resources/js/app.js`์— ์ž„ํฌํŠธ

4. `.env` ํŒŒ์ผ ์„ค์ •:
```
DB_CONNECTION=sqlite
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
SESSION_DRIVER=redis
CACHE_DRIVER=redis
```

5. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ์ƒ์„ฑ:
```bash
php artisan make:migration create_seasons_table
php artisan make:migration create_shows_table
php artisan make:migration create_venues_table
php artisan make:migration create_seats_table
php artisan make:migration create_bookings_table
php artisan make:migration create_performers_table
php artisan make:migration create_contracts_table
php artisan make:migration create_settlements_table
php artisan make:migration create_customers_table
php artisan make:migration create_reviews_table
```

๊ฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ์— ๋‹ค์Œ ์Šคํ‚ค๋งˆ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”:

### seasons ํ…Œ์ด๋ธ”
- id, name, description, start_date, end_date, status (upcoming/ongoing/completed), created_at, updated_at

### shows ํ…Œ์ด๋ธ”
- id, season_id, title, description, genre (musical/play/concert), poster_image, duration_minutes, created_at, updated_at

### venues ํ…Œ์ด๋ธ”
- id, name, address, total_seats, seat_map_svg (์ขŒ์„ ๋ฐฐ์น˜๋„ SVG), created_at, updated_at

### seats ํ…Œ์ด๋ธ”
- id, venue_id, row, number, grade (VIP/R/S/A), price, status (available/occupied/blocked), created_at, updated_at

### bookings ํ…Œ์ด๋ธ”
- id, customer_id, show_id, show_date, show_time, seat_ids (JSON), total_price, payment_status (pending/paid/refunded), payment_method, qr_code, created_at, updated_at

### performers ํ…Œ์ด๋ธ”
- id, name, role (actor/singer/staff), profile_image, bio, contact_email, contact_phone, created_at, updated_at

### contracts ํ…Œ์ด๋ธ”
- id, show_id, performer_id, role_in_show, fee_amount, payment_schedule (JSON), contract_pdf, signed_at, created_at, updated_at

### settlements ํ…Œ์ด๋ธ”
- id, show_id, total_revenue, performer_share, company_share, investor_share, settlement_date, pdf_report, created_at, updated_at

### customers ํ…Œ์ด๋ธ”
- id, name, email, phone, membership_tier (regular/vip/platinum), total_spent, created_at, updated_at

### reviews ํ…Œ์ด๋ธ”
- id, booking_id, customer_id, show_id, rating (1~5), comment, created_at, updated_at

6. ๋ชจ๋ธ ์ƒ์„ฑ:
```bash
php artisan make:model Season
php artisan make:model Show
php artisan make:model Venue
php artisan make:model Seat
php artisan make:model Booking
php artisan make:model Performer
php artisan make:model Contract
php artisan make:model Settlement
php artisan make:model Customer
php artisan make:model Review
```

๊ฐ ๋ชจ๋ธ์— ๊ด€๊ณ„ ์ •์˜:
- Season hasMany Shows
- Show belongsTo Season, hasMany Bookings, hasMany Contracts, hasMany Reviews
- Venue hasMany Seats
- Booking belongsTo Customer, belongsTo Show, hasOne Review
- Contract belongsTo Show, belongsTo Performer
- Settlement belongsTo Show
- Customer hasMany Bookings, hasMany Reviews
- Review belongsTo Booking, belongsTo Customer, belongsTo Show

7. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰:
```bash
php artisan migrate
```

8. Seeder ์ƒ์„ฑ ๋ฐ ์‹คํ–‰:
```bash
php artisan make:seeder SeasonSeeder
php artisan make:seeder ShowSeeder
php artisan make:seeder VenueSeeder
php artisan make:seeder SeatSeeder
php artisan db:seed
```

๋”๋ฏธ ๋ฐ์ดํ„ฐ ์˜ˆ์‹œ:
- ์‹œ์ฆŒ 3๊ฐœ (2024 ๋ด„ ์‹œ์ฆŒ, ์—ฌ๋ฆ„ ์‹œ์ฆŒ, ๊ฐ€์„ ์‹œ์ฆŒ)
- ๊ณต์—ฐ ๊ฐ ์‹œ์ฆŒ๋‹น 2~3๊ฐœ
- ๊ณต์—ฐ์žฅ 2๊ฐœ (๋Œ€๊ทน์žฅ 500์„, ์†Œ๊ทน์žฅ 200์„)
- ์ขŒ์„ ์ž๋™ ์ƒ์„ฑ (VIP 10%, R์„ 30%, S์„ 40%, A์„ 20%)

ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ๊ฐ€ ์™„์„ฑ๋˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel 13.x ํ”„๋กœ์ ํŠธ๊ฐ€ ์ดˆ๊ธฐํ™”๋˜๊ณ , ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ์™€ ๋ชจ๋ธ ๊ด€๊ณ„๊ฐ€ ์ •์˜๋˜๋ฉฐ, ๋”๋ฏธ ๋ฐ์ดํ„ฐ๊ฐ€ ์‹œ๋“œ๋ฉ๋‹ˆ๋‹ค. Tailwind CSS + Alpine.js ์„ค์ • ์™„๋ฃŒ.

2
Step 2
# ์‹œ์ฆŒ & ๊ณต์—ฐ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ ๊ตฌํ˜„

1. ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ:
```bash
php artisan make:controller SeasonController
php artisan make:controller ShowController
php artisan make:controller VenueController
```

2. `routes/web.php`์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/', [SeasonController::class, 'index'])->name('home');
Route::resource('seasons', SeasonController::class);
Route::resource('shows', ShowController::class);
Route::resource('venues', VenueController::class);
```

3. `SeasonController`์— ๋‹ค์Œ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„:
- index(): ์‹œ์ฆŒ ๋ชฉ๋ก (์นด๋“œ ๊ทธ๋ฆฌ๋“œ, ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ)
- create(): ์‹œ์ฆŒ ์ƒ์„ฑ ํผ
- store(): ์‹œ์ฆŒ ์ €์žฅ
- show($id): ์‹œ์ฆŒ ์ƒ์„ธ & ํฌํ•จ๋œ ๊ณต์—ฐ ๋ชฉ๋ก
- edit($id): ์‹œ์ฆŒ ์ˆ˜์ • ํผ
- update($id): ์‹œ์ฆŒ ์—…๋ฐ์ดํŠธ
- destroy($id): ์‹œ์ฆŒ ์‚ญ์ œ

4. `ShowController`์— ๋‹ค์Œ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„:
- index(): ์ „์ฒด ๊ณต์—ฐ ๋ชฉ๋ก (์žฅ๋ฅด๋ณ„ ํ•„ํ„ฐ, ๊ฒ€์ƒ‰)
- create(): ๊ณต์—ฐ ์ƒ์„ฑ ํผ (์‹œ์ฆŒ ์„ ํƒ ๋“œ๋กญ๋‹ค์šด)
- store(): ๊ณต์—ฐ ์ €์žฅ + ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ (intervention/image๋กœ ๋ฆฌ์‚ฌ์ด์ง•)
- show($id): ๊ณต์—ฐ ์ƒ์„ธ (ํฌ์Šคํ„ฐ, ์„ค๋ช…, ํšŒ์ฐจ ๋ชฉ๋ก, ์ถœ์—ฐ์ง„)
- edit($id): ๊ณต์—ฐ ์ˆ˜์ • ํผ
- update($id): ๊ณต์—ฐ ์—…๋ฐ์ดํŠธ
- destroy($id): ๊ณต์—ฐ ์‚ญ์ œ

5. Blade ๋ทฐ ํŒŒ์ผ ์ƒ์„ฑ:

### `resources/views/layouts/app.blade.php`
- ๊ธฐ๋ณธ ๋ ˆ์ด์•„์›ƒ ํ…œํ”Œ๋ฆฟ
- Tailwind CSS + Alpine.js ๋กœ๋“œ
- ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ” (์‹œ์ฆŒ/๊ณต์—ฐ/๊ณต์—ฐ์žฅ/์ถœ์—ฐ์ง„/์ •์‚ฐ ๋ฉ”๋‰ด)
- ํ‘ธํ„ฐ

### `resources/views/seasons/index.blade.php`
- ์‹œ์ฆŒ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ (3์—ด)
- ๊ฐ ์นด๋“œ: ์‹œ์ฆŒ๋ช…, ๊ธฐ๊ฐ„, ์ƒํƒœ ๋ฐฐ์ง€, ํฌํ•จ๋œ ๊ณต์—ฐ ์ˆ˜
- ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ ๋ฒ„ํŠผ (์ „์ฒด/์ง„ํ–‰์ค‘/์˜ˆ์ •/์ข…๋ฃŒ)
- "์ƒˆ ์‹œ์ฆŒ ์ถ”๊ฐ€" ๋ฒ„ํŠผ

### `resources/views/seasons/create.blade.php`
- ์‹œ์ฆŒ ์ƒ์„ฑ ํผ
- ํ•„๋“œ: ์‹œ์ฆŒ๋ช…, ์„ค๋ช…, ์‹œ์ž‘์ผ, ์ข…๋ฃŒ์ผ
- Tailwind CSS ํผ ์Šคํƒ€์ผ๋ง

### `resources/views/seasons/show.blade.php`
- ์‹œ์ฆŒ ์ƒ์„ธ ์ •๋ณด
- ํฌํ•จ๋œ ๊ณต์—ฐ ๋ชฉ๋ก (์นด๋“œ ํ˜•ํƒœ)
- "๊ณต์—ฐ ์ถ”๊ฐ€" ๋ฒ„ํŠผ
- ์ˆ˜์ •/์‚ญ์ œ ๋ฒ„ํŠผ

### `resources/views/shows/index.blade.php`
- ๊ณต์—ฐ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ (4์—ด)
- ๊ฐ ์นด๋“œ: ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€, ์ œ๋ชฉ, ์žฅ๋ฅด ๋ฐฐ์ง€, ๊ณต์—ฐ ๊ธฐ๊ฐ„
- ์žฅ๋ฅด๋ณ„ ํ•„ํ„ฐ (๋ฎค์ง€์ปฌ/์—ฐ๊ทน/์ฝ˜์„œํŠธ)
- ๊ฒ€์ƒ‰ ๋ฐ” (์ œ๋ชฉ ๊ฒ€์ƒ‰)

### `resources/views/shows/create.blade.php`
- ๊ณต์—ฐ ์ƒ์„ฑ ํผ
- ํ•„๋“œ: ์‹œ์ฆŒ ์„ ํƒ, ์ œ๋ชฉ, ์„ค๋ช…, ์žฅ๋ฅด, ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ, ๊ณต์—ฐ ์‹œ๊ฐ„(๋ถ„)
- ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ (Alpine.js)

### `resources/views/shows/show.blade.php`
- ๊ณต์—ฐ ์ƒ์„ธ ์ •๋ณด
- ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ (ํฐ ์‚ฌ์ด์ฆˆ)
- ๊ณต์—ฐ ์„ค๋ช…
- ํšŒ์ฐจ ๋ชฉ๋ก (๋‚ ์งœ/์‹œ๊ฐ„๋ณ„ ๋ฒ„ํŠผ)
- ์ถœ์—ฐ์ง„ ๋ชฉ๋ก (ํ”„๋กœํ•„ ์นด๋“œ)
- ๋ฆฌ๋ทฐ & ๋ณ„์  ์„น์…˜
- "ํ‹ฐ์ผ“ ์˜ˆ๋งค" ๋ฒ„ํŠผ

6. ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ:
- `ShowController@store`์—์„œ `intervention/image`๋กœ ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• (800x1200px)
- `storage/app/public/posters` ํด๋”์— ์ €์žฅ
- `php artisan storage:link` ์‹คํ–‰ํ•˜์—ฌ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ ์ƒ์„ฑ

7. ํผ Validation:
- `php artisan make:request StoreSeasonRequest`
- `php artisan make:request StoreShowRequest`
- ๊ฐ Request ํด๋ž˜์Šค์— validation ๊ทœ์น™ ์ •์˜

8. UI ๊ฐœ์„ :
- Tailwind CSS๋กœ ๋ชจ๋˜ํ•œ ๋””์ž์ธ (๊ทธ๋ผ๋””์–ธํŠธ, ๊ทธ๋ฆผ์ž, ํ˜ธ๋ฒ„ ํšจ๊ณผ)
- Alpine.js๋กœ ํ•„ํ„ฐ/๊ฒ€์ƒ‰ ์‹ค์‹œ๊ฐ„ ์ ์šฉ
- ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ (Alpine.js x-show)

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์‹œ์ฆŒ๊ณผ ๊ณต์—ฐ์„ ์ƒ์„ฑ/์กฐํšŒ/์ˆ˜์ •/์‚ญ์ œํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ฐ ๋ฆฌ์‚ฌ์ด์ง•์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. Blade ๋ทฐ๊ฐ€ Tailwind CSS๋กœ ์Šคํƒ€์ผ๋ง๋˜๊ณ  Alpine.js๋กœ ๋™์  ์ธํ„ฐ๋ž™์…˜์ด ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค.

3
Step 3
# ์‹ค์‹œ๊ฐ„ ์ขŒ์„ ์˜ˆ๋งค ์‹œ์Šคํ…œ ๊ตฌํ˜„

1. ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ:
```bash
php artisan make:controller BookingController
php artisan make:controller SeatController
```

2. `routes/web.php`์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/shows/{show}/booking', [BookingController::class, 'create'])->name('booking.create');
Route::post('/shows/{show}/booking', [BookingController::class, 'store'])->name('booking.store');
Route::get('/bookings/{booking}', [BookingController::class, 'show'])->name('booking.show');
Route::delete('/bookings/{booking}', [BookingController::class, 'cancel'])->name('booking.cancel');
Route::get('/api/shows/{show}/seats', [SeatController::class, 'getAvailableSeats']);
Route::post('/api/seats/{seat}/lock', [SeatController::class, 'lockSeat']);
Route::post('/api/seats/{seat}/unlock', [SeatController::class, 'unlockSeat']);
```

3. `BookingController`์— ๋‹ค์Œ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„:

### create($showId)
- ๊ณต์—ฐ ์ •๋ณด ๋กœ๋“œ
- ๊ณต์—ฐ์žฅ ์ขŒ์„ ๋งต ๋กœ๋“œ (venue์˜ seat_map_svg)
- ์˜ˆ๋งค ๊ฐ€๋Šฅํ•œ ์ขŒ์„ ๋ชฉ๋ก (status='available')
- Blade ๋ทฐ์— ๋ฐ์ดํ„ฐ ์ „๋‹ฌ

### store(Request $request, $showId)
- ์„ ํƒ๋œ ์ขŒ์„ ID ๋ฐฐ์—ด ๋ฐ›๊ธฐ
- Redis๋กœ ์ขŒ์„ ์ž ๊ธˆ ํ™•์ธ (๋™์‹œ์„ฑ ์ œ์–ด)
- ๊ณ ๊ฐ ์ •๋ณด ์ž…๋ ฅ ๋˜๋Š” ๋กœ๊ทธ์ธ
- Booking ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ (payment_status='pending')
- ๊ฒฐ์ œ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (PortOne/Toss Payments ์—ฐ๋™)

### show($bookingId)
- ์˜ˆ๋งค ํ™•์ธ ํŽ˜์ด์ง€
- QR ์ฝ”๋“œ ํ‹ฐ์ผ“ ํ‘œ์‹œ (SimpleSoftwareIO/simple-qrcode ์‚ฌ์šฉ)
- ์˜ˆ๋งค ์ƒ์„ธ ์ •๋ณด (๊ณต์—ฐ๋ช…, ๋‚ ์งœ, ์ขŒ์„, ๊ฒฐ์ œ ๊ธˆ์•ก)

### cancel($bookingId)
- ์˜ˆ๋งค ์ทจ์†Œ ์ฒ˜๋ฆฌ
- ์ขŒ์„ ์ƒํƒœ๋ฅผ 'available'๋กœ ๋ณ€๊ฒฝ
- ํ™˜๋ถˆ ์ฒ˜๋ฆฌ (๊ฒฐ์ œ API ํ˜ธ์ถœ)

4. `SeatController`์— ๋‹ค์Œ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„:

### getAvailableSeats($showId)
- JSON API: ์˜ˆ๋งค ๊ฐ€๋Šฅํ•œ ์ขŒ์„ ๋ชฉ๋ก ๋ฐ˜ํ™˜
- ์ขŒ์„ ์ •๋ณด (id, row, number, grade, price, status)

### lockSeat($seatId)
- Redis๋กœ ์ขŒ์„ ์ž„์‹œ ์ž ๊ธˆ (5๋ถ„ TTL)
- ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒ ์ค‘์ธ ์ขŒ์„ ํ‘œ์‹œ

### unlockSeat($seatId)
- Redis ์ž ๊ธˆ ํ•ด์ œ

5. Blade ๋ทฐ ์ƒ์„ฑ:

### `resources/views/bookings/create.blade.php`
- ๊ณต์—ฐ ์ •๋ณด ํ—ค๋” (ํฌ์Šคํ„ฐ, ์ œ๋ชฉ, ๋‚ ์งœ/์‹œ๊ฐ„)
- SVG ์ขŒ์„ ๋งต (venue์˜ seat_map_svg ๋ Œ๋”๋ง)
- Alpine.js๋กœ ์ขŒ์„ ํด๋ฆญ ์„ ํƒ ๊ตฌํ˜„
- ์„ ํƒ๋œ ์ขŒ์„ ๋ชฉ๋ก ์‚ฌ์ด๋“œ๋ฐ” (์ขŒ์„ ๋ฒˆํ˜ธ, ๋“ฑ๊ธ‰, ๊ฐ€๊ฒฉ)
- ์ด ๊ฒฐ์ œ ๊ธˆ์•ก ํ‘œ์‹œ
- ๊ณ ๊ฐ ์ •๋ณด ์ž…๋ ฅ ํผ (์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ „ํ™”๋ฒˆํ˜ธ)
- "์˜ˆ๋งคํ•˜๊ธฐ" ๋ฒ„ํŠผ

Alpine.js ๋กœ์ง:
```javascript
function bookingApp() {
  return {
    selectedSeats: [],
    seats: [],
    async init() {
      const response = await fetch(`/api/shows/${showId}/seats`);
      this.seats = await response.json();
    },
    async selectSeat(seat) {
      if (this.selectedSeats.includes(seat.id)) {
        this.selectedSeats = this.selectedSeats.filter(id => id !== seat.id);
        await fetch(`/api/seats/${seat.id}/unlock`, { method: 'POST' });
      } else {
        const lockResponse = await fetch(`/api/seats/${seat.id}/lock`, { method: 'POST' });
        if (lockResponse.ok) {
          this.selectedSeats.push(seat.id);
        } else {
          alert('์ด๋ฏธ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒํ•œ ์ขŒ์„์ž…๋‹ˆ๋‹ค.');
        }
      }
    },
    getTotalPrice() {
      return this.selectedSeats.reduce((total, seatId) => {
        const seat = this.seats.find(s => s.id === seatId);
        return total + (seat ? seat.price : 0);
      }, 0);
    }
  }
}
```

### `resources/views/bookings/show.blade.php`
- ์˜ˆ๋งค ์™„๋ฃŒ ๋ฉ”์‹œ์ง€
- QR ์ฝ”๋“œ ํ‹ฐ์ผ“ (SimpleSoftwareIO/simple-qrcode)
- ์˜ˆ๋งค ์ •๋ณด (๊ณต์—ฐ๋ช…, ๋‚ ์งœ, ์ขŒ์„, ๊ธˆ์•ก)
- "์˜ˆ๋งค ์ทจ์†Œ" ๋ฒ„ํŠผ
- "์ด๋ฉ”์ผ๋กœ ํ‹ฐ์ผ“ ์ „์†ก" ๋ฒ„ํŠผ

6. Redis ์ขŒ์„ ์ž ๊ธˆ ๋กœ์ง:
```php
// SeatController@lockSeat
use Illuminate\Support\Facades\Redis;

public function lockSeat($seatId)
{
    $key = "seat_lock:{$seatId}";
    $locked = Redis::set($key, auth()->id() ?? session()->getId(), 'EX', 300, 'NX');
    
    if ($locked) {
        return response()->json(['success' => true]);
    }
    
    return response()->json(['success' => false, 'message' => 'Seat already locked'], 409);
}
```

7. QR ์ฝ”๋“œ ์ƒ์„ฑ:
```bash
composer require simplesoftwareio/simple-qrcode
```

`BookingController@show`์—์„œ:
```php
use SimpleSoftwareIO\QrCode\Facades\QrCode;

public function show($bookingId)
{
    $booking = Booking::findOrFail($bookingId);
    $qrCode = QrCode::size(200)->generate(route('booking.verify', $booking->id));
    
    return view('bookings.show', compact('booking', 'qrCode'));
}
```

8. ๊ฒฐ์ œ ์—ฐ๋™ (PortOne ์˜ˆ์‹œ):
```bash
composer require iamport/iamport-rest-client-php
```

`BookingController@store`์—์„œ:
```php
use Iamport\RestClient\RestClient;

public function store(Request $request, $showId)
{
    // ... ์˜ˆ๋งค ์ƒ์„ฑ ๋กœ์ง
    
    // PortOne ๊ฒฐ์ œ ์š”์ฒญ
    $iamport = new RestClient('YOUR_API_KEY', 'YOUR_API_SECRET');
    $paymentData = [
        'merchant_uid' => 'booking_' . $booking->id,
        'amount' => $booking->total_price,
        'buyer_name' => $request->name,
        'buyer_email' => $request->email,
    ];
    
    // ๊ฒฐ์ œ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
    return view('bookings.payment', compact('booking', 'paymentData'));
}
```

9. ์˜ˆ๋งค ์ทจ์†Œ ๋กœ์ง:
```php
public function cancel($bookingId)
{
    $booking = Booking::findOrFail($bookingId);
    
    // ์ขŒ์„ ์ƒํƒœ ๋ณ€๊ฒฝ
    Seat::whereIn('id', $booking->seat_ids)->update(['status' => 'available']);
    
    // ํ™˜๋ถˆ ์ฒ˜๋ฆฌ (๊ฒฐ์ œ API ํ˜ธ์ถœ)
    // ...
    
    $booking->update(['payment_status' => 'refunded']);
    
    return redirect()->route('home')->with('success', '์˜ˆ๋งค๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์‹ค์‹œ๊ฐ„ ์ขŒ์„ ์„ ํƒ ์ธํ„ฐํŽ˜์ด์Šค๊ฐ€ ์ž‘๋™ํ•˜๊ณ , Redis๋กœ ๋™์‹œ์„ฑ ์ œ์–ด๊ฐ€ ๊ตฌํ˜„๋˜๋ฉฐ, QR ์ฝ”๋“œ ํ‹ฐ์ผ“์ด ๋ฐœ๊ธ‰๋ฉ๋‹ˆ๋‹ค. ๊ฒฐ์ œ ์—ฐ๋™ ์ค€๋น„ ์™„๋ฃŒ.

4
Step 4
# ์ถœ์—ฐ์ง„ & ๊ณ„์•ฝ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ ๊ตฌํ˜„

1. ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ:
```bash
php artisan make:controller PerformerController
php artisan make:controller ContractController
```

2. `routes/web.php`์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::resource('performers', PerformerController::class);
Route::get('/shows/{show}/contracts/create', [ContractController::class, 'create'])->name('contracts.create');
Route::post('/shows/{show}/contracts', [ContractController::class, 'store'])->name('contracts.store');
Route::get('/contracts/{contract}', [ContractController::class, 'show'])->name('contracts.show');
Route::get('/contracts/{contract}/pdf', [ContractController::class, 'generatePdf'])->name('contracts.pdf');
```

3. `PerformerController`์— ๋‹ค์Œ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„:

### index()
- ์ถœ์—ฐ์ง„/์Šคํƒœํ”„ ๋ชฉ๋ก (์นด๋“œ ๊ทธ๋ฆฌ๋“œ)
- ์—ญํ• ๋ณ„ ํ•„ํ„ฐ (๋ฐฐ์šฐ/๊ฐ€์ˆ˜/์Šคํƒœํ”„)
- ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ (์ด๋ฆ„)

### create()
- ์ถœ์—ฐ์ง„ ๋“ฑ๋ก ํผ
- ํ•„๋“œ: ์ด๋ฆ„, ์—ญํ• , ํ”„๋กœํ•„ ์ด๋ฏธ์ง€, ์•ฝ๋ ฅ, ์—ฐ๋ฝ์ฒ˜(์ด๋ฉ”์ผ, ์ „ํ™”)

### store()
- ์ถœ์—ฐ์ง„ ์ €์žฅ
- ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ (intervention/image๋กœ ๋ฆฌ์‚ฌ์ด์ง•)

### show($id)
- ์ถœ์—ฐ์ง„ ์ƒ์„ธ ์ •๋ณด
- ์ถœ์—ฐํ•œ ๊ณต์—ฐ ๋ชฉ๋ก
- ๊ณ„์•ฝ ๋‚ด์—ญ

### edit($id), update($id), destroy($id)
- ์ˆ˜์ •/์‚ญ์ œ ๊ธฐ๋Šฅ

4. `ContractController`์— ๋‹ค์Œ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„:

### create($showId)
- ๊ณ„์•ฝ์„œ ์ž‘์„ฑ ํผ
- ๊ณต์—ฐ ์ •๋ณด ํ‘œ์‹œ
- ์ถœ์—ฐ์ง„ ์„ ํƒ ๋“œ๋กญ๋‹ค์šด
- ํ•„๋“œ: ์ถœ์—ฐ์ง„, ๊ณต์—ฐ ๋‚ด ์—ญํ• , ์ถœ์—ฐ๋ฃŒ, ์ง€๊ธ‰ ์ผ์ • (JSON: [{date, amount}])

### store(Request $request, $showId)
- ๊ณ„์•ฝ ์ •๋ณด ์ €์žฅ
- PDF ๊ณ„์•ฝ์„œ ์ž๋™ ์ƒ์„ฑ (barryvdh/laravel-dompdf)
- `storage/app/public/contracts` ํด๋”์— ์ €์žฅ

### show($contractId)
- ๊ณ„์•ฝ ์ƒ์„ธ ์ •๋ณด
- PDF ๊ณ„์•ฝ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ
- "์ „์ž์„œ๋ช…" ๋ฒ„ํŠผ
- ์ง€๊ธ‰ ์ผ์ • ํƒ€์ž„๋ผ์ธ

### generatePdf($contractId)
- PDF ๊ณ„์•ฝ์„œ ๋‹ค์šด๋กœ๋“œ
- ๊ณ„์•ฝ ๋‚ด์šฉ ํ…œํ”Œ๋ฆฟ (๊ณต์—ฐ๋ช…, ์ถœ์—ฐ์ง„๋ช…, ์—ญํ• , ์ถœ์—ฐ๋ฃŒ, ์ง€๊ธ‰ ์ผ์ •)

5. Blade ๋ทฐ ์ƒ์„ฑ:

### `resources/views/performers/index.blade.php`
- ์ถœ์—ฐ์ง„ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ (4์—ด)
- ๊ฐ ์นด๋“œ: ํ”„๋กœํ•„ ์ด๋ฏธ์ง€, ์ด๋ฆ„, ์—ญํ•  ๋ฐฐ์ง€
- ์—ญํ• ๋ณ„ ํ•„ํ„ฐ ๋ฒ„ํŠผ
- "์ถœ์—ฐ์ง„ ์ถ”๊ฐ€" ๋ฒ„ํŠผ

### `resources/views/performers/create.blade.php`
- ์ถœ์—ฐ์ง„ ๋“ฑ๋ก ํผ
- ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ (Alpine.js)

### `resources/views/performers/show.blade.php`
- ์ถœ์—ฐ์ง„ ํ”„๋กœํ•„
- ์ถœ์—ฐํ•œ ๊ณต์—ฐ ๋ชฉ๋ก (์นด๋“œ)
- ๊ณ„์•ฝ ๋‚ด์—ญ ํ…Œ์ด๋ธ”

### `resources/views/contracts/create.blade.php`
- ๊ณ„์•ฝ์„œ ์ž‘์„ฑ ํผ
- ์ถœ์—ฐ์ง„ ์„ ํƒ (Select2 ๋˜๋Š” Alpine.js ๋“œ๋กญ๋‹ค์šด)
- ์ง€๊ธ‰ ์ผ์ • ๋™์  ์ถ”๊ฐ€/์‚ญ์ œ (Alpine.js)
```javascript
function contractForm() {
  return {
    schedules: [{date: '', amount: 0}],
    addSchedule() {
      this.schedules.push({date: '', amount: 0});
    },
    removeSchedule(index) {
      this.schedules.splice(index, 1);
    }
  }
}
```

### `resources/views/contracts/show.blade.php`
- ๊ณ„์•ฝ ์ƒ์„ธ ์ •๋ณด
- PDF ๊ณ„์•ฝ์„œ ์ž„๋ฒ ๋“œ (iframe)
- ์ง€๊ธ‰ ์ผ์ • ํƒ€์ž„๋ผ์ธ (Tailwind CSS๋กœ ์Šคํƒ€์ผ๋ง)
- "PDF ๋‹ค์šด๋กœ๋“œ" ๋ฒ„ํŠผ

### `resources/views/contracts/pdf.blade.php`
- PDF ๊ณ„์•ฝ์„œ ํ…œํ”Œ๋ฆฟ
- ํ—ค๋”: ํšŒ์‚ฌ ๋กœ๊ณ , ์ œ๋ชฉ "์ถœ์—ฐ ๊ณ„์•ฝ์„œ"
- ๋‚ด์šฉ: ๊ณต์—ฐ๋ช…, ์ถœ์—ฐ์ง„๋ช…, ์—ญํ• , ์ถœ์—ฐ๋ฃŒ, ์ง€๊ธ‰ ์ผ์ • ํ…Œ์ด๋ธ”
- ์„œ๋ช…๋ž€ (์ „์ž์„œ๋ช… ์ด๋ฏธ์ง€ ๋˜๋Š” ๋นˆ ๊ณต๊ฐ„)

6. PDF ์ƒ์„ฑ ๋กœ์ง:
```php
use Barryvdh\DomPDF\Facade\Pdf;

public function generatePdf($contractId)
{
    $contract = Contract::with('show', 'performer')->findOrFail($contractId);
    
    $pdf = Pdf::loadView('contracts.pdf', compact('contract'));
    
    return $pdf->download("contract_{$contract->id}.pdf");
}
```

7. ๊ณ„์•ฝ์„œ ์ž๋™ ์ƒ์„ฑ (store ๋ฉ”์„œ๋“œ):
```php
public function store(Request $request, $showId)
{
    $validated = $request->validate([
        'performer_id' => 'required|exists:performers,id',
        'role_in_show' => 'required|string',
        'fee_amount' => 'required|numeric',
        'payment_schedule' => 'required|json',
    ]);
    
    $contract = Contract::create([
        'show_id' => $showId,
        'performer_id' => $validated['performer_id'],
        'role_in_show' => $validated['role_in_show'],
        'fee_amount' => $validated['fee_amount'],
        'payment_schedule' => $validated['payment_schedule'],
    ]);
    
    // PDF ์ž๋™ ์ƒ์„ฑ
    $pdf = Pdf::loadView('contracts.pdf', compact('contract'));
    $pdfPath = "contracts/contract_{$contract->id}.pdf";
    Storage::disk('public')->put($pdfPath, $pdf->output());
    
    $contract->update(['contract_pdf' => $pdfPath]);
    
    return redirect()->route('contracts.show', $contract)->with('success', '๊ณ„์•ฝ์„œ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

8. ์ „์ž์„œ๋ช… ๊ธฐ๋Šฅ (์„ ํƒ):
- `signature_pad` ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ
- Canvas์— ์„œ๋ช… ์ž…๋ ฅ
- Base64 ์ด๋ฏธ์ง€๋กœ ์ €์žฅ
- PDF์— ์„œ๋ช… ์ด๋ฏธ์ง€ ์‚ฝ์ž…

9. UI ๊ฐœ์„ :
- ์ง€๊ธ‰ ์ผ์ • ํƒ€์ž„๋ผ์ธ (Tailwind CSS๋กœ ์ˆ˜์ง ๋ผ์ธ + ๋‚ ์งœ ๋งˆ์ปค)
- ๊ณ„์•ฝ ์ƒํƒœ ๋ฐฐ์ง€ (๋Œ€๊ธฐ์ค‘/์„œ๋ช…์™„๋ฃŒ/์ง€๊ธ‰์™„๋ฃŒ)

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์ถœ์—ฐ์ง„ ๋“ฑ๋ก ๋ฐ ๊ณ„์•ฝ์„œ ์ž‘์„ฑ์ด ๊ฐ€๋Šฅํ•˜๊ณ , PDF ๊ณ„์•ฝ์„œ๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋˜๋ฉฐ, ์ง€๊ธ‰ ์ผ์ • ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

5
Step 5
# ์ˆ˜์ต ์ •์‚ฐ & ๋Œ€์‹œ๋ณด๋“œ ๊ตฌํ˜„

1. ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ:
```bash
php artisan make:controller SettlementController
php artisan make:controller DashboardController
```

2. `routes/web.php`์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/shows/{show}/settlement', [SettlementController::class, 'show'])->name('settlement.show');
Route::post('/shows/{show}/settlement', [SettlementController::class, 'generate'])->name('settlement.generate');
Route::get('/settlements/{settlement}/pdf', [SettlementController::class, 'downloadPdf'])->name('settlement.pdf');
```

3. `DashboardController@index()` ๊ตฌํ˜„:

### ๋Œ€์‹œ๋ณด๋“œ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘
```php
public function index()
{
    // ์ „์ฒด ๋งค์ถœ ํ†ต๊ณ„
    $totalRevenue = Booking::where('payment_status', 'paid')->sum('total_price');
    $totalBookings = Booking::where('payment_status', 'paid')->count();
    $totalCustomers = Customer::count();
    
    // ์ด๋ฒˆ ๋‹ฌ ๋งค์ถœ
    $monthlyRevenue = Booking::where('payment_status', 'paid')
        ->whereMonth('created_at', now()->month)
        ->sum('total_price');
    
    // ๊ณต์—ฐ๋ณ„ ๋งค์ถœ ์ˆœ์œ„ (Top 5)
    $topShows = Show::withCount('bookings')
        ->withSum('bookings', 'total_price')
        ->orderByDesc('bookings_sum_total_price')
        ->take(5)
        ->get();
    
    // ์ผ๋ณ„ ๋งค์ถœ ์ฐจํŠธ ๋ฐ์ดํ„ฐ (์ตœ๊ทผ 30์ผ)
    $dailyRevenue = Booking::where('payment_status', 'paid')
        ->where('created_at', '>=', now()->subDays(30))
        ->selectRaw('DATE(created_at) as date, SUM(total_price) as revenue')
        ->groupBy('date')
        ->orderBy('date')
        ->get();
    
    // ์žฅ๋ฅด๋ณ„ ์˜ˆ๋งค ๋ถ„ํฌ
    $genreDistribution = Show::selectRaw('genre, COUNT(*) as count')
        ->groupBy('genre')
        ->get();
    
    return view('dashboard', compact(
        'totalRevenue',
        'totalBookings',
        'totalCustomers',
        'monthlyRevenue',
        'topShows',
        'dailyRevenue',
        'genreDistribution'
    ));
}
```

4. `SettlementController` ๊ตฌํ˜„:

### show($showId)
- ๊ณต์—ฐ๋ณ„ ์ •์‚ฐ ์ •๋ณด ํ‘œ์‹œ
- ์ด ๋งค์ถœ, ์ถœ์—ฐ์ง„ ์ง€๊ธ‰์•ก, ํšŒ์‚ฌ ์ˆ˜์ต, ํˆฌ์ž์ž ๋ฐฐ๋‹น
- ์ •์‚ฐ ๋‚ด์—ญ ํ…Œ์ด๋ธ”

### generate(Request $request, $showId)
```php
public function generate(Request $request, $showId)
{
    $show = Show::with('bookings', 'contracts')->findOrFail($showId);
    
    // ์ด ๋งค์ถœ ๊ณ„์‚ฐ
    $totalRevenue = $show->bookings()->where('payment_status', 'paid')->sum('total_price');
    
    // ์ถœ์—ฐ์ง„ ์ง€๊ธ‰์•ก ๊ณ„์‚ฐ
    $performerShare = $show->contracts->sum('fee_amount');
    
    // ์ˆ˜์ต ๋ถ„๋ฐฐ (์˜ˆ: ํšŒ์‚ฌ 60%, ํˆฌ์ž์ž 40%)
    $remainingRevenue = $totalRevenue - $performerShare;
    $companyShare = $remainingRevenue * 0.6;
    $investorShare = $remainingRevenue * 0.4;
    
    // ์ •์‚ฐ ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ
    $settlement = Settlement::create([
        'show_id' => $showId,
        'total_revenue' => $totalRevenue,
        'performer_share' => $performerShare,
        'company_share' => $companyShare,
        'investor_share' => $investorShare,
        'settlement_date' => now(),
    ]);
    
    // PDF ๋ฆฌํฌํŠธ ์ƒ์„ฑ
    $pdf = Pdf::loadView('settlements.pdf', compact('settlement', 'show'));
    $pdfPath = "settlements/settlement_{$settlement->id}.pdf";
    Storage::disk('public')->put($pdfPath, $pdf->output());
    
    $settlement->update(['pdf_report' => $pdfPath]);
    
    return redirect()->route('settlement.show', $show)->with('success', '์ •์‚ฐ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

### downloadPdf($settlementId)
```php
public function downloadPdf($settlementId)
{
    $settlement = Settlement::with('show')->findOrFail($settlementId);
    
    return Storage::disk('public')->download($settlement->pdf_report);
}
```

5. Blade ๋ทฐ ์ƒ์„ฑ:

### `resources/views/dashboard.blade.php`
- ๋Œ€์‹œ๋ณด๋“œ ๋ ˆ์ด์•„์›ƒ
- 4๊ฐœ ํ†ต๊ณ„ ์นด๋“œ (์ด ๋งค์ถœ, ์˜ˆ๋งค ์ˆ˜, ๊ณ ๊ฐ ์ˆ˜, ์ด๋ฒˆ ๋‹ฌ ๋งค์ถœ)
- Chart.js๋กœ ์ผ๋ณ„ ๋งค์ถœ ๋ผ์ธ ์ฐจํŠธ
- Chart.js๋กœ ์žฅ๋ฅด๋ณ„ ์˜ˆ๋งค ํŒŒ์ด ์ฐจํŠธ
- Top 5 ๊ณต์—ฐ ์ˆœ์œ„ ํ…Œ์ด๋ธ”

Chart.js ์„ค์น˜:
```bash
npm install chart.js
```

`resources/js/app.js`์— ์ถ”๊ฐ€:
```javascript
import Chart from 'chart.js/auto';

window.Chart = Chart;
```

Blade ๋ทฐ์—์„œ ์ฐจํŠธ ๋ Œ๋”๋ง:
```html
<canvas id="revenueChart"></canvas>

<script>
const ctx = document.getElementById('revenueChart').getContext('2d');
const chart = new Chart(ctx, {
    type: 'line',
    data: {
        labels: @json($dailyRevenue->pluck('date')),
        datasets: [{
            label: '์ผ๋ณ„ ๋งค์ถœ',
            data: @json($dailyRevenue->pluck('revenue')),
            borderColor: 'rgb(59, 130, 246)',
            tension: 0.1
        }]
    },
    options: {
        responsive: true,
        scales: {
            y: {
                beginAtZero: true
            }
        }
    }
});
</script>
```

### `resources/views/settlements/show.blade.php`
- ์ •์‚ฐ ์š”์•ฝ ์นด๋“œ (์ด ๋งค์ถœ, ์ถœ์—ฐ์ง„ ์ง€๊ธ‰์•ก, ํšŒ์‚ฌ ์ˆ˜์ต, ํˆฌ์ž์ž ๋ฐฐ๋‹น)
- "์ •์‚ฐ ์‹คํ–‰" ๋ฒ„ํŠผ (settlement๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ)
- ์ •์‚ฐ ๋‚ด์—ญ ํ…Œ์ด๋ธ”
- "PDF ๋‹ค์šด๋กœ๋“œ" ๋ฒ„ํŠผ

### `resources/views/settlements/pdf.blade.php`
- PDF ์ •์‚ฐ ๋ฆฌํฌํŠธ ํ…œํ”Œ๋ฆฟ
- ํ—ค๋”: ํšŒ์‚ฌ ๋กœ๊ณ , ์ œ๋ชฉ "์ •์‚ฐ ๋ฆฌํฌํŠธ"
- ๊ณต์—ฐ ์ •๋ณด (์ œ๋ชฉ, ์‹œ์ฆŒ, ๊ธฐ๊ฐ„)
- ์ •์‚ฐ ๋‚ด์—ญ ํ…Œ์ด๋ธ” (ํ•ญ๋ชฉ, ๊ธˆ์•ก)
- ์ถœ์—ฐ์ง„๋ณ„ ์ง€๊ธ‰ ๋‚ด์—ญ (์ด๋ฆ„, ์—ญํ• , ์ถœ์—ฐ๋ฃŒ)
- ์ด๊ณ„

6. Excel ๋ฆฌํฌํŠธ ๋‹ค์šด๋กœ๋“œ (๋ณด๋„ˆ์Šค):
```bash
composer require maatwebsite/excel
```

```php
php artisan make:export SettlementExport
```

`SettlementExport` ํด๋ž˜์Šค:
```php
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;

class SettlementExport implements FromCollection, WithHeadings
{
    protected $settlement;
    
    public function __construct(Settlement $settlement)
    {
        $this->settlement = $settlement;
    }
    
    public function collection()
    {
        return collect([
            ['์ด ๋งค์ถœ', $this->settlement->total_revenue],
            ['์ถœ์—ฐ์ง„ ์ง€๊ธ‰์•ก', $this->settlement->performer_share],
            ['ํšŒ์‚ฌ ์ˆ˜์ต', $this->settlement->company_share],
            ['ํˆฌ์ž์ž ๋ฐฐ๋‹น', $this->settlement->investor_share],
        ]);
    }
    
    public function headings(): array
    {
        return ['ํ•ญ๋ชฉ', '๊ธˆ์•ก'];
    }
}
```

๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/settlements/{settlement}/excel', function ($settlementId) {
    $settlement = Settlement::findOrFail($settlementId);
    return Excel::download(new SettlementExport($settlement), "settlement_{$settlement->id}.xlsx");
})->name('settlement.excel');
```

7. UI ๊ฐœ์„ :
- ๋Œ€์‹œ๋ณด๋“œ ํ†ต๊ณ„ ์นด๋“œ์— ์•„์ด์ฝ˜ ์ถ”๊ฐ€ (Heroicons)
- ์ฐจํŠธ ์ƒ‰์ƒ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•
- ์ •์‚ฐ ๋ฆฌํฌํŠธ ํ…Œ์ด๋ธ” ์Šคํƒ€์ผ๋ง

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋Œ€์‹œ๋ณด๋“œ์—์„œ ๋งค์ถœ ํ†ต๊ณ„์™€ ์ฐจํŠธ๊ฐ€ ํ‘œ์‹œ๋˜๊ณ , ๊ณต์—ฐ๋ณ„ ์ •์‚ฐ ๋ฆฌํฌํŠธ๊ฐ€ PDF/Excel๋กœ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

6
Step 6
# ๊ด€๊ฐ ๊ด€๋ฆฌ & ๋ฆฌ๋ทฐ ์‹œ์Šคํ…œ ๊ตฌํ˜„

1. ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ:
```bash
php artisan make:controller CustomerController
php artisan make:controller ReviewController
```

2. `routes/web.php`์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::resource('customers', CustomerController::class);
Route::post('/bookings/{booking}/review', [ReviewController::class, 'store'])->name('reviews.store');
Route::get('/shows/{show}/reviews', [ReviewController::class, 'index'])->name('reviews.index');
```

3. `CustomerController` ๊ตฌํ˜„:

### index()
- ๊ด€๊ฐ ๋ชฉ๋ก (ํ…Œ์ด๋ธ”)
- ํ•„ํ„ฐ: ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ (regular/vip/platinum)
- ๊ฒ€์ƒ‰: ์ด๋ฆ„, ์ด๋ฉ”์ผ
- ์ •๋ ฌ: ์ด ๊ตฌ๋งค์•ก, ๊ฐ€์ž…์ผ

### show($customerId)
- ๊ด€๊ฐ ์ƒ์„ธ ์ •๋ณด
- ์˜ˆ๋งค ๋‚ด์—ญ (์นด๋“œ ๊ทธ๋ฆฌ๋“œ)
- ๋ฆฌ๋ทฐ ์ž‘์„ฑ ๋‚ด์—ญ
- ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ & ํ˜œํƒ ํ‘œ์‹œ

### edit($customerId), update($customerId)
- ๊ด€๊ฐ ์ •๋ณด ์ˆ˜์ • (๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ ๋ณ€๊ฒฝ)

4. `ReviewController` ๊ตฌํ˜„:

### store(Request $request, $bookingId)
```php
public function store(Request $request, $bookingId)
{
    $validated = $request->validate([
        'rating' => 'required|integer|min:1|max:5',
        'comment' => 'required|string|max:1000',
    ]);
    
    $booking = Booking::findOrFail($bookingId);
    
    // ์ด๋ฏธ ๋ฆฌ๋ทฐ๋ฅผ ์ž‘์„ฑํ–ˆ๋Š”์ง€ ํ™•์ธ
    if ($booking->review) {
        return back()->with('error', '์ด๋ฏธ ๋ฆฌ๋ทฐ๋ฅผ ์ž‘์„ฑํ•˜์…จ์Šต๋‹ˆ๋‹ค.');
    }
    
    Review::create([
        'booking_id' => $bookingId,
        'customer_id' => $booking->customer_id,
        'show_id' => $booking->show_id,
        'rating' => $validated['rating'],
        'comment' => $validated['comment'],
    ]);
    
    return back()->with('success', '๋ฆฌ๋ทฐ๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

### index($showId)
- ๊ณต์—ฐ๋ณ„ ๋ฆฌ๋ทฐ ๋ชฉ๋ก
- ํ‰๊ท  ๋ณ„์  ํ‘œ์‹œ
- ๋ณ„์ ๋ณ„ ํ•„ํ„ฐ (5์ ๋งŒ, 4์  ์ด์ƒ ๋“ฑ)
- ์ตœ์‹ ์ˆœ/๋ณ„์ ์ˆœ ์ •๋ ฌ

5. Blade ๋ทฐ ์ƒ์„ฑ:

### `resources/views/customers/index.blade.php`
- ๊ด€๊ฐ ๋ชฉ๋ก ํ…Œ์ด๋ธ”
- ์ปฌ๋Ÿผ: ์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ „ํ™”๋ฒˆํ˜ธ, ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰, ์ด ๊ตฌ๋งค์•ก, ๊ฐ€์ž…์ผ
- ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ ๋ฐฐ์ง€ (์ƒ‰์ƒ ๊ตฌ๋ถ„)
- ํ•„ํ„ฐ & ๊ฒ€์ƒ‰ ๋ฐ”

### `resources/views/customers/show.blade.php`
- ๊ด€๊ฐ ํ”„๋กœํ•„ ์นด๋“œ
- ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ & ํ˜œํƒ ์„ค๋ช…
- ์˜ˆ๋งค ๋‚ด์—ญ (์นด๋“œ ๊ทธ๋ฆฌ๋“œ: ๊ณต์—ฐ ํฌ์Šคํ„ฐ, ์ œ๋ชฉ, ๋‚ ์งœ, ์ขŒ์„)
- ์ž‘์„ฑํ•œ ๋ฆฌ๋ทฐ ๋ชฉ๋ก

### `resources/views/reviews/index.blade.php`
- ๊ณต์—ฐ ๋ฆฌ๋ทฐ ๋ชฉ๋ก
- ํ‰๊ท  ๋ณ„์  ํ‘œ์‹œ (ํฐ ์ˆซ์ž + ๋ณ„ ์•„์ด์ฝ˜)
- ๋ณ„์  ๋ถ„ํฌ ์ฐจํŠธ (5์ : 50%, 4์ : 30%, ...)
- ๋ฆฌ๋ทฐ ์นด๋“œ (ํ”„๋กœํ•„ ์ด๋ฏธ์ง€, ์ด๋ฆ„, ๋ณ„์ , ์ฝ”๋ฉ˜ํŠธ, ์ž‘์„ฑ์ผ)
- ๋ณ„์ ๋ณ„ ํ•„ํ„ฐ ๋ฒ„ํŠผ

### `resources/views/bookings/show.blade.php`์— ๋ฆฌ๋ทฐ ์ž‘์„ฑ ํผ ์ถ”๊ฐ€
```html
@if(!$booking->review)
<div class="mt-8">
    <h3 class="text-xl font-bold mb-4">๊ณต์—ฐ์€ ์–ด๋– ์…จ๋‚˜์š”?</h3>
    <form action="{{ route('reviews.store', $booking) }}" method="POST">
        @csrf
        <div class="mb-4">
            <label class="block text-sm font-medium mb-2">๋ณ„์ </label>
            <div x-data="{ rating: 0 }" class="flex gap-2">
                <template x-for="i in 5" :key="i">
                    <button type="button" @click="rating = i" class="text-3xl">
                        <span x-show="i <= rating">โญ</span>
                        <span x-show="i > rating">โ˜†</span>
                    </button>
                </template>
                <input type="hidden" name="rating" x-model="rating">
            </div>
        </div>
        <div class="mb-4">
            <label class="block text-sm font-medium mb-2">ํ›„๊ธฐ</label>
            <textarea name="comment" rows="4" class="w-full border rounded-lg p-3" required></textarea>
        </div>
        <button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700">
            ๋ฆฌ๋ทฐ ๋“ฑ๋ก
        </button>
    </form>
</div>
@endif
```

6. ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ ์ž๋™ ์—…๊ทธ๋ ˆ์ด๋“œ ๋กœ์ง:

`app/Observers/BookingObserver.php` ์ƒ์„ฑ:
```php
php artisan make:observer BookingObserver --model=Booking
```

```php
public function updated(Booking $booking)
{
    if ($booking->payment_status === 'paid') {
        $customer = $booking->customer;
        $totalSpent = $customer->bookings()->where('payment_status', 'paid')->sum('total_price');
        
        // ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ ์ž๋™ ์—…๊ทธ๋ ˆ์ด๋“œ
        if ($totalSpent >= 1000000) {
            $customer->update(['membership_tier' => 'platinum']);
        } elseif ($totalSpent >= 500000) {
            $customer->update(['membership_tier' => 'vip']);
        }
        
        $customer->update(['total_spent' => $totalSpent]);
    }
}
```

`app/Providers/AppServiceProvider.php`์— ๋“ฑ๋ก:
```php
use App\Models\Booking;
use App\Observers\BookingObserver;

public function boot()
{
    Booking::observe(BookingObserver::class);
}
```

7. ๋ฆฌ๋ทฐ ๊ฐ์ • ๋ถ„์„ (๋ณด๋„ˆ์Šค - OpenAI API):
```bash
composer require openai-php/client
```

`ReviewController@store`์— ์ถ”๊ฐ€:
```php
use OpenAI\Client;

public function store(Request $request, $bookingId)
{
    // ... ๊ธฐ์กด ๋กœ์ง
    
    $review = Review::create([...]);
    
    // ๊ฐ์ • ๋ถ„์„ (ํ๋กœ ์ฒ˜๋ฆฌ)
    dispatch(function () use ($review) {
        $client = OpenAI::client(config('services.openai.key'));
        
        $result = $client->chat()->create([
            'model' => 'gpt-4',
            'messages' => [
                ['role' => 'system', 'content' => '๋‹ค์Œ ๊ณต์—ฐ ๋ฆฌ๋ทฐ์˜ ๊ฐ์ •์„ ๋ถ„์„ํ•˜์—ฌ ๊ธ์ •/๋ถ€์ •/์ค‘๋ฆฝ ์ค‘ ํ•˜๋‚˜๋กœ ๋ถ„๋ฅ˜ํ•˜์„ธ์š”.'],
                ['role' => 'user', 'content' => $review->comment],
            ],
        ]);
        
        $sentiment = $result->choices[0]->message->content;
        $review->update(['sentiment' => $sentiment]);
    });
    
    return back()->with('success', '๋ฆฌ๋ทฐ๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

8. UI ๊ฐœ์„ :
- ๋ฆฌ๋ทฐ ์นด๋“œ์— ๊ฐ์ • ๋ฐฐ์ง€ ์ถ”๊ฐ€ (๊ธ์ •: ์ดˆ๋ก, ๋ถ€์ •: ๋นจ๊ฐ•, ์ค‘๋ฆฝ: ํšŒ์ƒ‰)
- ๋ณ„์  ํ‰๊ท  ์‹œ๊ฐํ™” (์ง„ํ–‰๋ฐ”)
- ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰๋ณ„ ๋ฐฐ์ง€ ์ƒ‰์ƒ (regular: ํšŒ์ƒ‰, vip: ๊ธˆ์ƒ‰, platinum: ๋ณด๋ผ)

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๊ด€๊ฐ ๋ชฉ๋ก ๋ฐ ์ƒ์„ธ ์ •๋ณด๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๊ณ , ์˜ˆ๋งค ํ›„ ๋ฆฌ๋ทฐ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰์ด ์ž๋™์œผ๋กœ ์—…๊ทธ๋ ˆ์ด๋“œ๋ฉ๋‹ˆ๋‹ค.

7
Step 7
# ์•Œ๋ฆผ & ์ž๋™ํ™” ์‹œ์Šคํ…œ ๊ตฌํ˜„

1. Job & Notification ์ƒ์„ฑ:
```bash
php artisan make:job SendBookingReminderJob
php artisan make:job SendTicketOpenNotificationJob
php artisan make:notification BookingReminderNotification
php artisan make:notification TicketOpenNotification
```

2. `SendBookingReminderJob` ๊ตฌํ˜„:
```php
namespace App\Jobs;

use App\Models\Booking;
use App\Notifications\BookingReminderNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SendBookingReminderJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    protected $booking;
    
    public function __construct(Booking $booking)
    {
        $this->booking = $booking;
    }
    
    public function handle()
    {
        $this->booking->customer->notify(new BookingReminderNotification($this->booking));
    }
}
```

3. `BookingReminderNotification` ๊ตฌํ˜„:
```php
namespace App\Notifications;

use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BookingReminderNotification extends Notification
{
    use Queueable;
    
    protected $booking;
    
    public function __construct(Booking $booking)
    {
        $this->booking = $booking;
    }
    
    public function via($notifiable)
    {
        return ['mail', 'database'];
    }
    
    public function toMail($notifiable)
    {
        return (new MailMessage)
            ->subject('๊ณต์—ฐ ๊ด€๋žŒ ์•ˆ๋‚ด')
            ->greeting('์•ˆ๋…•ํ•˜์„ธ์š”, ' . $notifiable->name . '๋‹˜!')
            ->line('์˜ˆ๋งคํ•˜์‹  ๊ณต์—ฐ์ด ๊ณง ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.')
            ->line('๊ณต์—ฐ๋ช…: ' . $this->booking->show->title)
            ->line('๋‚ ์งœ: ' . $this->booking->show_date)
            ->line('์‹œ๊ฐ„: ' . $this->booking->show_time)
            ->action('ํ‹ฐ์ผ“ ํ™•์ธ', route('booking.show', $this->booking))
            ->line('์ฆ๊ฑฐ์šด ๊ด€๋žŒ ๋˜์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค!');
    }
    
    public function toArray($notifiable)
    {
        return [
            'booking_id' => $this->booking->id,
            'show_title' => $this->booking->show->title,
            'show_date' => $this->booking->show_date,
        ];
    }
}
```

4. ์Šค์ผ€์ค„๋Ÿฌ ์„ค์ • (`app/Console/Kernel.php`):
```php
protected function schedule(Schedule $schedule)
{
    // D-7 ์•Œ๋ฆผ
    $schedule->call(function () {
        $bookings = Booking::where('payment_status', 'paid')
            ->whereDate('show_date', now()->addDays(7))
            ->get();
        
        foreach ($bookings as $booking) {
            SendBookingReminderJob::dispatch($booking);
        }
    })->daily();
    
    // D-1 ์•Œ๋ฆผ
    $schedule->call(function () {
        $bookings = Booking::where('payment_status', 'paid')
            ->whereDate('show_date', now()->addDay())
            ->get();
        
        foreach ($bookings as $booking) {
            SendBookingReminderJob::dispatch($booking);
        }
    })->daily();
    
    // ์ •์‚ฐ ๋งˆ๊ฐ์ผ ๋ฆฌ๋งˆ์ธ๋”
    $schedule->call(function () {
        $shows = Show::whereDate('end_date', now()->addDays(3))
            ->whereDoesntHave('settlement')
            ->get();
        
        foreach ($shows as $show) {
            // ๊ด€๋ฆฌ์ž์—๊ฒŒ ์•Œ๋ฆผ (๊ตฌํ˜„ ์ƒ๋žต)
        }
    })->daily();
}
```

5. ํ ์›Œ์ปค ์„ค์ •:

`.env`์— ์ถ”๊ฐ€:
```
QUEUE_CONNECTION=redis
```

ํ ์›Œ์ปค ์‹คํ–‰ (ํ”„๋กœ๋•์…˜):
```bash
php artisan queue:work --queue=default,notifications
```

6. ์ด๋ฉ”์ผ ์„ค์ • (`.env`):
```
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=your-email@gmail.com
MAIL_PASSWORD=your-app-password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@theater-platform.com
MAIL_FROM_NAME="๊ณต์—ฐ ํ”„๋กœ๋“€์„œ ํ”Œ๋žซํผ"
```

7. ์•Œ๋ฆผ ์„ผํ„ฐ UI ๊ตฌํ˜„:

### ์•Œ๋ฆผ ํ…Œ์ด๋ธ” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (Laravel ๊ธฐ๋ณธ ์ œ๊ณต):
```bash
php artisan notifications:table
php artisan migrate
```

### ์•Œ๋ฆผ ์ปจํŠธ๋กค๋Ÿฌ:
```bash
php artisan make:controller NotificationController
```

```php
public function index()
{
    $notifications = auth()->user()->notifications()->paginate(20);
    return view('notifications.index', compact('notifications'));
}

public function markAsRead($notificationId)
{
    auth()->user()->notifications()->findOrFail($notificationId)->markAsRead();
    return back();
}
```

### ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read');
```

### Blade ๋ทฐ (`resources/views/notifications/index.blade.php`):
```html
<div class="max-w-4xl mx-auto">
    <h1 class="text-2xl font-bold mb-6">์•Œ๋ฆผ</h1>
    
    @forelse($notifications as $notification)
    <div class="bg-white border rounded-lg p-4 mb-4 {{ $notification->read_at ? 'opacity-60' : '' }}">
        <div class="flex justify-between items-start">
            <div>
                <p class="font-semibold">{{ $notification->data['show_title'] }}</p>
                <p class="text-sm text-gray-600">{{ $notification->data['show_date'] }}</p>
                <p class="text-xs text-gray-400 mt-2">{{ $notification->created_at->diffForHumans() }}</p>
            </div>
            @if(!$notification->read_at)
            <form action="{{ route('notifications.read', $notification) }}" method="POST">
                @csrf
                <button class="text-blue-600 text-sm">์ฝ์Œ์œผ๋กœ ํ‘œ์‹œ</button>
            </form>
            @endif
        </div>
    </div>
    @empty
    <p class="text-gray-500">์•Œ๋ฆผ์ด ์—†์Šต๋‹ˆ๋‹ค.</p>
    @endforelse
    
    {{ $notifications->links() }}
</div>
```

### ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ”์— ์•Œ๋ฆผ ์•„์ด์ฝ˜ ์ถ”๊ฐ€ (`resources/views/layouts/app.blade.php`):
```html
<div class="relative" x-data="{ open: false }">
    <button @click="open = !open" class="relative">
        ๐Ÿ””
        @if(auth()->user()->unreadNotifications->count() > 0)
        <span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
            {{ auth()->user()->unreadNotifications->count() }}
        </span>
        @endif
    </button>
    
    <div x-show="open" @click.away="open = false" class="absolute right-0 mt-2 w-80 bg-white border rounded-lg shadow-lg">
        <div class="p-4">
            <h3 class="font-bold mb-2">์ตœ๊ทผ ์•Œ๋ฆผ</h3>
            @foreach(auth()->user()->unreadNotifications->take(5) as $notification)
            <div class="py-2 border-b">
                <p class="text-sm">{{ $notification->data['show_title'] }}</p>
                <p class="text-xs text-gray-500">{{ $notification->created_at->diffForHumans() }}</p>
            </div>
            @endforeach
            <a href="{{ route('notifications.index') }}" class="block text-center text-blue-600 text-sm mt-2">
                ๋ชจ๋‘ ๋ณด๊ธฐ
            </a>
        </div>
    </div>
</div>
```

8. SMS ์•Œ๋ฆผ (๋ณด๋„ˆ์Šค - Twilio):
```bash
composer require twilio/sdk
```

`BookingReminderNotification`์— SMS ์ฑ„๋„ ์ถ”๊ฐ€:
```php
public function via($notifiable)
{
    return ['mail', 'database', 'twilio'];
}

public function toTwilio($notifiable)
{
    return (new TwilioSmsMessage())
        ->content('๊ณต์—ฐ ๊ด€๋žŒ ์•ˆ๋‚ด: ' . $this->booking->show->title . ' (' . $this->booking->show_date . ')');
}
```

9. Redis ํ ๋ชจ๋‹ˆํ„ฐ๋ง (Laravel Horizon):
```bash
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
```

๋ธŒ๋ผ์šฐ์ €์—์„œ `/horizon` ์ ‘์†ํ•˜์—ฌ ํ ์ž‘์—… ๋ชจ๋‹ˆํ„ฐ๋ง.

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์ด๋ฉ”์ผ ์•Œ๋ฆผ์ด ์ž๋™์œผ๋กœ ๋ฐœ์†ก๋˜๊ณ , ์•Œ๋ฆผ ์„ผํ„ฐ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ •์ƒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. ํ ์‹œ์Šคํ…œ์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์ž‘์—…์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

8
Step 8
# ๊ถŒํ•œ ๊ด€๋ฆฌ & ํ…Œ์ŠคํŠธ & ๋ฐฐํฌ ์ค€๋น„

1. ๊ถŒํ•œ ๊ด€๋ฆฌ (Policy & Gate) ๊ตฌํ˜„:

### Policy ์ƒ์„ฑ:
```bash
php artisan make:policy SeasonPolicy --model=Season
php artisan make:policy ShowPolicy --model=Show
php artisan make:policy BookingPolicy --model=Booking
```

### `SeasonPolicy` ๊ตฌํ˜„:
```php
namespace App\Policies;

use App\Models\Season;
use App\Models\User;

class SeasonPolicy
{
    public function viewAny(User $user)
    {
        return true; // ๋ชจ๋“  ์‚ฌ์šฉ์ž ์กฐํšŒ ๊ฐ€๋Šฅ
    }
    
    public function view(User $user, Season $season)
    {
        return true;
    }
    
    public function create(User $user)
    {
        return $user->is_admin; // ๊ด€๋ฆฌ์ž๋งŒ ์ƒ์„ฑ ๊ฐ€๋Šฅ
    }
    
    public function update(User $user, Season $season)
    {
        return $user->is_admin;
    }
    
    public function delete(User $user, Season $season)
    {
        return $user->is_admin;
    }
}
```

### `ShowPolicy`, `BookingPolicy`๋„ ์œ ์‚ฌํ•˜๊ฒŒ ๊ตฌํ˜„

### `app/Providers/AuthServiceProvider.php`์— ๋“ฑ๋ก:
```php
protected $policies = [
    Season::class => SeasonPolicy::class,
    Show::class => ShowPolicy::class,
    Booking::class => BookingPolicy::class,
];
```

### ์ปจํŠธ๋กค๋Ÿฌ์—์„œ Policy ์‚ฌ์šฉ:
```php
public function create()
{
    $this->authorize('create', Season::class);
    return view('seasons.create');
}

public function update(Request $request, $id)
{
    $season = Season::findOrFail($id);
    $this->authorize('update', $season);
    // ...
}
```

### Blade ๋ทฐ์—์„œ ๊ถŒํ•œ ํ™•์ธ:
```html
@can('create', App\Models\Season::class)
<a href="{{ route('seasons.create') }}" class="btn btn-primary">์ƒˆ ์‹œ์ฆŒ ์ถ”๊ฐ€</a>
@endcan

@can('update', $season)
<a href="{{ route('seasons.edit', $season) }}" class="btn btn-secondary">์ˆ˜์ •</a>
@endcan
```

2. ์‚ฌ์šฉ์ž ์—ญํ•  ์ถ”๊ฐ€ (users ํ…Œ์ด๋ธ” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜):
```bash
php artisan make:migration add_role_to_users_table
```

```php
public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->boolean('is_admin')->default(false);
    });
}
```

```bash
php artisan migrate
```

3. PHPUnit ํ…Œ์ŠคํŠธ ์ž‘์„ฑ:

### Feature ํ…Œ์ŠคํŠธ ์ƒ์„ฑ:
```bash
php artisan make:test SeasonTest
php artisan make:test ShowTest
php artisan make:test BookingTest
```

### `SeasonTest` ์˜ˆ์‹œ:
```php
namespace Tests\Feature;

use App\Models\Season;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SeasonTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_user_can_view_seasons()
    {
        $season = Season::factory()->create();
        
        $response = $this->get(route('seasons.index'));
        
        $response->assertStatus(200);
        $response->assertSee($season->name);
    }
    
    public function test_admin_can_create_season()
    {
        $admin = User::factory()->create(['is_admin' => true]);
        
        $response = $this->actingAs($admin)->post(route('seasons.store'), [
            'name' => '2024 ๋ด„ ์‹œ์ฆŒ',
            'description' => '์„ค๋ช…',
            'start_date' => '2024-03-01',
            'end_date' => '2024-05-31',
        ]);
        
        $response->assertRedirect();
        $this->assertDatabaseHas('seasons', ['name' => '2024 ๋ด„ ์‹œ์ฆŒ']);
    }
    
    public function test_regular_user_cannot_create_season()
    {
        $user = User::factory()->create(['is_admin' => false]);
        
        $response = $this->actingAs($user)->post(route('seasons.store'), [
            'name' => '2024 ๋ด„ ์‹œ์ฆŒ',
        ]);
        
        $response->assertForbidden();
    }
}
```

### `BookingTest` ์˜ˆ์‹œ:
```php
public function test_user_can_book_available_seats()
{
    $show = Show::factory()->create();
    $seat = Seat::factory()->create(['status' => 'available']);
    $customer = Customer::factory()->create();
    
    $response = $this->post(route('booking.store', $show), [
        'seat_ids' => [$seat->id],
        'customer_id' => $customer->id,
        'show_date' => '2024-06-01',
        'show_time' => '19:00',
    ]);
    
    $response->assertRedirect();
    $this->assertDatabaseHas('bookings', [
        'customer_id' => $customer->id,
        'show_id' => $show->id,
    ]);
}

public function test_user_cannot_book_occupied_seats()
{
    $show = Show::factory()->create();
    $seat = Seat::factory()->create(['status' => 'occupied']);
    $customer = Customer::factory()->create();
    
    $response = $this->post(route('booking.store', $show), [
        'seat_ids' => [$seat->id],
        'customer_id' => $customer->id,
    ]);
    
    $response->assertSessionHasErrors();
}
```

### ํ…Œ์ŠคํŠธ ์‹คํ–‰:
```bash
php artisan test
```

4. Factory ์ƒ์„ฑ (ํ…Œ์ŠคํŠธ์šฉ ๋”๋ฏธ ๋ฐ์ดํ„ฐ):
```bash
php artisan make:factory SeasonFactory
php artisan make:factory ShowFactory
php artisan make:factory BookingFactory
```

### `SeasonFactory` ์˜ˆ์‹œ:
```php
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class SeasonFactory extends Factory
{
    public function definition()
    {
        return [
            'name' => $this->faker->words(3, true) . ' ์‹œ์ฆŒ',
            'description' => $this->faker->paragraph,
            'start_date' => $this->faker->date(),
            'end_date' => $this->faker->date(),
            'status' => $this->faker->randomElement(['upcoming', 'ongoing', 'completed']),
        ];
    }
}
```

5. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ๋ณด์•ˆ:

### `.env.example` ํŒŒ์ผ ์—…๋ฐ์ดํŠธ:
```
APP_NAME="๊ณต์—ฐ ํ”„๋กœ๋“€์„œ ํ”Œ๋žซํผ"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://your-domain.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=theater_platform
DB_USERNAME=root
DB_PASSWORD=

QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1

MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=

PORTONE_API_KEY=
PORTONE_API_SECRET=
```

6. ํ”„๋กœ๋•์…˜ ์ตœ์ ํ™”:
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan optimize
```

7. ๋ฐฐํฌ ์ฒดํฌ๋ฆฌ์ŠคํŠธ:
- [ ] `.env` ํŒŒ์ผ ์„ค์ • (ํ”„๋กœ๋•์…˜ DB, ๋ฉ”์ผ, ๊ฒฐ์ œ API ํ‚ค)
- [ ] `APP_DEBUG=false` ์„ค์ •
- [ ] `php artisan key:generate` ์‹คํ–‰
- [ ] ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰
- [ ] Redis ์„œ๋ฒ„ ์‹คํ–‰
- [ ] ํ ์›Œ์ปค ์‹คํ–‰ (`php artisan queue:work`)
- [ ] ์Šค์ผ€์ค„๋Ÿฌ Cron ์„ค์ • (`* * * * * php /path/to/artisan schedule:run >> /dev/null 2>&1`)
- [ ] SSL ์ธ์ฆ์„œ ์„ค์น˜ (Let's Encrypt)
- [ ] Nginx/Apache ์„ค์ •
- [ ] ๋ฐฉํ™”๋ฒฝ ์„ค์ • (ํฌํŠธ 80, 443 ์—ด๊ธฐ)

8. ๋ณด์•ˆ ๊ฐœ์„ :
- CSRF ํ† ํฐ ๊ฒ€์ฆ (Laravel ๊ธฐ๋ณธ ์ œ๊ณต)
- SQL Injection ๋ฐฉ์ง€ (Eloquent ์‚ฌ์šฉ)
- XSS ๋ฐฉ์ง€ (Blade `{{ }}` ์ž๋™ ์ด์Šค์ผ€์ดํ”„)
- Rate Limiting (API ๋ผ์šฐํŠธ์— `throttle` ๋ฏธ๋“ค์›จ์–ด ์ ์šฉ)

```php
Route::middleware('throttle:60,1')->group(function () {
    Route::post('/bookings', [BookingController::class, 'store']);
});
```

9. ๋ชจ๋‹ˆํ„ฐ๋ง & ๋กœ๊น…:
- Laravel Telescope ์„ค์น˜ (๊ฐœ๋ฐœ ํ™˜๊ฒฝ)
```bash
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
```

- ํ”„๋กœ๋•์…˜ ๋กœ๊ทธ ๋ชจ๋‹ˆํ„ฐ๋ง (Sentry, Bugsnag ๋“ฑ)

10. README.md ์ž‘์„ฑ:
```markdown
# ๐ŸŽญ ๊ณต์—ฐ ํ”„๋กœ๋“€์„œ ์‹œ์ฆŒ ๊ด€๋ฆฌ ํ”Œ๋žซํผ

๊ณต์—ฐ ์ œ์ž‘์‚ฌ๋ฅผ ์œ„ํ•œ ์˜ฌ์ธ์› ์‹œ์ฆŒ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค.

## ์ฃผ์š” ๊ธฐ๋Šฅ
- ์‹œ์ฆŒ & ๊ณต์—ฐ ๊ด€๋ฆฌ
- ์‹ค์‹œ๊ฐ„ ์ขŒ์„ ์˜ˆ๋งค
- ์ถœ์—ฐ์ง„ ๊ณ„์•ฝ ๊ด€๋ฆฌ
- ์ˆ˜์ต ์ •์‚ฐ & ๋ฆฌํฌํŠธ
- ๊ด€๊ฐ ๋ฆฌ๋ทฐ & ๋ฉค๋ฒ„์‹ญ
- ์ž๋™ ์•Œ๋ฆผ ์‹œ์Šคํ…œ

## ์„ค์น˜ ๋ฐฉ๋ฒ•
1. `git clone https://github.com/your-repo/theater-season-producer-platform.git`
2. `composer install`
3. `npm install && npm run build`
4. `.env` ํŒŒ์ผ ์„ค์ •
5. `php artisan key:generate`
6. `php artisan migrate --seed`
7. `php artisan serve`

## ๋ผ์ด์„ ์Šค
MIT
```

ํ”„๋กœ์ ํŠธ๊ฐ€ ์™„์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค! ๐ŸŽ‰

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๊ถŒํ•œ ๊ด€๋ฆฌ๊ฐ€ ์ž‘๋™ํ•˜๊ณ , PHPUnit ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผํ•˜๋ฉฐ, ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ์ค€๋น„๊ฐ€ ์™„๋ฃŒ๋ฉ๋‹ˆ๋‹ค. ๋ณด์•ˆ ๋ฐ ์ตœ์ ํ™”๊ฐ€ ์ ์šฉ๋˜๊ณ  README.md๊ฐ€ ์ž‘์„ฑ๋ฉ๋‹ˆ๋‹ค.

์ œ์ถœ๋œ ์ž‘ํ’ˆ (0)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!