๐ญ ๊ณต์ฐ ํ๋ก๋์ ์์ฆ ๊ด๋ฆฌ ํ๋ซํผ - ๊ธฐํ๋ถํฐ ์ ์ฐ๊น์ง ์ฌ์ธ์ ์์คํ
theater-season-producer-platform
๋ฌธ์ ์ค๋ช
๐ฏ ํ๋ก์ ํธ ๊ฐ์
๊ณต์ฐ ์ ์์ฌ/ํ๋ก๋์๋ฅผ ์ํ ์์ฆ ํตํฉ ๊ด๋ฆฌ ์์คํ ์ ๋๋ค. ์ฌ๋ฌ ๊ณต์ฐ ์์ฆ์ ๋์์ ๊ด๋ฆฌํ๊ณ , ํฐ์ผ ํ๋งค๋ถํฐ ์ถ์ฐ์ง ๊ณ์ฝ, ์์ต ์ ์ฐ๊น์ง ์ ๊ณผ์ ์ ์๋ํํฉ๋๋ค.
โจ ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
-
์์ฆ & ๊ณต์ฐ ๊ด๋ฆฌ
- ์์ฆ๋ณ ๋ค์ค ๊ณต์ฐ ๋ฑ๋ก (๋ฎค์ง์ปฌ, ์ฐ๊ทน, ์ฝ์ํธ ๋ฑ)
- ๊ณต์ฐ๋ณ ํ์ฐจ/๋ ์ง/์๊ฐ ์ค์ผ์ค ๊ด๋ฆฌ
- ๊ณต์ฐ์ฅ ์ ๋ณด & ์ข์ ๋ฐฐ์น๋ ์ค์
- ํฐ์ผ ๊ฐ๊ฒฉ ๋ฑ๊ธ๋ณ ์ค์ (VIP/R์/S์ ๋ฑ)
-
ํฐ์ผ ํ๋งค & ์๋งค
- ์ค์๊ฐ ์ข์ ์ ํ ์ธํฐํ์ด์ค (SVG ์ข์ ๋งต)
- ๋ค์ค ์ข์ ์ ํ & ์ฅ๋ฐ๊ตฌ๋
- ๊ฒฐ์ ์ฐ๋ (PortOne/Toss Payments)
- ์๋งค ํ์ธ & QR ์ฝ๋ ํฐ์ผ ๋ฐ๊ธ
- ์๋งค ์ทจ์/ํ๋ถ ์ฒ๋ฆฌ
-
์ถ์ฐ์ง & ์คํํ ๊ณ์ฝ ๊ด๋ฆฌ
- ์ถ์ฐ์ง/์คํํ ํ๋กํ ๋ฑ๋ก
- ๊ณต์ฐ๋ณ ์บ์คํ ๋ฐฐ์
- ๊ณ์ฝ์ ํ ํ๋ฆฟ & ์ ์์๋ช (PDF ์์ฑ)
- ์ถ์ฐ๋ฃ ์ ์ฐ ์ค์ผ์ค ๊ด๋ฆฌ
-
์์ต ์ ์ฐ & ๋ฆฌํฌํธ
- ๊ณต์ฐ๋ณ/์์ฆ๋ณ ๋งค์ถ ๋์๋ณด๋
- ํฐ์ผ ํ๋งค ํต๊ณ (์ผ๋ณ/์ฃผ๋ณ/์๋ณ)
- ์ถ์ฐ์ง ์ ์ฐ ๋ด์ญ ์๋ ๊ณ์ฐ
- ์์ต ๋ถ๋ฐฐ ๋น์จ ์ค์ (ํฌ์์/์ ์์ฌ/์ถ์ฐ์ง)
- Excel/PDF ๋ฆฌํฌํธ ๋ค์ด๋ก๋
-
๊ด๊ฐ ๊ด๋ฆฌ & ๋ง์ผํ
- ๊ด๊ฐ DB & ๊ตฌ๋งค ์ด๋ ฅ ์ถ์
- ์ด๋ฉ์ผ ๋ด์ค๋ ํฐ ๋ฐ์ก (๊ณต์ฐ ์๋ฆผ/ํ ์ธ ์ฟ ํฐ)
- VIP ๋ฉค๋ฒ์ญ ๋ฑ๊ธ ์์คํ
- ๋ฆฌ๋ทฐ & ๋ณ์ ์์ง
-
์๋ฆผ & ์๋ํ
- ๊ณต์ฐ 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์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
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 ์ค์ ์๋ฃ.
# ์์ฆ & ๊ณต์ฐ ๊ด๋ฆฌ ๊ธฐ๋ฅ ๊ตฌํ
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๋ก ๋์ ์ธํฐ๋์ ์ด ๊ตฌํ๋ฉ๋๋ค.
# ์ค์๊ฐ ์ข์ ์๋งค ์์คํ
๊ตฌํ
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 ์ฝ๋ ํฐ์ผ์ด ๋ฐ๊ธ๋ฉ๋๋ค. ๊ฒฐ์ ์ฐ๋ ์ค๋น ์๋ฃ.
# ์ถ์ฐ์ง & ๊ณ์ฝ ๊ด๋ฆฌ ๊ธฐ๋ฅ ๊ตฌํ
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 ๊ณ์ฝ์๊ฐ ์๋ ์์ฑ๋๋ฉฐ, ์ง๊ธ ์ผ์ ๊ด๋ฆฌ ๊ธฐ๋ฅ์ด ์๋ํฉ๋๋ค.
# ์์ต ์ ์ฐ & ๋์๋ณด๋ ๊ตฌํ
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๋ก ๋ค์ด๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.
# ๊ด๊ฐ ๊ด๋ฆฌ & ๋ฆฌ๋ทฐ ์์คํ
๊ตฌํ
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: ๋ณด๋ผ)
๋ชจ๋ ๊ธฐ๋ฅ์ด ์๋ํ๋ฉด ๋ค์ ๋จ๊ณ๋ก ์งํํ๊ฒ ์ต๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ด๊ฐ ๋ชฉ๋ก ๋ฐ ์์ธ ์ ๋ณด๋ฅผ ํ์ธํ ์ ์๊ณ , ์๋งค ํ ๋ฆฌ๋ทฐ๋ฅผ ์์ฑํ ์ ์์ผ๋ฉฐ, ๋ฉค๋ฒ์ญ ๋ฑ๊ธ์ด ์๋์ผ๋ก ์ ๊ทธ๋ ์ด๋๋ฉ๋๋ค.
# ์๋ฆผ & ์๋ํ ์์คํ
๊ตฌํ
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` ์ ์ํ์ฌ ํ ์์
๋ชจ๋ํฐ๋ง.
๋ชจ๋ ๊ธฐ๋ฅ์ด ์๋ํ๋ฉด ๋ค์ ๋จ๊ณ๋ก ์งํํ๊ฒ ์ต๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ด๋ฉ์ผ ์๋ฆผ์ด ์๋์ผ๋ก ๋ฐ์ก๋๊ณ , ์๋ฆผ ์ผํฐ์์ ํ์ธํ ์ ์์ผ๋ฉฐ, ์ค์ผ์ค๋ฌ๊ฐ ์ ์ ์๋ํฉ๋๋ค. ํ ์์คํ ์ด ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์์ ์ ์ฒ๋ฆฌํฉ๋๋ค.
# ๊ถํ ๊ด๋ฆฌ & ํ
์คํธ & ๋ฐฐํฌ ์ค๋น
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)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!