๐ญ ๊ทน๋จ ๊ณต์ฐ ์์ฆ ํฐ์ผ ํจํค์ง ํ๋งค ํ๋ซํผ - ์ฐ๊ทน/๋ฎค์ง์ปฌ ์๋ฆฌ์ฆ ๊ตฌ๋ ์์คํ
theater-season-ticket-platform
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
๊ทน๋จ์ด๋ ๊ณต์ฐ์ฅ์์ ์์ฆ ๋จ์๋ก ์ฌ๋ฌ ๊ณต์ฐ์ ๋ฌถ์ด ํจํค์ง ํฐ์ผ์ ํ๋งคํ๋ ํ๋ซํผ์ ๋๋ค. ๊ด๊ฐ์ ์์ฆ ํจ์ค๋ฅผ ๊ตฌ๋งคํ์ฌ ์๋ฆฌ์ฆ ๊ณต์ฐ์ ํ ์ธ๋ ๊ฐ๊ฒฉ์ ๊ด๋ํ ์ ์์ผ๋ฉฐ, ๊ทน๋จ์ ์์ ์ ์ธ ์์ต๊ณผ ์ถฉ์ฑ ๊ณ ๊ฐ์ ํ๋ณดํ ์ ์์ต๋๋ค.
ํต์ฌ ๊ธฐ๋ฅ
- ์์ฆ ํจํค์ง ๊ด๋ฆฌ: ๊ทน๋จ์ด ์ฌ๋ฌ ๊ณต์ฐ์ ๋ฌถ์ด ์์ฆ ํจํค์ง ์์ฑ (์: 2025 ๋ด ์์ฆ - 4๊ฐ ๊ณต์ฐ)
- ๋ค๋จ๊ณ ๊ฐ๊ฒฉ ์ ์ฑ : ์ผ๋ฆฌ๋ฒ๋/์ ๊ท/ํ์ฅ ๊ฐ๊ฒฉ, VIP/์ผ๋ฐ์ ๋ฑ๊ธ๋ณ ์ฐจ๋ฑ ๊ฐ๊ฒฉ
- ์ข์ ์์ฝ ์์คํ : ๊ณต์ฐ๋ณ๋ก ๋ ์ง/์๊ฐ/์ข์ ์ ํ ํ ์์ฝ (์์ฆ ํจ์ค ์์ง์ ์ฐ์ ์์ฝ)
- ํ์ ๋ฑ๊ธ ์์คํ : ๊ตฌ๋งค ์ด๋ ฅ์ ๋ฐ๋ผ Bronze/Silver/Gold ๋ฑ๊ธ, ๋ฑ๊ธ๋ณ ํ ์ธ ํํ
- ๊ณต์ฐ ์๋ฆผ ์๋ํ: ์์ฝํ ๊ณต์ฐ D-7, D-1 ์๋ ์ด๋ฉ์ผ/SMS ์๋ฆผ (Laravel Queue)
- ๋ฆฌ๋ทฐ & ํ์ : ๊ด๋ ํ ๊ณต์ฐ๋ณ ๋ฆฌ๋ทฐ ์์ฑ, ๋ณ์ ์ง๊ณ
- ๊ด๋ฆฌ์ ๋์๋ณด๋: ํฐ์ผ ํ๋งค ํํฉ, ์ข์ ์ ์ ์จ, ๋งค์ถ ํต๊ณ ์ฐจํธ (Chart.js)
- ๊ฒฐ์ ์ฐ๋: Stripe/Toss Payments API ์ฐ๋ํ์ฌ ์ค์ ๊ฒฐ์ ์ฒ๋ฆฌ
- PDF ํฐ์ผ ๋ฐ๊ธ: ์์ฝ ์๋ฃ ์ QR ์ฝ๋ ํฌํจ PDF ํฐ์ผ ์๋ ์์ฑ (DomPDF)
- Redis ์บ์ฑ: ์ข์ ์ฌ๊ณ , ์ธ๊ธฐ ๊ณต์ฐ ๋ชฉ๋ก ๋ฑ ์์ฃผ ์กฐํ๋๋ ๋ฐ์ดํฐ ์บ์ฑ
ํ์ ๊ตฌํ ์ฌํญ
- Laravel 13.x + Blade + Tailwind CSS + Alpine.js ์คํ
- SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค (๋ง์ด๊ทธ๋ ์ด์ ํฌํจ)
- Eloquent ๊ด๊ณ: Season hasMany Shows, Show hasMany Seats, User hasMany Bookings (๋ค๋๋ค ํฌํจ)
- Laravel Policy๋ก ๊ถํ ๊ด๋ฆฌ (๊ทน๋จ ๊ด๋ฆฌ์/์ผ๋ฐ ํ์ ๊ตฌ๋ถ)
- Queue๋ฅผ ์ด์ฉํ ์ด๋ฉ์ผ ์๋ฆผ ๋น๋๊ธฐ ์ฒ๋ฆฌ
- ์ข์ ์ ํ UI: ๊ทน์ฅ ์ข์ ๋ฐฐ์น๋ ์๊ฐํ (์์ฝ/์ ํ/๋งค์ง ์ํ ๊ตฌ๋ถ)
- ๊ฒฐ์ ํ๋ก์ธ์ค: ์ฅ๋ฐ๊ตฌ๋ โ ๊ฒฐ์ ์ ๋ณด ์ ๋ ฅ โ ๊ฒฐ์ ์๋ฃ โ ํฐ์ผ ๋ฐ๊ธ
- ๊ด๋ฆฌ์ ํ์ด์ง: ์์ฆ/๊ณต์ฐ/์ข์ CRUD, ์์ฝ ๊ด๋ฆฌ, ํต๊ณ ๋์๋ณด๋
- ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ ํฐ์ผ ํ์ธ ์ต์ ํ)
- PHPUnit ํ ์คํธ: ์์ฝ ๋ก์ง, ๊ฒฐ์ ๊ฒ์ฆ, ์ข์ ์ค๋ณต ๋ฐฉ์ง ํ ์คํธ
๋ณด๋์ค ๊ธฐ๋ฅ
- WebSocket(Laravel Reverb)์ผ๋ก ์ค์๊ฐ ์ข์ ์ ์ ์ํ ๋๊ธฐํ
- ๋ค๊ตญ์ด ์ง์ (ํ๊ตญ์ด/์์ด)
- ์์ ๋ก๊ทธ์ธ (Google/Kakao OAuth)
- ์ถ์ฒ ์์คํ : ๊ณผ๊ฑฐ ๊ด๋ ์ด๋ ฅ ๊ธฐ๋ฐ AI ์ถ์ฒ (OpenAI API)
- ๊ณต์ฐ์ฅ 3D ์ข์ ๋ทฐ (Three.js)
- ์น๊ตฌ ์ด๋ ํ๋ก๋ชจ์ (์ถ์ฒ์ธ ํ ์ธ ์ฝ๋)
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x, PHP 8.3+
- ํ๋ก ํธ์๋: Blade Templates, Tailwind CSS 4.x, Alpine.js 3.x
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๊ฐ๋ฐ), MySQL/PostgreSQL (ํ๋ก๋์ ๊ถ์ฅ)
- ์บ์ฑ: Redis
- ํ: Laravel Queue (database driver)
- ๊ฒฐ์ : Stripe API / Toss Payments API
- PDF: DomPDF / Snappy (wkhtmltopdf)
- ์ฐจํธ: Chart.js
- ํ ์คํธ: PHPUnit, Laravel Dusk (E2E)
ํ์ต ๋ชฉํ
- ๋ณต์กํ ์์ฝ ์์คํ ์ ๋น์ฆ๋์ค ๋ก์ง ์ค๊ณ
- ์ข์ ์ฌ๊ณ ๊ด๋ฆฌ ๋ฐ ๋์์ฑ ์ ์ด (ํธ๋์ญ์ , ๋ฝ)
- ๊ฒฐ์ API ์ฐ๋ ๋ฐ ๋ณด์ ์ฒ๋ฆฌ
- Queue๋ฅผ ํ์ฉํ ๋น๋๊ธฐ ์์ ์ฒ๋ฆฌ
- Policy/Gate๋ฅผ ํตํ ์ธ๋ฐํ ๊ถํ ๊ด๋ฆฌ
- Redis ์บ์ฑ ์ ๋ต ์๋ฆฝ
- ๋์ฉ๋ ์์ฝ ๋ฐ์ดํฐ ์ต์ ํ (์ธ๋ฑ์ค, N+1 ์ฟผ๋ฆฌ ํด๊ฒฐ)
- ์ค๋ฌด ์์ค์ ํ ์คํธ ์ฝ๋ ์์ฑ
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x ํ์ (๊ตฌ๋ฒ์ ์ฌ์ฉ ๊ธ์ง)
- โข Blade ํ ํ๋ฆฟ ์์ง ์ฌ์ฉ (React/Vue ๋ฑ SPA ํ๋ ์์ํฌ ๊ธ์ง)
- โข Tailwind CSS 4.x๋ก ์คํ์ผ๋ง
- โข Alpine.js๋ก ๊ฐ๋จํ ์ธํฐ๋์ ์ฒ๋ฆฌ (์ข์ ์ ํ, ๋ชจ๋ฌ ๋ฑ)
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๋ก์ปฌ ๊ฐ๋ฐ ํ๊ฒฝ)
- โข Redis ์ค์น ๋ฐ ์บ์ฑ ์ ์ฉ ํ์
- โข Stripe ๋๋ Toss Payments ํ ์คํธ API ํค ์ฌ์ฉ
- โข Queue ์์ ์ database ๋๋ผ์ด๋ฒ๋ก ์์ (ํ๋ก๋์ ์์๋ Redis ๊ถ์ฅ)
- โข ์ข์ ์ค๋ณต ์์ฝ ๋ฐฉ์ง๋ฅผ ์ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํธ๋์ญ์ ๋ฐ ์ ๋ํฌ ์ ์ฝ ์กฐ๊ฑด ์ ์ฉ
- โข ๊ด๋ฆฌ์์ ์ผ๋ฐ ์ฌ์ฉ์ ์ญํ ๊ตฌ๋ถ (Laravel Policy ํ์ฉ)
- โข ๋ชจ๋ ํผ์ CSRF ํ ํฐ ์ ์ฉ
- โข ์ด๋ฏธ์ง ์ ๋ก๋ ์ ํ์ผ ๊ฒ์ฆ (ํ์ฅ์, ํฌ๊ธฐ ์ ํ)
- โข ๋ฏผ๊ฐํ ์ ๋ณด๋ .env ํ์ผ๋ก ๊ด๋ฆฌ
- โข PHPUnit ํ ์คํธ ์ปค๋ฒ๋ฆฌ์ง 50% ์ด์
- โข ๋ฐ์ํ ๋์์ธ ํ์ (๋ชจ๋ฐ์ผ ์ฐ์ ์ ๊ทผ)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir theater-season-ticket-platform && cd theater-season-ticket-platform ์ด ํด๋์์ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. Laravel 13.x ํ๋ก์ ํธ๋ฅผ ์์ฑํ๊ณ ๊ธฐ๋ณธ ์ค์ ์ ์๋ฃํด์ฃผ์ธ์. ```bash composer create-project laravel/laravel . "^13.0" ``` ๋ค์ ์ค์ ์ ์งํํ์ธ์: 1. .env ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ์ค์ - DB_CONNECTION=sqlite - DB_DATABASE ๊ฒฝ๋ก๋ฅผ database/database.sqlite๋ก ์ง์ - database/database.sqlite ํ์ผ ์์ฑ 2. Tailwind CSS 4.x ์ค์น ๋ฐ ์ค์ - npm install -D tailwindcss@latest postcss autoprefixer - npx tailwindcss init -p - tailwind.config.js์์ content ๊ฒฝ๋ก ์ค์ (resources/**/*.blade.php, resources/**/*.js) - resources/css/app.css์ @tailwind ์ง์๋ฌธ ์ถ๊ฐ 3. Alpine.js 3.x ์ค์น - npm install alpinejs@latest - resources/js/app.js์์ Alpine ์ด๊ธฐํ 4. Redis ์ค์ - .env์์ CACHE_DRIVER=redis, QUEUE_CONNECTION=database ์ค์ - composer require predis/predis 5. ๊ธฐ๋ณธ ์ธ์ฆ ์ค์บํด๋ฉ ์ค์น - composer require laravel/breeze --dev - php artisan breeze:install blade - npm install && npm run build 6. ํ์ํ ํจํค์ง ์ถ๊ฐ ์ค์น - composer require barryvdh/laravel-dompdf (PDF ์์ฑ) - composer require stripe/stripe-php (๊ฒฐ์ ์ฐ๋) ๋ง์ด๊ทธ๋ ์ด์ ์คํ: ```bash php artisan migrate ``` ๊ฐ๋ฐ ์๋ฒ ์คํ: ```bash php artisan serve ``` ํ๋ก์ ํธ ๊ธฐ๋ณธ ๊ตฌ์กฐ๊ฐ ์์ฑ๋๋ฉด http://localhost:8000 ์ ์ ์ํ์ฌ Laravel ์ฐ์ปด ํ์ด์ง๋ฅผ ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ค์ ๋๋ฉฐ, Tailwind CSS์ Alpine.js๊ฐ ํตํฉ๋ฉ๋๋ค. Breeze ์ธ์ฆ ์์คํ ์ด ์ค์น๋์ด ๋ก๊ทธ์ธ/ํ์๊ฐ์ ๊ธฐ๋ฅ์ด ์ค๋น๋ฉ๋๋ค. Redis์ ๊ฒฐ์ /PDF ํจํค์ง๊ฐ ์ค์น๋์ด ์ถํ ๊ธฐ๋ฅ ๊ตฌํ ์ค๋น๊ฐ ์๋ฃ๋ฉ๋๋ค.
๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๋ฅผ ์ค๊ณํ๊ณ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์์ฑํ์ธ์. ๋ค์ ํ ์ด๋ธ๋ค์ ์์ฑํด์ผ ํฉ๋๋ค: 1. **users ํ ์ด๋ธ** (Breeze ๊ธฐ๋ณธ ํ ์ด๋ธ ํ์ฅ) - role ์ปฌ๋ผ ์ถ๊ฐ (enum: 'admin', 'user', ๊ธฐ๋ณธ๊ฐ 'user') - membership_tier ์ปฌ๋ผ ์ถ๊ฐ (enum: 'bronze', 'silver', 'gold', nullable) - total_spent ์ปฌ๋ผ ์ถ๊ฐ (decimal, ๊ธฐ๋ณธ๊ฐ 0) - ๋์ ๊ตฌ๋งค ๊ธ์ก 2. **seasons ํ ์ด๋ธ** - id (primary key) - title (string) - ์์ฆ ์ ๋ชฉ (์: "2025 ๋ด ์์ฆ") - description (text) - ์์ฆ ์๊ฐ - start_date, end_date (date) - ์์ฆ ๊ธฐ๊ฐ - image (string, nullable) - ์์ฆ ํฌ์คํฐ ์ด๋ฏธ์ง ๊ฒฝ๋ก - is_active (boolean, ๊ธฐ๋ณธ๊ฐ true) - ํ๋งค ์ค ์ฌ๋ถ - created_at, updated_at 3. **shows ํ ์ด๋ธ** - id (primary key) - season_id (foreign key โ seasons.id, onDelete cascade) - title (string) - ๊ณต์ฐ ์ ๋ชฉ - description (text) - genre (string) - ์ฅ๋ฅด (์ฐ๊ทน/๋ฎค์ง์ปฌ/์คํ๋ผ ๋ฑ) - duration_minutes (integer) - ๊ณต์ฐ ์๊ฐ(๋ถ) - image (string, nullable) - created_at, updated_at 4. **show_schedules ํ ์ด๋ธ** - id (primary key) - show_id (foreign key โ shows.id, onDelete cascade) - show_date (date) - ๊ณต์ฐ ๋ ์ง - show_time (time) - ๊ณต์ฐ ์์ ์๊ฐ - venue (string) - ๊ณต์ฐ์ฅ ์ด๋ฆ - total_seats (integer) - ์ด ์ข์ ์ - available_seats (integer) - ๋จ์ ์ข์ ์ - created_at, updated_at 5. **seats ํ ์ด๋ธ** - id (primary key) - show_schedule_id (foreign key โ show_schedules.id, onDelete cascade) - seat_number (string) - ์ข์ ๋ฒํธ (์: "A-1", "B-5") - seat_type (enum: 'vip', 'regular') - ์ข์ ๋ฑ๊ธ - price (decimal) - ์ข์ ๊ฐ๊ฒฉ - status (enum: 'available', 'reserved', 'sold') - ์ข์ ์ํ - unique ์ ์ฝ ์กฐ๊ฑด: (show_schedule_id, seat_number) - created_at, updated_at 6. **season_packages ํ ์ด๋ธ** - id (primary key) - season_id (foreign key โ seasons.id, onDelete cascade) - name (string) - ํจํค์ง ์ด๋ฆ (์: "์ผ๋ฆฌ๋ฒ๋ 4ํ๊ถ") - description (text) - show_count (integer) - ํฌํจ๋ ๊ณต์ฐ ์ - price (decimal) - ํจํค์ง ๊ฐ๊ฒฉ - discount_rate (decimal) - ํ ์ธ์จ (%) - is_available (boolean, ๊ธฐ๋ณธ๊ฐ true) - created_at, updated_at 7. **bookings ํ ์ด๋ธ** - id (primary key) - user_id (foreign key โ users.id, onDelete cascade) - season_package_id (foreign key โ season_packages.id, nullable) - ์์ฆ ํจ์ค ๊ตฌ๋งค ์ - booking_number (string, unique) - ์์ฝ ๋ฒํธ (์๋ ์์ฑ) - total_amount (decimal) - ์ด ๊ฒฐ์ ๊ธ์ก - payment_status (enum: 'pending', 'completed', 'failed', 'refunded') - payment_method (string, nullable) - ๊ฒฐ์ ์๋จ - stripe_payment_id (string, nullable) - Stripe ๊ฒฐ์ ID - created_at, updated_at 8. **booking_seats ํ ์ด๋ธ** (์ค๊ฐ ํ ์ด๋ธ) - id (primary key) - booking_id (foreign key โ bookings.id, onDelete cascade) - seat_id (foreign key โ seats.id, onDelete cascade) - attended (boolean, ๊ธฐ๋ณธ๊ฐ false) - ์ค์ ๊ด๋ ์ฌ๋ถ - unique ์ ์ฝ ์กฐ๊ฑด: (booking_id, seat_id) - created_at, updated_at 9. **reviews ํ ์ด๋ธ** - id (primary key) - user_id (foreign key โ users.id, onDelete cascade) - show_id (foreign key โ shows.id, onDelete cascade) - rating (integer) - ๋ณ์ (1~5) - comment (text, nullable) - ๋ฆฌ๋ทฐ ๋ด์ฉ - created_at, updated_at ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ ์์ฑ ๋ช ๋ น์ด: ```bash php artisan make:migration add_role_to_users_table --table=users php artisan make:migration create_seasons_table php artisan make:migration create_shows_table php artisan make:migration create_show_schedules_table php artisan make:migration create_seats_table php artisan make:migration create_season_packages_table php artisan make:migration create_bookings_table php artisan make:migration create_booking_seats_table php artisan make:migration create_reviews_table ``` ๊ฐ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์ ์คํค๋ง๋๋ก ์ปฌ๋ผ๊ณผ ์ ์ฝ ์กฐ๊ฑด์ ์ ์ํ๊ณ , ๋ง์ด๊ทธ๋ ์ด์ ์ ์คํํ์ธ์: ```bash php artisan migrate ``` ์ธ๋ฑ์ค ์ถ๊ฐ: - seats ํ ์ด๋ธ: (show_schedule_id, status) ๋ณตํฉ ์ธ๋ฑ์ค - bookings ํ ์ด๋ธ: (user_id, payment_status) ๋ณตํฉ ์ธ๋ฑ์ค - reviews ํ ์ด๋ธ: (show_id, rating) ๋ณตํฉ ์ธ๋ฑ์ค
๊ธฐ๋ ๊ฒฐ๊ณผ: 9๊ฐ์ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ด ์์ฑ๋๊ณ ์คํ๋์ด, ์์ฆ/๊ณต์ฐ/์ข์/์์ฝ/๋ฆฌ๋ทฐ ๋ฑ ๋ชจ๋ ํ ์ด๋ธ์ด SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์์ฑ๋ฉ๋๋ค. ์ธ๋ ํค ์ ์ฝ ์กฐ๊ฑด๊ณผ ์ ๋ํฌ ์ ์ฝ ์กฐ๊ฑด์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋์ด ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ์ด ๋ณด์ฅ๋ฉ๋๋ค.
Eloquent ๋ชจ๋ธ์ ์์ฑํ๊ณ ๊ด๊ณ๋ฅผ ์ ์ํ์ธ์.
๋ค์ ๋ช
๋ น์ด๋ก ๋ชจ๋ธ ํ์ผ์ ์์ฑํ์ธ์:
```bash
php artisan make:model Season
php artisan make:model Show
php artisan make:model ShowSchedule
php artisan make:model Seat
php artisan make:model SeasonPackage
php artisan make:model Booking
php artisan make:model BookingSeat
php artisan make:model Review
```
๊ฐ ๋ชจ๋ธ์ ๋ค์ ๊ด๊ณ๋ฅผ ์ ์ํ์ธ์:
**User ๋ชจ๋ธ** (app/Models/User.php):
- `bookings()`: hasMany(Booking::class)
- `reviews()`: hasMany(Review::class)
- `membershipTierBadge()`: Accessor - ๋ฑ๊ธ์ ๋ฐ๋ผ ๋ฐฐ์ง ์์ด์ฝ ๋ฐํ
- `upgradeMembershipTier()`: ๋ฉ์๋ - total_spent ๊ธฐ์ค์ผ๋ก ๋ฑ๊ธ ์๋ ์
๊ทธ๋ ์ด๋ (Bronze: 0์, Silver: 50๋ง์, Gold: 100๋ง์)
**Season ๋ชจ๋ธ**:
- `shows()`: hasMany(Show::class)
- `packages()`: hasMany(SeasonPackage::class)
- `scopeActive()`: ์ฟผ๋ฆฌ ์ค์ฝํ - is_active๊ฐ true์ธ ์์ฆ๋ง ์กฐํ
**Show ๋ชจ๋ธ**:
- `season()`: belongsTo(Season::class)
- `schedules()`: hasMany(ShowSchedule::class)
- `reviews()`: hasMany(Review::class)
- `averageRating()`: Accessor - ๋ฆฌ๋ทฐ ๋ณ์ ํ๊ท ๊ณ์ฐ
**ShowSchedule ๋ชจ๋ธ**:
- `show()`: belongsTo(Show::class)
- `seats()`: hasMany(Seat::class)
- `availableSeatsCount()`: Accessor - available_seats ๊ฐ ๋ฐํ
- `isSoldOut()`: Accessor - available_seats๊ฐ 0์ด๋ฉด true
**Seat ๋ชจ๋ธ**:
- `schedule()`: belongsTo(ShowSchedule::class, 'show_schedule_id')
- `bookingSeats()`: hasMany(BookingSeat::class)
- `scopeAvailable()`: ์ฟผ๋ฆฌ ์ค์ฝํ - status๊ฐ 'available'์ธ ์ข์๋ง ์กฐํ
- `reserve()`: ๋ฉ์๋ - ์ข์ ์ํ๋ฅผ 'reserved'๋ก ๋ณ๊ฒฝ
- `markAsSold()`: ๋ฉ์๋ - ์ข์ ์ํ๋ฅผ 'sold'๋ก ๋ณ๊ฒฝ
**SeasonPackage ๋ชจ๋ธ**:
- `season()`: belongsTo(Season::class)
- `bookings()`: hasMany(Booking::class)
- `discountedPrice()`: Accessor - ํ ์ธ ์ ์ฉ ํ ๊ฐ๊ฒฉ ๊ณ์ฐ
**Booking ๋ชจ๋ธ**:
- `user()`: belongsTo(User::class)
- `seasonPackage()`: belongsTo(SeasonPackage::class)
- `bookingSeats()`: hasMany(BookingSeat::class)
- `seats()`: belongsToMany(Seat::class, 'booking_seats')->withPivot('attended')->withTimestamps()
- `generateBookingNumber()`: Static ๋ฉ์๋ - ์์ฝ ๋ฒํธ ์๋ ์์ฑ (์: "BK-20250115-0001")
- `isPaid()`: Accessor - payment_status๊ฐ 'completed'์ธ์ง ํ์ธ
**BookingSeat ๋ชจ๋ธ**:
- `booking()`: belongsTo(Booking::class)
- `seat()`: belongsTo(Seat::class)
**Review ๋ชจ๋ธ**:
- `user()`: belongsTo(User::class)
- `show()`: belongsTo(Show::class)
- `scopeHighRated()`: ์ฟผ๋ฆฌ ์ค์ฝํ - rating์ด 4 ์ด์์ธ ๋ฆฌ๋ทฐ๋ง ์กฐํ
๊ฐ ๋ชจ๋ธ์ $fillable ์์ฑ์ ์ ์ํ์ฌ ๋๋ ํ ๋น ๊ฐ๋ฅํ ํ๋๋ฅผ ๋ช
์ํ์ธ์.
๋ํ, Booking ๋ชจ๋ธ์ ๋ค์ ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ฅผ ์ถ๊ฐํ์ธ์:
- `creating` ์ด๋ฒคํธ: booking_number ์๋ ์์ฑ
- `updated` ์ด๋ฒคํธ: payment_status๊ฐ 'completed'๋ก ๋ณ๊ฒฝ๋๋ฉด user์ total_spent ์
๋ฐ์ดํธ ๋ฐ ๋ฑ๊ธ ๊ฐฑ์
Cast ์ค์ :
- ๋ ์ง ํ๋๋ ๋ชจ๋ Carbon ์ธ์คํด์ค๋ก ์บ์คํ
- boolean ํ๋๋ boolean ํ์
์ผ๋ก ์บ์คํ
- decimal ํ๋๋ float ๋๋ decimal:2๋ก ์บ์คํ
๊ธฐ๋ ๊ฒฐ๊ณผ: 8๊ฐ์ Eloquent ๋ชจ๋ธ์ด ์์ฑ๋๊ณ , ๋ชจ๋ ๊ด๊ณ๊ฐ ์ ์๋ฉ๋๋ค. Accessor, Mutator, ์ฟผ๋ฆฌ ์ค์ฝํ๊ฐ ์ถ๊ฐ๋์ด ๋น์ฆ๋์ค ๋ก์ง์ด ๋ชจ๋ธ ๋ ๋ฒจ์์ ์บก์ํ๋ฉ๋๋ค. ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ฅผ ํตํด ์์ฝ ๋ฒํธ ์๋ ์์ฑ ๋ฐ ํ์ ๋ฑ๊ธ ์๋ ์ ๊ทธ๋ ์ด๋ ๋ก์ง์ด ๊ตฌํ๋ฉ๋๋ค.
์๋(Seeder)๋ฅผ ์์ฑํ์ฌ ํ
์คํธ ๋ฐ์ดํฐ๋ฅผ ์ฝ์
ํ์ธ์.
```bash
php artisan make:seeder DatabaseSeeder
```
DatabaseSeeder ํ์ผ(database/seeders/DatabaseSeeder.php)์์ ๋ค์ ๋ฐ์ดํฐ๋ฅผ ์์ฑํ์ธ์:
1. **๊ด๋ฆฌ์ ๊ณ์ 1๊ฐ**
- name: "๊ทน๋จ ๊ด๋ฆฌ์"
- email: "admin@theater.com"
- password: bcrypt('password')
- role: 'admin'
2. **์ผ๋ฐ ์ฌ์ฉ์ 10๋ช
**
- ํฉํ ๋ฆฌ ์ฌ์ฉ ๋๋ ์๋ ์์ฑ
- role: 'user'
- membership_tier๋ ๋๋ค (bronze, silver, gold)
3. **์์ฆ 2๊ฐ**
- "2025 ๋ด ์์ฆ" (3์~5์)
- "2025 ์ฌ๋ฆ ์์ฆ" (6์~8์)
- ๊ฐ ์์ฆ๋ง๋ค ์ด๋ฏธ์ง ๊ฒฝ๋ก ์ค์ (public/images/seasons/spring.jpg ๋ฑ)
4. **๊ฐ ์์ฆ๋ง๋ค ๊ณต์ฐ 4๊ฐ**
- ์ด 8๊ฐ ๊ณต์ฐ ์์ฑ
- ์ ๋ชฉ ์์: "ํ๋ฆฟ", "๋ก๋ฏธ์ค์ ์ค๋ฆฌ์ฃ", "์คํ๋ผ์ ์ ๋ น", "์บฃ์ธ " ๋ฑ
- ์ฅ๋ฅด: ์ฐ๊ทน, ๋ฎค์ง์ปฌ ์์ด์
- duration_minutes: 120~180 ์ฌ์ด ๋๋ค
5. **๊ฐ ๊ณต์ฐ๋ง๋ค ์ค์ผ์ค 3๊ฐ**
- ์ด 24๊ฐ ์ค์ผ์ค
- show_date: ์์ฆ ๊ธฐ๊ฐ ๋ด ๋๋ค ๋ ์ง
- show_time: 14:00, 19:00 ์ค ๋๋ค
- venue: "๋๊ทน์ฅ", "์๊ทน์ฅ" ์ค ๋๋ค
- total_seats: 100
- available_seats: 100 (์ด๊ธฐ๊ฐ)
6. **๊ฐ ์ค์ผ์ค๋ง๋ค ์ข์ 100๊ฐ**
- ์ด 2400๊ฐ ์ข์
- seat_number: "A-1"๋ถํฐ "J-10"๊น์ง (10ํ x 10์ด)
- seat_type: ์ 3ํ์ 'vip', ๋๋จธ์ง๋ 'regular'
- price: VIP 80,000์, ์ผ๋ฐ์ 50,000์
- status: 'available'
7. **๊ฐ ์์ฆ๋ง๋ค ํจํค์ง 3๊ฐ**
- ์ด 6๊ฐ ํจํค์ง
- ์์:
- "์ผ๋ฆฌ๋ฒ๋ 4ํ๊ถ" (show_count: 4, discount_rate: 20%)
- "์ ๊ท 4ํ๊ถ" (show_count: 4, discount_rate: 10%)
- "VIP 4ํ๊ถ" (show_count: 4, discount_rate: 15%, VIP ์ข์ ํฌํจ)
8. **์์ฝ ๋ฐ์ดํฐ 20๊ฐ**
- ์ผ๋ฐ ์ฌ์ฉ์๋ค์ด ๋๋คํ๊ฒ ์์ฝ
- ์ผ๋ถ๋ ์์ฆ ํจํค์ง ๊ตฌ๋งค, ์ผ๋ถ๋ ๊ฐ๋ณ ์ข์ ๊ตฌ๋งค
- payment_status: 'completed' ๋๋ 'pending'
- booking_seats ํ
์ด๋ธ์๋ ์ฐ๊ฒฐ
9. **๋ฆฌ๋ทฐ ๋ฐ์ดํฐ 30๊ฐ**
- ์์ฝ ์๋ฃ๋ ๊ณต์ฐ์ ๋ํด ๋๋ค ๋ฆฌ๋ทฐ ์์ฑ
- rating: 1~5 ์ฌ์ด ๋๋ค
- comment: ์ํ ํ
์คํธ
์๋ ์คํ:
```bash
php artisan db:seed
```
๋๋ ๋ง์ด๊ทธ๋ ์ด์
๊ณผ ํจ๊ป ์คํ:
```bash
php artisan migrate:fresh --seed
```
์๋ ์คํ ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ํ
์คํธ ๋ฐ์ดํฐ๊ฐ ์ ๋ค์ด๊ฐ๋์ง ํ์ธํ์ธ์:
```bash
php artisan tinker
>>> \App\Models\Season::count()
>>> \App\Models\Show::count()
>>> \App\Models\Booking::count()
```
๊ธฐ๋ ๊ฒฐ๊ณผ: DatabaseSeeder๊ฐ ์คํ๋์ด ๊ด๋ฆฌ์ ๊ณ์ , ์ผ๋ฐ ์ฌ์ฉ์, ์์ฆ, ๊ณต์ฐ, ์ค์ผ์ค, ์ข์, ํจํค์ง, ์์ฝ, ๋ฆฌ๋ทฐ ๋ฑ ๋ชจ๋ ํ ์คํธ ๋ฐ์ดํฐ๊ฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ฝ์ ๋ฉ๋๋ค. ์ด๋ฅผ ํตํด ์ค์ ๋ฐ์ดํฐ๊ฐ ์๋ ์ํ์์ ๊ฐ๋ฐ์ ์งํํ ์ ์์ต๋๋ค.
๋ผ์ฐํธ์ ์ปจํธ๋กค๋ฌ๋ฅผ ์์ฑํ์ฌ ์ฌ์ฉ์ ํ์ด์ง์ ๊ธฐ๋ณธ ๊ตฌ์กฐ๋ฅผ ๋ง๋์ธ์.
routes/web.php ํ์ผ์ ๋ค์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
```php
use App\Http\Controllers\HomeController;
use App\Http\Controllers\SeasonController;
use App\Http\Controllers\ShowController;
use App\Http\Controllers\BookingController;
use App\Http\Controllers\ReviewController;
// ํํ์ด์ง
Route::get('/', [HomeController::class, 'index'])->name('home');
// ์์ฆ ๋ชฉ๋ก ๋ฐ ์์ธ
Route::get('/seasons', [SeasonController::class, 'index'])->name('seasons.index');
Route::get('/seasons/{season}', [SeasonController::class, 'show'])->name('seasons.show');
// ๊ณต์ฐ ์์ธ
Route::get('/shows/{show}', [ShowController::class, 'show'])->name('shows.show');
// ์์ฝ ํ๋ก์ธ์ค (๋ก๊ทธ์ธ ํ์)
Route::middleware(['auth'])->group(function () {
Route::get('/shows/{show}/book', [BookingController::class, 'create'])->name('bookings.create');
Route::post('/bookings', [BookingController::class, 'store'])->name('bookings.store');
Route::get('/bookings/{booking}', [BookingController::class, 'show'])->name('bookings.show');
Route::get('/bookings/{booking}/ticket', [BookingController::class, 'downloadTicket'])->name('bookings.ticket');
Route::get('/my-bookings', [BookingController::class, 'myBookings'])->name('bookings.my');
// ๋ฆฌ๋ทฐ ์์ฑ
Route::post('/shows/{show}/reviews', [ReviewController::class, 'store'])->name('reviews.store');
});
```
์ปจํธ๋กค๋ฌ ์์ฑ:
```bash
php artisan make:controller HomeController
php artisan make:controller SeasonController
php artisan make:controller ShowController
php artisan make:controller BookingController
php artisan make:controller ReviewController
```
๊ฐ ์ปจํธ๋กค๋ฌ์ ๋ค์ ๋ฉ์๋๋ฅผ ๊ตฌํํ์ธ์:
**HomeController**:
- `index()`: ํ์ฑ ์์ฆ ๋ชฉ๋ก, ์ธ๊ธฐ ๊ณต์ฐ(๋ฆฌ๋ทฐ ํ์ ๋์ ์), ์ต๊ทผ ๋ฆฌ๋ทฐ 3๊ฐ๋ฅผ ๋ทฐ์ ์ ๋ฌ
**SeasonController**:
- `index()`: ๋ชจ๋ ํ์ฑ ์์ฆ ๋ชฉ๋ก ์กฐํ (ํ์ด์ง๋ค์ด์
)
- `show(Season $season)`: ํน์ ์์ฆ ์์ธ ์ ๋ณด, ํฌํจ๋ ๊ณต์ฐ ๋ชฉ๋ก, ์์ฆ ํจํค์ง ๋ชฉ๋ก
**ShowController**:
- `show(Show $show)`: ๊ณต์ฐ ์์ธ ์ ๋ณด, ์ค์ผ์ค ๋ชฉ๋ก, ๋ฆฌ๋ทฐ ๋ชฉ๋ก(ํ์ ๋์ ์, ํ์ด์ง๋ค์ด์
), ํ๊ท ๋ณ์
**BookingController**:
- `create(Show $show)`: ๊ณต์ฐ์ ์ค์ผ์ค ์ ํ ๋ฐ ์ข์ ์ ํ ํผ ํ์
- `store(Request $request)`: ์์ฝ ์ ๋ณด ๊ฒ์ฆ ๋ฐ ์ ์ฅ (ํธ๋์ญ์
์ฌ์ฉ)
- ์ข์ ์ค๋ณต ์์ฝ ๋ฐฉ์ง (DB ๋ฝ ์ฌ์ฉ)
- ์ข์ ์ํ๋ฅผ 'reserved'๋ก ๋ณ๊ฒฝ
- Booking ๋ ์ฝ๋ ์์ฑ
- BookingSeat ๋ ์ฝ๋ ์์ฑ
- ๊ฒฐ์ ๋๊ธฐ ์ํ๋ก ์ค์
- `show(Booking $booking)`: ์์ฝ ์์ธ ์ ๋ณด ํ์ (Policy๋ก ๋ณธ์ธ ์์ฝ๋ง ์กฐํ ๊ฐ๋ฅ)
- `downloadTicket(Booking $booking)`: PDF ํฐ์ผ ์์ฑ ๋ฐ ๋ค์ด๋ก๋
- `myBookings()`: ๋ก๊ทธ์ธํ ์ฌ์ฉ์์ ๋ชจ๋ ์์ฝ ๋ชฉ๋ก
**ReviewController**:
- `store(Request $request, Show $show)`: ๋ฆฌ๋ทฐ ์์ฑ (๋ก๊ทธ์ธ + ํด๋น ๊ณต์ฐ ์์ฝ ์ด๋ ฅ ํ์ธ)
Policy ์์ฑ ๋ฐ ๋ฑ๋ก:
```bash
php artisan make:policy BookingPolicy --model=Booking
```
BookingPolicy์ `view` ๋ฉ์๋ ์ถ๊ฐ:
- ์์ฝํ ์ฌ์ฉ์ ๋ณธ์ธ ๋๋ ๊ด๋ฆฌ์๋ง ์กฐํ ๊ฐ๋ฅ
app/Providers/AuthServiceProvider.php์ Policy ๋ฑ๋ก:
```php
protected $policies = [
Booking::class => BookingPolicy::class,
];
```
์ปจํธ๋กค๋ฌ์์ Policy ์ฌ์ฉ:
```php
$this->authorize('view', $booking);
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ผ์ฐํธ๊ฐ ์ ์๋๊ณ 5๊ฐ์ ์ปจํธ๋กค๋ฌ๊ฐ ์์ฑ๋ฉ๋๋ค. ๊ฐ ์ปจํธ๋กค๋ฌ์ ๋ฉ์๋์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฟผ๋ฆฌ๋ฅผ ์ํํ์ฌ ๋ทฐ์ ๋ฐ์ดํฐ๋ฅผ ์ ๋ฌํ๋ ๊ธฐ๋ณธ ๋ก์ง์ด ๊ตฌํ๋ฉ๋๋ค. Policy๋ฅผ ํตํด ์์ฝ ์กฐํ ๊ถํ์ด ์ ์ด๋ฉ๋๋ค.
Blade ๋ทฐ ํ์ผ์ ์์ฑํ์ฌ ์ฌ์ฉ์ ํ์ด์ง์ UI๋ฅผ ๊ตฌํํ์ธ์.
Tailwind CSS์ Alpine.js๋ฅผ ํ์ฉํ์ฌ ๋ฐ์ํ ๋์์ธ์ ์ ์ฉํ์ธ์.
**๋ ์ด์์ ํ์ผ** (resources/views/layouts/app.blade.php):
- Breeze์ ๊ธฐ๋ณธ ๋ ์ด์์ ํ์ฉ ๋๋ ์ปค์คํฐ๋ง์ด์ง
- ๋ค๋น๊ฒ์ด์
๋ฐ: ํ, ์์ฆ, ๋ด ์์ฝ, ๋ก๊ทธ์ธ/๋ก๊ทธ์์
- ํธํฐ ์ถ๊ฐ
**ํํ์ด์ง** (resources/views/home.blade.php):
- ํ์ด๋ก ์น์
: ๊ทน๋จ ์๊ฐ, ์์ฆ ํ์ด๋ผ์ดํธ ์ด๋ฏธ์ง
- ํ์ฑ ์์ฆ ์นด๋ ๋ชฉ๋ก (์ต๋ 3๊ฐ ํ์)
- ์ธ๊ธฐ ๊ณต์ฐ ์น์
: ํ์ ๋์ ๊ณต์ฐ 4๊ฐ, ๋ณ์ ์์ด์ฝ ํ์
- ์ต๊ทผ ๋ฆฌ๋ทฐ ์น์
: ์ต์ ๋ฆฌ๋ทฐ 3๊ฐ, ์ฌ์ฉ์ ์ด๋ฆ + ๋ณ์ + ์ฝ๋ฉํธ
**์์ฆ ๋ชฉ๋ก ํ์ด์ง** (resources/views/seasons/index.blade.php):
- ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ์์ฆ ์นด๋ ํ์
- ๊ฐ ์นด๋: ์์ฆ ํฌ์คํฐ ์ด๋ฏธ์ง, ์ ๋ชฉ, ๊ธฐ๊ฐ, ํฌํจ๋ ๊ณต์ฐ ์, "์์ธํ ๋ณด๊ธฐ" ๋ฒํผ
- ํ์ด์ง๋ค์ด์
**์์ฆ ์์ธ ํ์ด์ง** (resources/views/seasons/show.blade.php):
- ์์ฆ ์ ๋ณด: ์ ๋ชฉ, ์ค๋ช
, ๊ธฐ๊ฐ
- ํฌํจ๋ ๊ณต์ฐ ๋ชฉ๋ก: ๊ฐ ๊ณต์ฐ ์นด๋ (์ด๋ฏธ์ง, ์ ๋ชฉ, ์ฅ๋ฅด, ํ๊ท ๋ณ์ , "์์ฝํ๊ธฐ" ๋ฒํผ)
- ์์ฆ ํจํค์ง ์น์
: ํจํค์ง ์นด๋ (์ด๋ฆ, ์ค๋ช
, ๊ฐ๊ฒฉ, ํ ์ธ์จ, "๊ตฌ๋งคํ๊ธฐ" ๋ฒํผ)
**๊ณต์ฐ ์์ธ ํ์ด์ง** (resources/views/shows/show.blade.php):
- ๊ณต์ฐ ์ ๋ณด: ์ ๋ชฉ, ์ค๋ช
, ์ฅ๋ฅด, ๋ฌ๋ํ์, ํ๊ท ๋ณ์
- ์ค์ผ์ค ์ ํ ์น์
: ๋ ์ง/์๊ฐ๋ณ ์ค์ผ์ค ๋ชฉ๋ก, ์์ฌ ์ข์ ์ ํ์
- ๋งค์ง๋ ์ค์ผ์ค์ "๋งค์ง" ๋ฐฐ์ง ํ์
- ์์ฝ ๊ฐ๋ฅํ ์ค์ผ์ค์ "์์ฝํ๊ธฐ" ๋ฒํผ
- ๋ฆฌ๋ทฐ ์น์
: ๋ฆฌ๋ทฐ ๋ชฉ๋ก (๋ณ์ , ์์ฑ์, ๋ ์ง, ์ฝ๋ฉํธ), ํ์ด์ง๋ค์ด์
- ๋ฆฌ๋ทฐ ์์ฑ ํผ (๋ก๊ทธ์ธ + ํด๋น ๊ณต์ฐ ์์ฝ ์ด๋ ฅ ์๋ ๊ฒฝ์ฐ์๋ง ํ์)
**์์ฝ ํ์ด์ง** (resources/views/bookings/create.blade.php):
- ์ ํํ ์ค์ผ์ค ์ ๋ณด ํ์
- ์ข์ ์ ํ UI:
- ๊ทน์ฅ ์ข์ ๋ฐฐ์น๋ (10ํ x 10์ด ๊ทธ๋ฆฌ๋)
- ์ข์ ์ํ๋ณ ์์ ๊ตฌ๋ถ: ์์ฝ ๊ฐ๋ฅ(์ด๋ก), ์ ํ๋จ(ํ๋), ๋งค์ง(ํ์)
- Alpine.js๋ก ์ข์ ํด๋ฆญ ์ ์ ํ/ํด์ ํ ๊ธ
- ์ ํํ ์ข์ ๋ชฉ๋ก ์ค์๊ฐ ํ์
- ์ด ๊ฒฐ์ ๊ธ์ก ๊ณ์ฐ (์ ํํ ์ข์ ๊ฐ๊ฒฉ ํฉ๊ณ)
- "๊ฒฐ์ ํ๊ธฐ" ๋ฒํผ (์ ํํ ์ข์์ด ์์ผ๋ฉด ๋นํ์ฑํ)
Alpine.js ์ข์ ์ ํ ๋ก์ง ์์:
```html
<div x-data="{
selectedSeats: [],
toggleSeat(seatId, price) {
const index = this.selectedSeats.findIndex(s => s.id === seatId);
if (index > -1) {
this.selectedSeats.splice(index, 1);
} else {
this.selectedSeats.push({ id: seatId, price: price });
}
},
totalPrice() {
return this.selectedSeats.reduce((sum, seat) => sum + seat.price, 0);
}
}">
<!-- ์ข์ ๊ทธ๋ฆฌ๋ -->
@foreach($seats as $seat)
<button
@click="toggleSeat({{ $seat->id }}, {{ $seat->price }})"
:class="selectedSeats.some(s => s.id === {{ $seat->id }}) ? 'bg-blue-500' : 'bg-green-500'"
:disabled="'{{ $seat->status }}' !== 'available'"
class="w-10 h-10 m-1 rounded"
>
{{ $seat->seat_number }}
</button>
@endforeach
<!-- ์ ํํ ์ข์ ๋ชฉ๋ก -->
<div>
<h3>์ ํํ ์ข์:</h3>
<template x-for="seat in selectedSeats" :key="seat.id">
<p x-text="'์ข์ ID: ' + seat.id + ', ๊ฐ๊ฒฉ: ' + seat.price + '์'"></p>
</template>
<p><strong>์ด ๊ธ์ก: <span x-text="totalPrice()"></span>์</strong></p>
</div>
<!-- ํผ ์ ์ถ -->
<form method="POST" action="{{ route('bookings.store') }}">
@csrf
<input type="hidden" name="schedule_id" value="{{ $schedule->id }}">
<template x-for="seat in selectedSeats" :key="seat.id">
<input type="hidden" name="seat_ids[]" :value="seat.id">
</template>
<button type="submit" :disabled="selectedSeats.length === 0">๊ฒฐ์ ํ๊ธฐ</button>
</form>
</div>
```
**์์ฝ ์๋ฃ ํ์ด์ง** (resources/views/bookings/show.blade.php):
- ์์ฝ ๋ฒํธ, ๊ณต์ฐ ์ ๋ณด, ์ ํํ ์ข์, ๊ฒฐ์ ๊ธ์ก
- "PDF ํฐ์ผ ๋ค์ด๋ก๋" ๋ฒํผ
- ๊ฒฐ์ ์ํ ํ์ (๋๊ธฐ ์ค/์๋ฃ/์คํจ)
**๋ด ์์ฝ ๋ชฉ๋ก ํ์ด์ง** (resources/views/bookings/my.blade.php):
- ๋ก๊ทธ์ธํ ์ฌ์ฉ์์ ๋ชจ๋ ์์ฝ ๋ชฉ๋ก
- ๊ฐ ์์ฝ: ๊ณต์ฐ ์ ๋ชฉ, ๋ ์ง, ์ข์, ๊ฒฐ์ ์ํ, "์์ธ ๋ณด๊ธฐ" ๋ฒํผ
- ํ์ด์ง๋ค์ด์
๋ชจ๋ ๋ทฐ์์ Tailwind CSS ์ ํธ๋ฆฌํฐ ํด๋์ค๋ฅผ ์ฌ์ฉํ์ฌ ์คํ์ผ๋งํ์ธ์.
๋ฐ์ํ ๋์์ธ: sm:, md:, lg: ๋ธ๋ ์ดํฌํฌ์ธํธ ํ์ฉ
๊ธฐ๋ ๊ฒฐ๊ณผ: Blade ๋ทฐ ํ์ผ๋ค์ด ์์ฑ๋๊ณ , ํํ์ด์ง, ์์ฆ ๋ชฉ๋ก/์์ธ, ๊ณต์ฐ ์์ธ, ์์ฝ ํ์ด์ง, ๋ด ์์ฝ ํ์ด์ง ๋ฑ ๋ชจ๋ ์ฌ์ฉ์ ํ์ด์ง์ UI๊ฐ ์์ฑ๋ฉ๋๋ค. Alpine.js๋ฅผ ํ์ฉํ ์ข์ ์ ํ ์ธํฐ๋์ ์ด ๊ตฌํ๋์ด ์ฌ์ฉ์๊ฐ ์ง๊ด์ ์ผ๋ก ์ข์์ ์ ํํ ์ ์์ต๋๋ค. Tailwind CSS๋ก ๊น๋ํ๊ณ ๋ฐ์ํ์ธ ๋์์ธ์ด ์ ์ฉ๋ฉ๋๋ค.
๊ฒฐ์ ์ฐ๋ ๋ฐ ์์ฝ ์๋ฃ ์ฒ๋ฆฌ๋ฅผ ๊ตฌํํ์ธ์.
Stripe API๋ฅผ ์ฌ์ฉํ์ฌ ์ค์ ๊ฒฐ์ ํ๋ก์ธ์ค๋ฅผ ๊ตฌํํฉ๋๋ค.
1. **Stripe ์ค์ **:
- .env ํ์ผ์ Stripe API ํค ์ถ๊ฐ:
```
STRIPE_KEY=pk_test_xxxxx
STRIPE_SECRET=sk_test_xxxxx
```
- config/services.php์ Stripe ์ค์ ์ถ๊ฐ:
```php
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
],
```
2. **๊ฒฐ์ ์ปจํธ๋กค๋ฌ ์์ฑ**:
```bash
php artisan make:controller PaymentController
```
3. **๋ผ์ฐํธ ์ถ๊ฐ** (routes/web.php):
```php
Route::middleware(['auth'])->group(function () {
Route::post('/payment/process', [PaymentController::class, 'process'])->name('payment.process');
Route::get('/payment/success', [PaymentController::class, 'success'])->name('payment.success');
Route::get('/payment/cancel', [PaymentController::class, 'cancel'])->name('payment.cancel');
});
```
4. **PaymentController ๊ตฌํ**:
- `process()` ๋ฉ์๋:
- ์์ฝ ์ ๋ณด๋ฅผ ์ธ์
์ ์ ์ฅ
- Stripe Checkout Session ์์ฑ
- Checkout URL๋ก ๋ฆฌ๋ค์ด๋ ํธ
```php
use Stripe\Stripe;
use Stripe\Checkout\Session;
public function process(Request $request)
{
Stripe::setApiKey(config('services.stripe.secret'));
// ์์ฝ ์ ๋ณด ๊ฒ์ฆ
$validated = $request->validate([
'schedule_id' => 'required|exists:show_schedules,id',
'seat_ids' => 'required|array|min:1',
'seat_ids.*' => 'exists:seats,id',
]);
$schedule = ShowSchedule::findOrFail($validated['schedule_id']);
$seats = Seat::whereIn('id', $validated['seat_ids'])
->where('status', 'available')
->lockForUpdate()
->get();
// ์ข์์ด ๋ชจ๋ ์์ฝ ๊ฐ๋ฅํ์ง ํ์ธ
if ($seats->count() !== count($validated['seat_ids'])) {
return back()->withErrors(['seats' => '์ ํํ ์ข์ ์ค ์ผ๋ถ๊ฐ ์ด๋ฏธ ์์ฝ๋์์ต๋๋ค.']);
}
// ์ด ๊ธ์ก ๊ณ์ฐ
$totalAmount = $seats->sum('price');
// ์์ฝ ์ ๋ณด ์ธ์
์ ์ ์ฅ
session([
'pending_booking' => [
'schedule_id' => $schedule->id,
'seat_ids' => $validated['seat_ids'],
'total_amount' => $totalAmount,
]
]);
// Stripe Checkout Session ์์ฑ
$checkoutSession = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'krw',
'product_data' => [
'name' => $schedule->show->title . ' - ' . $schedule->show_date,
],
'unit_amount' => $totalAmount,
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('payment.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('payment.cancel'),
]);
return redirect($checkoutSession->url);
}
```
- `success()` ๋ฉ์๋:
- Stripe ๊ฒฐ์ ์ฑ๊ณต ํ์ธ
- DB ํธ๋์ญ์
์ผ๋ก ์์ฝ ์์ฑ ๋ฐ ์ข์ ์ํ ์
๋ฐ์ดํธ
- ์ฌ์ฉ์ total_spent ์
๋ฐ์ดํธ ๋ฐ ๋ฑ๊ธ ๊ฐฑ์
- ์์ฝ ์๋ฃ ์ด๋ฉ์ผ ๋ฐ์ก (Queue ์ฌ์ฉ)
```php
public function success(Request $request)
{
Stripe::setApiKey(config('services.stripe.secret'));
$sessionId = $request->query('session_id');
$checkoutSession = Session::retrieve($sessionId);
if ($checkoutSession->payment_status !== 'paid') {
return redirect()->route('home')->withErrors(['payment' => '๊ฒฐ์ ๊ฐ ์๋ฃ๋์ง ์์์ต๋๋ค.']);
}
$pendingBooking = session('pending_booking');
if (!$pendingBooking) {
return redirect()->route('home')->withErrors(['booking' => '์์ฝ ์ ๋ณด๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.']);
}
DB::transaction(function () use ($pendingBooking, $sessionId) {
// ์ข์ ๋ฝ ๋ฐ ์ํ ํ์ธ
$seats = Seat::whereIn('id', $pendingBooking['seat_ids'])
->where('status', 'available')
->lockForUpdate()
->get();
if ($seats->count() !== count($pendingBooking['seat_ids'])) {
throw new \Exception('์ข์์ด ์ด๋ฏธ ์์ฝ๋์์ต๋๋ค.');
}
// ์์ฝ ์์ฑ
$booking = Booking::create([
'user_id' => auth()->id(),
'booking_number' => Booking::generateBookingNumber(),
'total_amount' => $pendingBooking['total_amount'],
'payment_status' => 'completed',
'payment_method' => 'card',
'stripe_payment_id' => $sessionId,
]);
// ์ข์ ์์ฝ ๋ฐ ์ํ ๋ณ๊ฒฝ
foreach ($seats as $seat) {
BookingSeat::create([
'booking_id' => $booking->id,
'seat_id' => $seat->id,
]);
$seat->markAsSold();
}
// ์ค์ผ์ค์ ๋จ์ ์ข์ ์ ์
๋ฐ์ดํธ
$schedule = ShowSchedule::find($pendingBooking['schedule_id']);
$schedule->decrement('available_seats', $seats->count());
// ์ฌ์ฉ์ ๋์ ๊ตฌ๋งค ๊ธ์ก ์
๋ฐ์ดํธ
$user = auth()->user();
$user->increment('total_spent', $pendingBooking['total_amount']);
$user->upgradeMembershipTier();
// ์์ฝ ์๋ฃ ์ด๋ฉ์ผ ๋ฐ์ก (Queue)
// TODO: ๋ค์ ๋จ๊ณ์์ ๊ตฌํ
});
session()->forget('pending_booking');
return redirect()->route('bookings.my')->with('success', '์์ฝ์ด ์๋ฃ๋์์ต๋๋ค!');
}
```
- `cancel()` ๋ฉ์๋:
- ๊ฒฐ์ ์ทจ์ ์ฒ๋ฆฌ
- ์ธ์
์ ๋ณด ์ญ์
5. **์์ฝ ํ์ด์ง ์์ **:
- bookings/create.blade.php์ ํผ ์ก์
์ `route('payment.process')`๋ก ๋ณ๊ฒฝ
- ๊ฒฐ์ ํ๊ธฐ ๋ฒํผ ํด๋ฆญ ์ Stripe Checkout์ผ๋ก ์ด๋
6. **ํธ๋์ญ์
๋ฐ ๋ฝ ์ฒ๋ฆฌ**:
- ์ข์ ์ค๋ณต ์์ฝ ๋ฐฉ์ง๋ฅผ ์ํด `lockForUpdate()` ์ฌ์ฉ
- DB::transaction()์ผ๋ก ์์์ฑ ๋ณด์ฅ
๊ธฐ๋ ๊ฒฐ๊ณผ: Stripe API๊ฐ ์ฐ๋๋์ด ์ค์ ๊ฒฐ์ ํ๋ก์ธ์ค๊ฐ ๊ตฌํ๋ฉ๋๋ค. ์ฌ์ฉ์๊ฐ ์ข์์ ์ ํํ๊ณ ๊ฒฐ์ ํ๊ธฐ ๋ฒํผ์ ํด๋ฆญํ๋ฉด Stripe Checkout ํ์ด์ง๋ก ์ด๋ํ์ฌ ์นด๋ ์ ๋ณด๋ฅผ ์ ๋ ฅํ ์ ์์ต๋๋ค. ๊ฒฐ์ ์ฑ๊ณต ์ ์์ฝ์ด ์์ฑ๋๊ณ ์ข์ ์ํ๊ฐ ์ ๋ฐ์ดํธ๋๋ฉฐ, ์ฌ์ฉ์์ ๋์ ๊ตฌ๋งค ๊ธ์ก๊ณผ ํ์ ๋ฑ๊ธ์ด ์๋์ผ๋ก ๊ฐฑ์ ๋ฉ๋๋ค. ํธ๋์ญ์ ๊ณผ ๋ฝ์ ํตํด ๋์์ฑ ๋ฌธ์ ๊ฐ ํด๊ฒฐ๋ฉ๋๋ค.
Queue๋ฅผ ์ฌ์ฉํ์ฌ ์์ฝ ์๋ฃ ์ด๋ฉ์ผ ์๋ฆผ์ ๋น๋๊ธฐ๋ก ๋ฐ์กํ๊ณ , PDF ํฐ์ผ์ ์์ฑํ์ธ์.
1. **Queue ์ค์ **:
- .env ํ์ผ์์ QUEUE_CONNECTION์ database๋ก ์ค์ (์ด๋ฏธ ์ค์ ๋จ)
- Queue ํ
์ด๋ธ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ ๋ฐ ์คํ:
```bash
php artisan queue:table
php artisan migrate
```
2. **๋ฉ์ผ ์ค์ **:
- .env ํ์ผ์ ๋ฉ์ผ ์ค์ ์ถ๊ฐ (ํ
์คํธ์ฉ์ผ๋ก Mailtrap ๋๋ log ๋๋ผ์ด๋ฒ ์ฌ์ฉ):
```
MAIL_MAILER=log
MAIL_FROM_ADDRESS=noreply@theater.com
MAIL_FROM_NAME="๊ทน๋จ ํฐ์ผ"
```
3. **Mailable ํด๋์ค ์์ฑ**:
```bash
php artisan make:mail BookingConfirmation
```
app/Mail/BookingConfirmation.php:
```php
namespace App\Mail;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class BookingConfirmation extends Mailable
{
use Queueable, SerializesModels;
public $booking;
public function __construct(Booking $booking)
{
$this->booking = $booking;
}
public function build()
{
return $this->subject('์์ฝ ์๋ฃ ์๋ด')
->markdown('emails.booking-confirmation');
}
}
```
4. **๋ฉ์ผ ๋ทฐ ์์ฑ** (resources/views/emails/booking-confirmation.blade.php):
```blade
@component('mail::message')
# ์์ฝ์ด ์๋ฃ๋์์ต๋๋ค!
์๋
ํ์ธ์ {{ $booking->user->name }}๋,
์์ฝ ๋ฒํธ: **{{ $booking->booking_number }}**
## ์์ฝ ์ ๋ณด
- ๊ณต์ฐ: {{ $booking->seats->first()->schedule->show->title }}
- ๋ ์ง: {{ $booking->seats->first()->schedule->show_date }}
- ์๊ฐ: {{ $booking->seats->first()->schedule->show_time }}
- ์ข์: {{ $booking->seats->pluck('seat_number')->join(', ') }}
- ์ด ๊ธ์ก: {{ number_format($booking->total_amount) }}์
@component('mail::button', ['url' => route('bookings.show', $booking)])
์์ฝ ์์ธ ๋ณด๊ธฐ
@endcomponent
๊ฐ์ฌํฉ๋๋ค,<br>
{{ config('app.name') }}
@endcomponent
```
5. **Job ํด๋์ค ์์ฑ**:
```bash
php artisan make:job SendBookingConfirmationEmail
```
app/Jobs/SendBookingConfirmationEmail.php:
```php
namespace App\Jobs;
use App\Models\Booking;
use App\Mail\BookingConfirmation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendBookingConfirmationEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $booking;
public function __construct(Booking $booking)
{
$this->booking = $booking;
}
public function handle()
{
Mail::to($this->booking->user->email)
->send(new BookingConfirmation($this->booking));
}
}
```
6. **PaymentController์ success() ๋ฉ์๋ ์์ **:
- ํธ๋์ญ์
๋ด๋ถ์์ Job ๋์คํจ์น:
```php
// ์์ฝ ์๋ฃ ์ด๋ฉ์ผ ๋ฐ์ก (Queue)
SendBookingConfirmationEmail::dispatch($booking);
```
7. **Queue Worker ์คํ**:
```bash
php artisan queue:work
```
- ๋๋ ๊ฐ๋ฐ ํ๊ฒฝ์์๋ `php artisan queue:listen`
8. **PDF ํฐ์ผ ์์ฑ**:
- BookingController์ `downloadTicket()` ๋ฉ์๋ ๊ตฌํ:
```php
use Barryvdh\DomPDF\Facade\Pdf;
public function downloadTicket(Booking $booking)
{
$this->authorize('view', $booking);
if ($booking->payment_status !== 'completed') {
return back()->withErrors(['ticket' => '๊ฒฐ์ ๊ฐ ์๋ฃ๋ ์์ฝ๋ง ํฐ์ผ์ ๋ค์ด๋ก๋ํ ์ ์์ต๋๋ค.']);
}
$pdf = Pdf::loadView('bookings.ticket', compact('booking'));
return $pdf->download('ticket-' . $booking->booking_number . '.pdf');
}
```
9. **PDF ํฐ์ผ ๋ทฐ ์์ฑ** (resources/views/bookings/ticket.blade.php):
```blade
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ํฐ์ผ - {{ $booking->booking_number }}</title>
<style>
body { font-family: DejaVu Sans, sans-serif; }
.ticket { border: 2px solid #000; padding: 20px; }
.qr-code { text-align: center; }
</style>
</head>
<body>
<div class="ticket">
<h1>๊ณต์ฐ ํฐ์ผ</h1>
<p><strong>์์ฝ ๋ฒํธ:</strong> {{ $booking->booking_number }}</p>
<p><strong>๊ณต์ฐ:</strong> {{ $booking->seats->first()->schedule->show->title }}</p>
<p><strong>๋ ์ง:</strong> {{ $booking->seats->first()->schedule->show_date }}</p>
<p><strong>์๊ฐ:</strong> {{ $booking->seats->first()->schedule->show_time }}</p>
<p><strong>์ข์:</strong> {{ $booking->seats->pluck('seat_number')->join(', ') }}</p>
<p><strong>๊ด๋๊ฐ:</strong> {{ $booking->user->name }}</p>
<div class="qr-code">
<!-- QR ์ฝ๋ ์์ฑ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ (์: SimpleSoftwareIO/simple-qrcode) -->
<p>[QR ์ฝ๋ ์์น]</p>
<p>{{ $booking->booking_number }}</p>
</div>
</div>
</body>
</html>
```
10. **QR ์ฝ๋ ์์ฑ (์ ํ์ฌํญ)**:
- QR ์ฝ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ค์น:
```bash
composer require simplesoftwareio/simple-qrcode
```
- ํฐ์ผ ๋ทฐ์์ QR ์ฝ๋ ์์ฑ:
```blade
{!! QrCode::size(200)->generate($booking->booking_number) !!}
```
๊ธฐ๋ ๊ฒฐ๊ณผ: Queue ์์คํ ์ด ์ค์ ๋๊ณ , ์์ฝ ์๋ฃ ์ ์ด๋ฉ์ผ์ด ๋น๋๊ธฐ๋ก ๋ฐ์ก๋ฉ๋๋ค. Queue Worker๋ฅผ ์คํํ๋ฉด jobs ํ ์ด๋ธ์ ์์ธ ์์ ์ด ์ฒ๋ฆฌ๋์ด ์ฌ์ฉ์์๊ฒ ์์ฝ ํ์ธ ์ด๋ฉ์ผ์ด ์ ์ก๋ฉ๋๋ค. PDF ํฐ์ผ ๋ค์ด๋ก๋ ๊ธฐ๋ฅ์ด ๊ตฌํ๋์ด ์ฌ์ฉ์๊ฐ ์์ฝ ์์ธ ํ์ด์ง์์ ํฐ์ผ์ PDF๋ก ๋ค์ด๋ก๋ํ ์ ์์ต๋๋ค. QR ์ฝ๋๊ฐ ํฌํจ๋ ํฐ์ผ์ ํ์ฅ์์ ์ ์ฅ ํ์ธ์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
๊ด๋ฆฌ์ ๋์๋ณด๋๋ฅผ ๊ตฌ์ฑํ์ฌ ์์ฆ/๊ณต์ฐ/์ข์ ๊ด๋ฆฌ ๋ฐ ํต๊ณ ๊ธฐ๋ฅ์ ๊ตฌํํ์ธ์.
1. **๊ด๋ฆฌ์ ๋ฏธ๋ค์จ์ด ์์ฑ**:
```bash
php artisan make:middleware IsAdmin
```
app/Http/Middleware/IsAdmin.php:
```php
public function handle(Request $request, Closure $next)
{
if (!auth()->check() || auth()->user()->role !== 'admin') {
abort(403, '๊ด๋ฆฌ์ ๊ถํ์ด ํ์ํฉ๋๋ค.');
}
return $next($request);
}
```
app/Http/Kernel.php์ ๋ฏธ๋ค์จ์ด ๋ฑ๋ก:
```php
protected $middlewareAliases = [
// ...
'admin' => \App\Http\Middleware\IsAdmin::class,
];
```
2. **๊ด๋ฆฌ์ ๋ผ์ฐํธ ์ถ๊ฐ** (routes/web.php):
```php
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\SeasonManageController;
use App\Http\Controllers\Admin\ShowManageController;
use App\Http\Controllers\Admin\BookingManageController;
Route::prefix('admin')->middleware(['auth', 'admin'])->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('seasons', SeasonManageController::class);
Route::resource('shows', ShowManageController::class);
Route::get('bookings', [BookingManageController::class, 'index'])->name('bookings.index');
Route::patch('bookings/{booking}/refund', [BookingManageController::class, 'refund'])->name('bookings.refund');
});
```
3. **๊ด๋ฆฌ์ ์ปจํธ๋กค๋ฌ ์์ฑ**:
```bash
php artisan make:controller Admin/DashboardController
php artisan make:controller Admin/SeasonManageController --resource
php artisan make:controller Admin/ShowManageController --resource
php artisan make:controller Admin/BookingManageController
```
4. **DashboardController ๊ตฌํ**:
- `index()` ๋ฉ์๋:
- ์ ์ฒด ์์ฝ ์, ์ด ๋งค์ถ, ํ๊ท ์์ฝ ๊ธ์ก
- ์ค๋/์ด๋ฒ ์ฃผ/์ด๋ฒ ๋ฌ ์์ฝ ํต๊ณ
- ์ธ๊ธฐ ๊ณต์ฐ Top 5 (์์ฝ ์ ๊ธฐ์ค)
- ์ต๊ทผ ์์ฝ 10๊ฑด
- ์ข์ ์ ์ ์จ ์ฐจํธ ๋ฐ์ดํฐ (Chart.js์ฉ JSON)
```php
public function index()
{
$totalBookings = Booking::where('payment_status', 'completed')->count();
$totalRevenue = Booking::where('payment_status', 'completed')->sum('total_amount');
$avgBookingAmount = $totalBookings > 0 ? $totalRevenue / $totalBookings : 0;
$todayBookings = Booking::whereDate('created_at', today())->count();
$weekBookings = Booking::whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()])->count();
$monthBookings = Booking::whereMonth('created_at', now()->month)->count();
$topShows = Show::withCount('schedules.seats.bookingSeats')
->orderBy('schedules_seats_booking_seats_count', 'desc')
->take(5)
->get();
$recentBookings = Booking::with('user', 'seats.schedule.show')
->latest()
->take(10)
->get();
// ์ข์ ์ ์ ์จ ์ฐจํธ ๋ฐ์ดํฐ
$occupancyData = ShowSchedule::selectRaw('show_id, SUM(total_seats - available_seats) as sold, SUM(total_seats) as total')
->groupBy('show_id')
->with('show')
->get()
->map(function ($schedule) {
return [
'show' => $schedule->show->title,
'occupancy' => $schedule->total > 0 ? round(($schedule->sold / $schedule->total) * 100, 2) : 0,
];
});
return view('admin.dashboard', compact(
'totalBookings', 'totalRevenue', 'avgBookingAmount',
'todayBookings', 'weekBookings', 'monthBookings',
'topShows', 'recentBookings', 'occupancyData'
));
}
```
5. **SeasonManageController ๊ตฌํ**:
- `index()`: ์์ฆ ๋ชฉ๋ก (ํ์ด์ง๋ค์ด์
)
- `create()`: ์์ฆ ์์ฑ ํผ
- `store()`: ์์ฆ ์ ์ฅ (์ด๋ฏธ์ง ์
๋ก๋ ํฌํจ)
- `edit()`: ์์ฆ ์์ ํผ
- `update()`: ์์ฆ ์
๋ฐ์ดํธ
- `destroy()`: ์์ฆ ์ญ์ (์ฐ๊ด๋ ๊ณต์ฐ๋ ํจ๊ป ์ญ์ ๋จ)
6. **ShowManageController ๊ตฌํ**:
- `index()`: ๊ณต์ฐ ๋ชฉ๋ก (์์ฆ๋ณ ํํฐ๋ง ๊ฐ๋ฅ)
- `create()`: ๊ณต์ฐ ์์ฑ ํผ (์์ฆ ์ ํ ๋๋กญ๋ค์ด)
- `store()`: ๊ณต์ฐ ์ ์ฅ (์ค์ผ์ค ๋ฐ ์ข์ ์๋ ์์ฑ)
- `edit()`: ๊ณต์ฐ ์์ ํผ
- `update()`: ๊ณต์ฐ ์
๋ฐ์ดํธ
- `destroy()`: ๊ณต์ฐ ์ญ์
7. **BookingManageController ๊ตฌํ**:
- `index()`: ์์ฝ ๋ชฉ๋ก (๊ฒ์/ํํฐ๋ง: ์ฌ์ฉ์๋ช
, ๊ณต์ฐ๋ช
, ๊ฒฐ์ ์ํ)
- `refund()`: ์์ฝ ํ๋ถ ์ฒ๋ฆฌ (์ข์ ์ํ 'available'๋ก ๋ณต๊ตฌ, ๊ฒฐ์ ์ํ 'refunded'๋ก ๋ณ๊ฒฝ)
8. **๊ด๋ฆฌ์ ๋ทฐ ์์ฑ**:
- resources/views/admin/dashboard.blade.php:
- ํต๊ณ ์นด๋ (์ด ์์ฝ, ์ด ๋งค์ถ, ํ๊ท ๊ธ์ก)
- ์ค๋/์ด๋ฒ ์ฃผ/์ด๋ฒ ๋ฌ ์์ฝ ์
- ์ธ๊ธฐ ๊ณต์ฐ Top 5 ๋ฆฌ์คํธ
- ์ต๊ทผ ์์ฝ ํ
์ด๋ธ
- Chart.js๋ก ์ข์ ์ ์ ์จ ์ฐจํธ ํ์
- resources/views/admin/seasons/index.blade.php:
- ์์ฆ ๋ชฉ๋ก ํ
์ด๋ธ (์ ๋ชฉ, ๊ธฐ๊ฐ, ๊ณต์ฐ ์, ์ก์
๋ฒํผ)
- "์ ์์ฆ ์ถ๊ฐ" ๋ฒํผ
- resources/views/admin/seasons/create.blade.php:
- ์์ฆ ์์ฑ ํผ (์ ๋ชฉ, ์ค๋ช
, ์์/์ข
๋ฃ ๋ ์ง, ์ด๋ฏธ์ง ์
๋ก๋)
- resources/views/admin/shows/index.blade.php:
- ๊ณต์ฐ ๋ชฉ๋ก ํ
์ด๋ธ (์ ๋ชฉ, ์์ฆ, ์ฅ๋ฅด, ์ค์ผ์ค ์, ์ก์
๋ฒํผ)
- resources/views/admin/bookings/index.blade.php:
- ์์ฝ ๋ชฉ๋ก ํ
์ด๋ธ (์์ฝ ๋ฒํธ, ์ฌ์ฉ์, ๊ณต์ฐ, ๊ฒฐ์ ์ํ, ๊ธ์ก, ํ๋ถ ๋ฒํผ)
9. **Chart.js ํตํฉ**:
- resources/views/admin/dashboard.blade.php์ Chart.js CDN ์ถ๊ฐ:
```html
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('occupancyChart').getContext('2d');
const occupancyData = @json($occupancyData);
new Chart(ctx, {
type: 'bar',
data: {
labels: occupancyData.map(d => d.show),
datasets: [{
label: '์ข์ ์ ์ ์จ (%)',
data: occupancyData.map(d => d.occupancy),
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
</script>
```
10. **๋ค๋น๊ฒ์ด์
์ ๊ด๋ฆฌ์ ๋งํฌ ์ถ๊ฐ**:
- layouts/app.blade.php์์ ๋ก๊ทธ์ธํ ์ฌ์ฉ์๊ฐ ๊ด๋ฆฌ์์ธ ๊ฒฝ์ฐ "๊ด๋ฆฌ์ ๋์๋ณด๋" ๋งํฌ ํ์:
```blade
@auth
@if(auth()->user()->role === 'admin')
<a href="{{ route('admin.dashboard') }}">๊ด๋ฆฌ์ ๋์๋ณด๋</a>
@endif
@endauth
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ด๋ฆฌ์ ์ ์ฉ ๋์๋ณด๋๊ฐ ์์ฑ๋๊ณ , ์์ฆ/๊ณต์ฐ/์์ฝ์ ๊ด๋ฆฌํ ์ ์๋ CRUD ๊ธฐ๋ฅ์ด ๊ตฌํ๋ฉ๋๋ค. ํต๊ณ ์ฐจํธ(Chart.js)๋ฅผ ํตํด ์ข์ ์ ์ ์จ์ ์๊ฐ์ ์ผ๋ก ํ์ธํ ์ ์์ผ๋ฉฐ, ์ต๊ทผ ์์ฝ ๋ชฉ๋ก๊ณผ ์ธ๊ธฐ ๊ณต์ฐ ์์๋ฅผ ํ๋์ ๋ณผ ์ ์์ต๋๋ค. ๊ด๋ฆฌ์๋ ์์ฝ์ ํ๋ถ ์ฒ๋ฆฌํ ์ ์์ผ๋ฉฐ, ์ข์ ์ํ๊ฐ ์๋์ผ๋ก ๋ณต๊ตฌ๋ฉ๋๋ค.
Redis ์บ์ฑ์ ์ ์ฉํ์ฌ ์ฑ๋ฅ์ ์ต์ ํํ๊ณ , PHPUnit ํ
์คํธ๋ฅผ ์์ฑํ์ธ์.
1. **Redis ์บ์ฑ ์ ์ฉ**:
- ์์ฃผ ์กฐํ๋๋ ๋ฐ์ดํฐ์ ์บ์ฑ ์ ์ฉ:
- ํํ์ด์ง์ ์ธ๊ธฐ ๊ณต์ฐ ๋ชฉ๋ก
- ์์ฆ ๋ชฉ๋ก
- ๊ณต์ฐ ์์ธ ์ ๋ณด
- ์ข์ ์ฌ๊ณ ์ ๋ณด
์์: HomeController์ index() ๋ฉ์๋์ ์บ์ฑ ์ ์ฉ:
```php
use Illuminate\Support\Facades\Cache;
public function index()
{
$activeSeasons = Cache::remember('active_seasons', 3600, function () {
return Season::active()->with('shows')->get();
});
$popularShows = Cache::remember('popular_shows', 3600, function () {
return Show::withAvg('reviews', 'rating')
->orderBy('reviews_avg_rating', 'desc')
->take(4)
->get();
});
$recentReviews = Review::with('user', 'show')
->latest()
->take(3)
->get();
return view('home', compact('activeSeasons', 'popularShows', 'recentReviews'));
}
```
- ์บ์ ๋ฌดํจํ:
- ์์ฆ์ด๋ ๊ณต์ฐ์ด ์์ฑ/์์ /์ญ์ ๋ ๋ ๊ด๋ จ ์บ์ ์ญ์ :
```php
Cache::forget('active_seasons');
Cache::forget('popular_shows');
```
2. **์ข์ ์ฌ๊ณ ์บ์ฑ**:
- ShowSchedule ๋ชจ๋ธ์ ์บ์ ๋ฉ์๋ ์ถ๊ฐ:
```php
public function getAvailableSeatsAttribute()
{
return Cache::remember("schedule_{$this->id}_available_seats", 60, function () {
return $this->seats()->where('status', 'available')->count();
});
}
public function decrementAvailableSeats()
{
Cache::forget("schedule_{$this->id}_available_seats");
$this->decrement('available_seats');
}
```
3. **PHPUnit ํ
์คํธ ์์ฑ**:
- ํ
์คํธ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์ (phpunit.xml):
```xml
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
```
- Feature ํ
์คํธ ์์ฑ:
```bash
php artisan make:test BookingTest
php artisan make:test PaymentTest
php artisan make:test SeatReservationTest
```
- **BookingTest** (tests/Feature/BookingTest.php):
```php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Show;
use App\Models\ShowSchedule;
use App\Models\Seat;
use Illuminate\Foundation\Testing\RefreshDatabase;
class BookingTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_view_booking_page()
{
$user = User::factory()->create();
$show = Show::factory()->create();
$schedule = ShowSchedule::factory()->create(['show_id' => $show->id]);
$response = $this->actingAs($user)
->get(route('bookings.create', $show));
$response->assertStatus(200);
$response->assertSee($show->title);
}
public function test_guest_cannot_access_booking_page()
{
$show = Show::factory()->create();
$response = $this->get(route('bookings.create', $show));
$response->assertRedirect(route('login'));
}
public function test_booking_creates_record_in_database()
{
$user = User::factory()->create();
$schedule = ShowSchedule::factory()->create();
$seat = Seat::factory()->create(['show_schedule_id' => $schedule->id, 'status' => 'available']);
$response = $this->actingAs($user)
->post(route('payment.process'), [
'schedule_id' => $schedule->id,
'seat_ids' => [$seat->id],
]);
// Stripe Checkout์ผ๋ก ๋ฆฌ๋ค์ด๋ ํธ๋๋์ง ํ์ธ
$response->assertRedirect();
// ์ธ์
์ ์์ฝ ์ ๋ณด๊ฐ ์ ์ฅ๋๋์ง ํ์ธ
$this->assertNotNull(session('pending_booking'));
}
}
```
- **SeatReservationTest** (tests/Feature/SeatReservationTest.php):
```php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Seat;
use App\Models\Booking;
use App\Models\BookingSeat;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
class SeatReservationTest extends TestCase
{
use RefreshDatabase;
public function test_seat_status_changes_after_booking()
{
$seat = Seat::factory()->create(['status' => 'available']);
$seat->markAsSold();
$this->assertEquals('sold', $seat->fresh()->status);
}
public function test_cannot_book_already_sold_seat()
{
$seat = Seat::factory()->create(['status' => 'sold']);
$availableSeats = Seat::where('id', $seat->id)
->where('status', 'available')
->get();
$this->assertCount(0, $availableSeats);
}
public function test_concurrent_booking_prevents_double_reservation()
{
$seat = Seat::factory()->create(['status' => 'available']);
// ๋์ ์์ฝ ์๋ฎฌ๋ ์ด์
DB::transaction(function () use ($seat) {
$lockedSeat = Seat::where('id', $seat->id)
->where('status', 'available')
->lockForUpdate()
->first();
$this->assertNotNull($lockedSeat);
$lockedSeat->markAsSold();
});
// ๋ค๋ฅธ ํธ๋์ญ์
์์ ๊ฐ์ ์ข์ ์์ฝ ์๋
$lockedSeat = Seat::where('id', $seat->id)
->where('status', 'available')
->first();
$this->assertNull($lockedSeat);
}
}
```
- **PaymentTest** (tests/Feature/PaymentTest.php):
```php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Booking;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PaymentTest extends TestCase
{
use RefreshDatabase;
public function test_user_total_spent_increases_after_payment()
{
$user = User::factory()->create(['total_spent' => 0]);
$booking = Booking::factory()->create([
'user_id' => $user->id,
'total_amount' => 100000,
'payment_status' => 'pending',
]);
$booking->update(['payment_status' => 'completed']);
$this->assertEquals(100000, $user->fresh()->total_spent);
}
public function test_membership_tier_upgrades_based_on_spending()
{
$user = User::factory()->create(['total_spent' => 0, 'membership_tier' => 'bronze']);
$user->update(['total_spent' => 600000]);
$user->upgradeMembershipTier();
$this->assertEquals('silver', $user->fresh()->membership_tier);
$user->update(['total_spent' => 1100000]);
$user->upgradeMembershipTier();
$this->assertEquals('gold', $user->fresh()->membership_tier);
}
}
```
4. **ํฉํ ๋ฆฌ ์์ฑ** (ํ์ํ ๊ฒฝ์ฐ):
```bash
php artisan make:factory ShowFactory
php artisan make:factory ShowScheduleFactory
php artisan make:factory SeatFactory
php artisan make:factory BookingFactory
```
๊ฐ ํฉํ ๋ฆฌ์ ๊ธฐ๋ณธ ๋ฐ์ดํฐ ์ ์:
```php
// database/factories/SeatFactory.php
public function definition()
{
return [
'show_schedule_id' => ShowSchedule::factory(),
'seat_number' => 'A-' . $this->faker->numberBetween(1, 100),
'seat_type' => $this->faker->randomElement(['vip', 'regular']),
'price' => $this->faker->randomElement([50000, 80000]),
'status' => 'available',
];
}
```
5. **ํ
์คํธ ์คํ**:
```bash
php artisan test
```
๋๋ ํน์ ํ
์คํธ๋ง ์คํ:
```bash
php artisan test --filter BookingTest
```
6. **ํ
์คํธ ์ปค๋ฒ๋ฆฌ์ง ํ์ธ** (์ ํ์ฌํญ):
- Xdebug ์ค์น ํ:
```bash
php artisan test --coverage
```
7. **N+1 ์ฟผ๋ฆฌ ๋ฌธ์ ํด๊ฒฐ**:
- Eager Loading ์ ์ฉ:
```php
// ์์ฝ ๋ชฉ๋ก ์กฐํ ์
$bookings = Booking::with('user', 'seats.schedule.show')->paginate(20);
// ๊ณต์ฐ ๋ชฉ๋ก ์กฐํ ์
$shows = Show::with('season', 'schedules')->get();
```
- Laravel Debugbar ์ค์นํ์ฌ ์ฟผ๋ฆฌ ๋ชจ๋ํฐ๋ง:
```bash
composer require barryvdh/laravel-debugbar --dev
```
๊ธฐ๋ ๊ฒฐ๊ณผ: Redis ์บ์ฑ์ด ์ ์ฉ๋์ด ์์ฃผ ์กฐํ๋๋ ๋ฐ์ดํฐ(์ธ๊ธฐ ๊ณต์ฐ, ์์ฆ ๋ชฉ๋ก ๋ฑ)์ ๋ก๋ฉ ์๋๊ฐ ํฌ๊ฒ ํฅ์๋ฉ๋๋ค. PHPUnit ํ ์คํธ๊ฐ ์์ฑ๋์ด ์์ฝ ๋ก์ง, ์ข์ ์ค๋ณต ๋ฐฉ์ง, ๊ฒฐ์ ์ฒ๋ฆฌ ๋ฑ ํต์ฌ ๊ธฐ๋ฅ์ด ์๋์ผ๋ก ๊ฒ์ฆ๋ฉ๋๋ค. ํ ์คํธ ์ปค๋ฒ๋ฆฌ์ง๊ฐ 50% ์ด์ ๋ฌ์ฑ๋๋ฉฐ, N+1 ์ฟผ๋ฆฌ ๋ฌธ์ ๊ฐ ํด๊ฒฐ๋์ด ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฟผ๋ฆฌ ์๊ฐ ์ต์ ํ๋ฉ๋๋ค. ์ ์ฒด ์์คํ ์ด ์์ ์ ์ผ๋ก ๋์ํ๋ฉฐ, ํฅํ ๊ธฐ๋ฅ ์ถ๊ฐ ์์๋ ๊ธฐ์กด ๊ธฐ๋ฅ์ ์ ์ ์๋์ด ๋ณด์ฅ๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!