diff --git a/DOCKER-GUIDE.md b/DOCKER-GUIDE.md new file mode 100644 index 0000000..5d04679 --- /dev/null +++ b/DOCKER-GUIDE.md @@ -0,0 +1,193 @@ +# TK-MP-Project Docker ๊ฐ€์ด๋“œ + +## ๐Ÿš€ ๋น ๋ฅธ ์‹œ์ž‘ + +### 1. ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์‹คํ–‰ +```bash +./docker-run.sh dev up +``` + +### 2. ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์‹คํ–‰ +```bash +./docker-run.sh prod up +``` + +### 3. ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ ์‹คํ–‰ +```bash +./docker-run.sh synology up +``` + +## ๐Ÿ“‹ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ช…๋ น์–ด + +| ๋ช…๋ น์–ด | ์„ค๋ช… | +|--------|------| +| `up` | ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ (๊ธฐ๋ณธ๊ฐ’) | +| `down` | ์ปจํ…Œ์ด๋„ˆ ์ค‘์ง€ | +| `build` | ์ด๋ฏธ์ง€ ๋นŒ๋“œ | +| `rebuild` | ์ด๋ฏธ์ง€ ์žฌ๋นŒ๋“œ (์บ์‹œ ๋ฌด์‹œ) | +| `logs` | ๋กœ๊ทธ ์‹ค์‹œ๊ฐ„ ํ™•์ธ | +| `ps` ๋˜๋Š” `status` | ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ | +| `restart` | ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ | + +## ๐ŸŒ ํ™˜๊ฒฝ๋ณ„ ์„ค์ • + +### ๊ฐœ๋ฐœ ํ™˜๊ฒฝ (dev) +- **ํฌํŠธ**: ๋ชจ๋“  ์„œ๋น„์Šค ์™ธ๋ถ€ ๋…ธ์ถœ + - Frontend: http://localhost:13000 + - Backend API: http://localhost:18000 + - PostgreSQL: localhost:5432 + - Redis: localhost:6379 + - pgAdmin: http://localhost:5050 +- **ํŠน์ง•**: + - ์ฝ”๋“œ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ (Hot Reload) + - ๋””๋ฒ„๊ทธ ๋ชจ๋“œ ํ™œ์„ฑํ™” + - ๋ชจ๋“  ๋กœ๊ทธ ๋ ˆ๋ฒจ ์ถœ๋ ฅ + +### ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ (prod) +- **ํฌํŠธ**: Nginx๋ฅผ ํ†ตํ•œ ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ + - Web: http://localhost (Nginx) + - HTTPS: https://localhost (SSL ์„ค์ • ํ•„์š”) +- **ํŠน์ง•**: + - ๋‚ด๋ถ€ ์„œ๋น„์Šค ํฌํŠธ ๋น„๋…ธ์ถœ + - ์ตœ์ ํ™”๋œ ๋นŒ๋“œ + - ๋กœ๊ทธ ๋ ˆ๋ฒจ INFO + - pgAdmin ๋น„ํ™œ์„ฑํ™” + +### ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ (synology) +- **ํฌํŠธ**: ํฌํŠธ ์ถฉ๋Œ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ์ปค์Šคํ…€ ํฌํŠธ + - Frontend: http://localhost:10173 + - Backend API: http://localhost:10080 + - PostgreSQL: localhost:15432 + - Redis: localhost:16379 + - pgAdmin: http://localhost:15050 +- **ํŠน์ง•**: + - ๋ช…๋ช…๋œ ๋ณผ๋ฅจ ์‚ฌ์šฉ + - ์‹œ๋†€๋กœ์ง€ Container Manager ํ˜ธํ™˜ + +## ๐Ÿ”ง ํ™˜๊ฒฝ ์„ค์ • ํŒŒ์ผ + +๊ฐ ํ™˜๊ฒฝ๋ณ„ ์„ค์ •์€ ๋‹ค์Œ ํŒŒ์ผ์—์„œ ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค: + +- `env.development` - ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์„ค์ • +- `env.production` - ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์„ค์ • +- `env.synology` - ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ์„ค์ • + +### ์ฃผ์š” ํ™˜๊ฒฝ ๋ณ€์ˆ˜ + +```bash +# ๋ฐฐํฌ ํ™˜๊ฒฝ +DEPLOY_ENV=development|production|synology + +# ํฌํŠธ ์„ค์ • +FRONTEND_EXTERNAL_PORT=13000 +BACKEND_EXTERNAL_PORT=18000 +POSTGRES_EXTERNAL_PORT=5432 + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +POSTGRES_DB=tk_mp_bom +POSTGRES_USER=tkmp_user +POSTGRES_PASSWORD=tkmp_password_2025 + +# ๋””๋ฒ„๊ทธ ์„ค์ • +DEBUG=true|false +LOG_LEVEL=DEBUG|INFO|WARNING|ERROR +``` + +## ๐Ÿ› ๏ธ ์‚ฌ์šฉ ์˜ˆ์‹œ + +### ๊ฐœ๋ฐœ ์‹œ์ž‘ +```bash +# ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์‹œ์ž‘ +./docker-run.sh dev up + +# ๋กœ๊ทธ ํ™•์ธ +./docker-run.sh dev logs + +# ์ƒํƒœ ํ™•์ธ +./docker-run.sh dev ps +``` + +### ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ +```bash +# ์ด๋ฏธ์ง€ ๋นŒ๋“œ +./docker-run.sh prod build + +# ํ”„๋กœ๋•์…˜ ์‹œ์ž‘ +./docker-run.sh prod up + +# ์ƒํƒœ ํ™•์ธ +./docker-run.sh prod ps +``` + +### ์‹œ๋†€๋กœ์ง€ NAS ๋ฐฐํฌ +```bash +# ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ์‹œ์ž‘ +./docker-run.sh synology up + +# ๋กœ๊ทธ ํ™•์ธ +./docker-run.sh synology logs +``` + +### ์ปจํ…Œ์ด๋„ˆ ๊ด€๋ฆฌ +```bash +# ์ปจํ…Œ์ด๋„ˆ ์ค‘์ง€ +./docker-run.sh dev down + +# ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ +./docker-run.sh dev restart + +# ์ด๋ฏธ์ง€ ์žฌ๋นŒ๋“œ (์บ์‹œ ๋ฌด์‹œ) +./docker-run.sh dev rebuild +``` + +## ๐Ÿ” ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… + +### ํฌํŠธ ์ถฉ๋Œ ํ•ด๊ฒฐ +ํ™˜๊ฒฝ ์„ค์ • ํŒŒ์ผ์—์„œ `*_EXTERNAL_PORT` ๋ณ€์ˆ˜๋ฅผ ์ˆ˜์ •ํ•˜์„ธ์š”. + +### ๋ณผ๋ฅจ ๊ถŒํ•œ ๋ฌธ์ œ +```bash +# ๋ณผ๋ฅจ ์‚ญ์ œ ํ›„ ์žฌ์ƒ์„ฑ +docker volume prune +./docker-run.sh dev up +``` + +### ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฌธ์ œ +```bash +# ์บ์‹œ ์—†์ด ์žฌ๋นŒ๋“œ +./docker-run.sh dev rebuild +``` + +## ๐Ÿ“ ํŒŒ์ผ ๊ตฌ์กฐ + +``` +TK-MP-Project/ +โ”œโ”€โ”€ docker-compose.yml # ํ†ตํ•ฉ Docker Compose ํŒŒ์ผ +โ”œโ”€โ”€ docker-run.sh # ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ +โ”œโ”€โ”€ env.development # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์„ค์ • +โ”œโ”€โ”€ env.production # ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์„ค์ • +โ”œโ”€โ”€ env.synology # ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ์„ค์ • +โ”œโ”€โ”€ docker-backup/ # ๊ธฐ์กด ํŒŒ์ผ ๋ฐฑ์—… +โ”‚ โ”œโ”€โ”€ docker-compose.yml +โ”‚ โ”œโ”€โ”€ docker-compose.prod.yml +โ”‚ โ”œโ”€โ”€ docker-compose.synology.yml +โ”‚ โ””โ”€โ”€ docker-compose.override.yml +โ””โ”€โ”€ DOCKER-GUIDE.md # ์ด ๊ฐ€์ด๋“œ ํŒŒ์ผ +``` + +## ๐ŸŽฏ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ + +๊ธฐ์กด Docker Compose ํŒŒ์ผ์„ ์‚ฌ์šฉํ•˜๋˜ ๊ฒฝ์šฐ: + +1. **๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ค‘์ง€** + ```bash + docker-compose down + ``` + +2. **์ƒˆ๋กœ์šด ๋ฐฉ์‹์œผ๋กœ ์‹œ์ž‘** + ```bash + ./docker-run.sh dev up + ``` + +3. **๊ธฐ์กด ํŒŒ์ผ์€ `docker-backup/` ํด๋”์— ๋ณด๊ด€๋จ** + diff --git a/RULES.md b/RULES.md index 6e55717..a59f838 100644 --- a/RULES.md +++ b/RULES.md @@ -1,4 +1,4 @@ -# ๐Ÿš€ TK-MP-Project: ํ†ตํ•ฉ ํ”„๋กœ์ ํŠธ ๋ฌธ์„œ +# ๐Ÿš€ TK-MP-Project: ํ†ตํ•ฉ ํ”„๋กœ์ ํŠธ ๋ฌธ์„œ์กด > **์ตœ์ข… ์—…๋ฐ์ดํŠธ**: 2025๋…„ 1์›” (ํ†ตํ•ฉ ๋ฌธ์„œ ์ƒ์„ฑ) @@ -59,11 +59,15 @@ frontend/src/ โ”œโ”€โ”€ api.js # API ํด๋ผ์ด์–ธํŠธ (Axios ์„ค์ •) โ”œโ”€โ”€ components/ # ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ โ”‚ โ”œโ”€โ”€ NavigationMenu.jsx # ์‚ฌ์ด๋“œ๋ฐ” ๋„ค๋น„๊ฒŒ์ด์…˜ (๊ถŒํ•œ ๊ธฐ๋ฐ˜) +โ”‚ โ”œโ”€โ”€ PersonalizedDashboard.jsx # ๊ฐœ์ธํ™”๋œ ๋Œ€์‹œ๋ณด๋“œ (์—ญํ• ๋ณ„ ๋งž์ถค) +โ”‚ โ”œโ”€โ”€ ProjectSelector.jsx # ํ”„๋กœ์ ํŠธ ์„ ํƒ ๋“œ๋กญ๋‹ค์šด (๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ) โ”‚ โ”œโ”€โ”€ BOMFileUpload.jsx # BOM ํŒŒ์ผ ์—…๋กœ๋“œ ํผ โ”‚ โ”œโ”€โ”€ BOMFileTable.jsx # BOM ํŒŒ์ผ ๋ชฉ๋ก ํ…Œ์ด๋ธ” โ”‚ โ””โ”€โ”€ RevisionUploadDialog.jsx # ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ๋‹ค์ด์–ผ๋กœ๊ทธ โ””โ”€โ”€ pages/ # ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ - โ”œโ”€โ”€ DashboardPage.jsx # ๋Œ€์‹œ๋ณด๋“œ + โ”œโ”€โ”€ DashboardPage.jsx # ๋Œ€์‹œ๋ณด๋“œ (๊ธฐ์กด) + โ”œโ”€โ”€ ProjectWorkspacePage.jsx # ํ”„๋กœ์ ํŠธ๋ณ„ ์›Œํฌ์ŠคํŽ˜์ด์Šค (์‹ ๊ทœ) + โ”œโ”€โ”€ BOMUploadPage.jsx # BOM ์—…๋กœ๋“œ ํŽ˜์ด์ง€ (์‹ ๊ทœ) โ”œโ”€โ”€ ProjectsPage.jsx # ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ โ”œโ”€โ”€ JobSelectionPage.jsx # ํ”„๋กœ์ ํŠธ ์„ ํƒ โ”œโ”€โ”€ BOMStatusPage.jsx # BOM ๊ด€๋ฆฌ ๋ฉ”์ธ @@ -176,37 +180,401 @@ graph TD | `RevisionPurchasePage.jsx` | 300์ค„+ | ๊ตฌ๋งค ๋กœ์ง ๋ถ„๋ฆฌ | ๋‚ฎ์Œ | | `auth_service.py` | 300์ค„+ | ๊ธฐ๋Šฅ๋ณ„ ์„œ๋น„์Šค ๋ถ„๋ฆฌ | ๋†’์Œ | -### ๐ŸŒ API ์—”๋“œํฌ์ธํŠธ ๋งต +### ๐ŸŒ **API ์—”๋“œํฌ์ธํŠธ ์ „์ฒด ๋งต** (2025.01 ์ตœ์‹ ) -#### ์ธ์ฆ API (`/auth/`) +> **์ค‘์š”**: ์ƒˆ๋กœ์šด API ์ถ”๊ฐ€ ์‹œ ๋ฐ˜๋“œ์‹œ ์ด ์„น์…˜์„ ์—…๋ฐ์ดํŠธํ•˜๊ณ , ๋ฒ„์ „ ๊ด€๋ฆฌ ๋ฐ ํ•˜์œ„ ํ˜ธํ™˜์„ฑ์„ ๊ณ ๋ คํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +#### **๐Ÿ“‹ API ๋ฌธ์„œํ™” ๊ทœ์น™** +1. **์ƒˆ API ์ถ”๊ฐ€ ์‹œ**: ์ด ๋ฌธ์„œ์— ์ฆ‰์‹œ ๋ฐ˜์˜ +2. **API ๋ณ€๊ฒฝ ์‹œ**: ๋ณ€๊ฒฝ ์ด๋ ฅ๊ณผ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ ํฌํ•จ +3. **๊ถŒํ•œ ํ‘œ์‹œ**: ๊ฐ ์—”๋“œํฌ์ธํŠธ๋ณ„ ํ•„์š” ๊ถŒํ•œ ๋ช…์‹œ +4. **์‘๋‹ต ํ˜•์‹**: ํ‘œ์ค€ ์‘๋‹ต ๊ตฌ์กฐ ์ค€์ˆ˜ + +#### **๐Ÿšจ API ์‚ฌ์šฉ ๊ฐ€์ด๋“œ๋ผ์ธ (ํ˜ผ๋™ ๋ฐฉ์ง€)** โญ ์ค‘์š” + +##### **1. ์ž์žฌ ๊ด€๋ จ API ํ†ตํ•ฉ ์‚ฌ์šฉ๋ฒ•** +```javascript +// โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ๋ฒ• - ํ†ตํ•ฉ๋œ API ์‚ฌ์šฉ +import { fetchMaterials } from '../api'; + +// ํŒŒ์ผ๋ณ„ ์ž์žฌ ์กฐํšŒ +const materials = await fetchMaterials({ file_id: 123, limit: 1000 }); + +// ํ”„๋กœ์ ํŠธ๋ณ„ ์ž์žฌ ์กฐํšŒ +const materials = await fetchMaterials({ job_no: 'J24-001', limit: 1000 }); + +// ๋ฆฌ๋น„์ „๋ณ„ ์ž์žฌ ์กฐํšŒ +const materials = await fetchMaterials({ + job_no: 'J24-001', + revision: 'Rev.1', + limit: 1000 +}); + +// โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ๋ฒ• - ์ง์ ‘ API ํ˜ธ์ถœ ๊ธˆ์ง€ +const response = await api.get('/files/materials-v2', { params }); // ๊ธˆ์ง€ +const response = await api.get('/files/materials', { params }); // ์กด์žฌํ•˜์ง€ ์•Š์Œ ``` -POST /auth/login # ๋กœ๊ทธ์ธ -POST /auth/register # ์‚ฌ์šฉ์ž ๋“ฑ๋ก -POST /auth/refresh # ํ† ํฐ ๊ฐฑ์‹  -POST /auth/logout # ๋กœ๊ทธ์•„์›ƒ -GET /auth/me # ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด -GET /auth/verify # ํ† ํฐ ๊ฒ€์ฆ + +##### **2. API ํ•จ์ˆ˜ vs ์ง์ ‘ ํ˜ธ์ถœ ๊ทœ์น™** +```javascript +// โœ… ๊ถŒ์žฅ: api.js์˜ ๋ž˜ํผ ํ•จ์ˆ˜ ์‚ฌ์šฉ +import { fetchMaterials, fetchFiles, fetchJobs } from '../api'; + +// โŒ ๋น„๊ถŒ์žฅ: ์ง์ ‘ API ํ˜ธ์ถœ (ํŠน๋ณ„ํ•œ ๊ฒฝ์šฐ์—๋งŒ) +const response = await api.get('/files/materials-v2'); +``` + +##### **3. ๋ฐฑ์—”๋“œ API ์—”๋“œํฌ์ธํŠธ ๋ช…๋ช… ๊ทœ์น™** +- **๊ธฐ๋ณธ ํ˜•ํƒœ**: `/{๋ชจ๋“ˆ}/{๋ฆฌ์†Œ์Šค}` +- **๋ฒ„์ „ ๊ด€๋ฆฌ**: `/{๋ชจ๋“ˆ}/{๋ฆฌ์†Œ์Šค}-v2` (ํ•˜์œ„ ํ˜ธํ™˜์„ฑ ์œ ์ง€) +- **์•ก์…˜ ๊ธฐ๋ฐ˜**: `/{๋ชจ๋“ˆ}/{๋ฆฌ์†Œ์Šค}/{์•ก์…˜}` + +**์˜ˆ์‹œ:** +``` +/files/materials-v2 # ์ž์žฌ ๋ชฉ๋ก (์ตœ์‹  ๋ฒ„์ „) +/files/materials/summary # ์ž์žฌ ์š”์•ฝ ํ†ต๊ณ„ +/files/materials/compare-revisions # ๋ฆฌ๋น„์ „ ๋น„๊ต +/purchase/items/calculate # ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ +/materials/compare-revisions # ์ž์žฌ ๋น„๊ต (๋ณ„๋„ ๋ชจ๋“ˆ) +``` + +##### **4. ํ”„๋ก ํŠธ์—”๋“œ API ํ˜ธ์ถœ ํ‘œ์ค€ํ™”** +```javascript +// api.js - ๋ชจ๋“  API ํ•จ์ˆ˜๋Š” ์—ฌ๊ธฐ์— ์ •์˜ +export function fetchMaterials(params) { + return api.get('/files/materials-v2', { params }); +} + +export function fetchFiles(params) { + return api.get('/files', { params }); +} + +export function fetchJobs(params) { + return api.get('/jobs/', { params }); +} + +// ์ปดํฌ๋„ŒํŠธ์—์„œ ์‚ฌ์šฉ +import { fetchMaterials } from '../api'; +const response = await fetchMaterials({ job_no: 'J24-001' }); +``` + +--- + +#### **๐Ÿ” ์ธ์ฆ API (`/auth/`)** +```http +POST /auth/login # ๋กœ๊ทธ์ธ (๊ณต๊ฐœ) +POST /auth/register # ์‚ฌ์šฉ์ž ๋“ฑ๋ก (๊ด€๋ฆฌ์ž) +POST /auth/refresh # ํ† ํฐ ๊ฐฑ์‹  (์ธ์ฆ ํ•„์š”) +POST /auth/logout # ๋กœ๊ทธ์•„์›ƒ (์ธ์ฆ ํ•„์š”) +GET /auth/me # ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด (์ธ์ฆ ํ•„์š”) +GET /auth/verify # ํ† ํฐ ๊ฒ€์ฆ (์ธ์ฆ ํ•„์š”) GET /auth/users # ์‚ฌ์šฉ์ž ๋ชฉ๋ก (๊ด€๋ฆฌ์ž) PUT /auth/users/{id} # ์‚ฌ์šฉ์ž ์ˆ˜์ • (๊ด€๋ฆฌ์ž) +DELETE /auth/users/{id} # ์‚ฌ์šฉ์ž ์‚ญ์ œ (๊ด€๋ฆฌ์ž) ``` -#### ํ”„๋กœ์ ํŠธ API (`/jobs/`) -``` -GET /jobs/ # ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก -POST /jobs/ # ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ -PUT /jobs/{id} # ํ”„๋กœ์ ํŠธ ์ˆ˜์ • -DELETE /jobs/{id} # ํ”„๋กœ์ ํŠธ ์‚ญ์ œ +#### **๐Ÿ“‹ ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ API (`/jobs/`)** +```http +GET /jobs/ # ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก (์‚ฌ์šฉ์ž) +POST /jobs/ # ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ (๋งค๋‹ˆ์ €+) +GET /jobs/{id} # ํ”„๋กœ์ ํŠธ ์ƒ์„ธ (์‚ฌ์šฉ์ž) +PUT /jobs/{id} # ํ”„๋กœ์ ํŠธ ์ˆ˜์ • (๋งค๋‹ˆ์ €+) +DELETE /jobs/{id} # ํ”„๋กœ์ ํŠธ ์‚ญ์ œ (๊ด€๋ฆฌ์ž) +GET /jobs/stats # ํ”„๋กœ์ ํŠธ ํ†ต๊ณ„ (๋งค๋‹ˆ์ €+) +POST /jobs/{id}/assign # ๋‹ด๋‹น์ž ํ• ๋‹น (๋งค๋‹ˆ์ €+) ``` -#### ํŒŒ์ผ/์ž์žฌ API (`/files/`) +**์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ:** +```json +// GET /jobs/ - ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก +{ + "success": true, + "total_count": 2, + "jobs": [ + { + "job_no": "J24-001", + "job_name": "์šธ์‚ฐ SK์—๋„ˆ์ง€ ์ •์œ ์‹œ์„ค ์ฆ์„ค ๋ฐฐ๊ด€๊ณต์‚ฌ", + "project_name": "์šธ์‚ฐ SK์—๋„ˆ์ง€ ์ •์œ ์‹œ์„ค ์ฆ์„ค ๋ฐฐ๊ด€๊ณต์‚ฌ", + "client_name": "์‚ผ์„ฑ์—”์ง€๋‹ˆ์–ด๋ง", + "end_user": "SK์—๋„ˆ์ง€", + "epc_company": "์‚ผ์„ฑ์—”์ง€๋‹ˆ์–ด๋ง", + "project_site": "์šธ์‚ฐ๊ด‘์—ญ์‹œ ์˜จ์‚ฐ๊ณต๋‹จ", + "contract_date": "2024-03-15", + "delivery_date": "2024-08-30", + "delivery_terms": "FOB ์šธ์‚ฐํ•ญ", + "project_type": "๋ƒ‰๋™๊ธฐ", + "status": "์ง„ํ–‰์ค‘", + "description": "์ •์œ ์‹œ์„ค ์ฆ์„ค์„ ์œ„ํ•œ ๋ฐฐ๊ด€ ์ž์žฌ ๊ณต๊ธ‰", + "created_at": "2025-07-15T03:44:46.035325" + } + ] +} ``` -GET /files # ํŒŒ์ผ ๋ชฉ๋ก (job_no ํ•„ํ„ฐ) -POST /files/upload # ํŒŒ์ผ ์—…๋กœ๋“œ -DELETE /files/{id} # ํŒŒ์ผ ์‚ญ์ œ -GET /files/stats # ํŒŒ์ผ/์ž์žฌ ํ†ต๊ณ„ -GET /files/materials # ์ž์žฌ ๋ชฉ๋ก (file_id ํ•„ํ„ฐ) + +#### **๐Ÿ“„ ํŒŒ์ผ/์ž์žฌ ๊ด€๋ฆฌ API (`/files/`)** +```http +GET /files # ํŒŒ์ผ ๋ชฉ๋ก (์‚ฌ์šฉ์ž) +POST /files/upload # ํŒŒ์ผ ์—…๋กœ๋“œ (์„ค๊ณ„์ž+) โญ ์‚ฌ์šฉ์ž ์ถ”์  +DELETE /files/delete/{file_id} # ํŒŒ์ผ ์‚ญ์ œ (์„ค๊ณ„์ž+) +GET /files/stats # ํŒŒ์ผ/์ž์žฌ ํ†ต๊ณ„ (์‚ฌ์šฉ์ž) +GET /files/materials-v2 # ์ž์žฌ ๋ชฉ๋ก (์‚ฌ์šฉ์ž) โญ ์ตœ์‹  ๋ฒ„์ „ +GET /files/materials/summary # ์ž์žฌ ์š”์•ฝ ํ†ต๊ณ„ (์‚ฌ์šฉ์ž) +GET /files/materials/compare-revisions # ๋ฆฌ๋น„์ „ ๋น„๊ต (์‚ฌ์šฉ์ž) +GET /files/pipe-details # ํŒŒ์ดํ”„ ์ƒ์„ธ ์ •๋ณด (์‚ฌ์šฉ์ž) +GET /files/fitting-details # ํ”ผํŒ… ์ƒ์„ธ ์ •๋ณด (์‚ฌ์šฉ์ž) +GET /files/valve-details # ๋ฐธ๋ธŒ ์ƒ์„ธ ์ •๋ณด (์‚ฌ์šฉ์ž) +POST /files/user-requirements # ์‚ฌ์šฉ์ž ์š”๊ตฌ์‚ฌํ•ญ ์ƒ์„ฑ (์‚ฌ์šฉ์ž) +GET /files/user-requirements # ์‚ฌ์šฉ์ž ์š”๊ตฌ์‚ฌํ•ญ ์กฐํšŒ (์‚ฌ์šฉ์ž) +POST /files/materials/{id}/verify # ์ž์žฌ ๋ถ„๋ฅ˜ ๊ฒ€์ฆ (์„ค๊ณ„์ž+) +PUT /files/materials/{id}/update-classification # ์ž์žฌ ๋ถ„๋ฅ˜ ์ˆ˜์ • (์„ค๊ณ„์ž+) +POST /files/materials/confirm-purchase # ์ž์žฌ ๊ตฌ๋งค ํ™•์ • (๊ตฌ๋งค์ž+) ``` +**โš ๏ธ ์ค‘์š”: ์ž์žฌ API ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ** +- โœ… **์‚ฌ์šฉ**: `/files/materials-v2` (์ตœ์‹  ๋ฒ„์ „, ๋ชจ๋“  ๊ธฐ๋Šฅ ์ง€์›) +- โŒ **์‚ฌ์šฉ ๊ธˆ์ง€**: `/files/materials` (์กด์žฌํ•˜์ง€ ์•Š์Œ, 404 ์˜ค๋ฅ˜ ๋ฐœ์ƒ) +- ๐Ÿ”„ **๋งˆ์ด๊ทธ๋ ˆ์ด์…˜**: ๋ชจ๋“  ์ปดํฌ๋„ŒํŠธ์—์„œ `fetchMaterials()` ํ•จ์ˆ˜ ์‚ฌ์šฉ ๊ถŒ์žฅ + +#### **๐Ÿ”ง ์ž์žฌ ๋ถ„๋ฅ˜/๋น„๊ต API (`/materials/`)** +```http +POST /materials/compare-revisions # ๋ฆฌ๋น„์ „ ๋น„๊ต (์„ค๊ณ„์ž+) โญ ์‚ฌ์šฉ์ž ์ถ”์  +GET /materials/comparison-history # ๋น„๊ต ์ด๋ ฅ ์กฐํšŒ (์‚ฌ์šฉ์ž) +GET /materials/inventory-status # ์žฌ๊ณ  ํ˜„ํ™ฉ (๊ตฌ๋งค์ž+) +POST /materials/confirm-purchase # ๊ตฌ๋งค ํ™•์ • (๊ตฌ๋งค์ž+) โญ ์‚ฌ์šฉ์ž ์ถ”์  +GET /materials/purchase-status # ๊ตฌ๋งค ์ƒํƒœ (๊ตฌ๋งค์ž+) +``` + +#### **๐Ÿ›’ ๊ตฌ๋งค ๊ด€๋ฆฌ API (`/purchase/`)** +```http +GET /purchase/items/calculate # ๊ตฌ๋งค ํ’ˆ๋ชฉ ๊ณ„์‚ฐ (๊ตฌ๋งค์ž+) +POST /purchase/confirm # ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • (๊ตฌ๋งค์ž+) โญ ์‚ฌ์šฉ์ž ์ถ”์  +POST /purchase/items/save # ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ €์žฅ (๊ตฌ๋งค์ž+) +GET /purchase/items # ๊ตฌ๋งค ํ’ˆ๋ชฉ ๋ชฉ๋ก (๊ตฌ๋งค์ž+) +GET /purchase/revision-diff # ๋ฆฌ๋น„์ „ ์ฐจ์ด (๊ตฌ๋งค์ž+) +POST /purchase/orders/create # ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ (๊ตฌ๋งค์ž+) โญ ์‚ฌ์šฉ์ž ์ถ”์  +GET /purchase/orders # ๊ตฌ๋งค ์ฃผ๋ฌธ ๋ชฉ๋ก (๊ตฌ๋งค์ž+) +``` + +#### **๐Ÿ“Š ๋Œ€์‹œ๋ณด๋“œ API (`/dashboard/`)** โญ ์‹ ๊ทœ (2025.01) +```http +GET /dashboard/stats # ์‚ฌ์šฉ์ž๋ณ„ ๋งž์ถค ํ†ต๊ณ„ (์ธ์ฆ ํ•„์š”) +GET /dashboard/activities # ์‚ฌ์šฉ์ž ํ™œ๋™ ์ด๋ ฅ (์ธ์ฆ ํ•„์š”) +GET /dashboard/recent-activities # ์ „์ฒด ์ตœ๊ทผ ํ™œ๋™ (๋งค๋‹ˆ์ €+) +GET /dashboard/quick-actions # ์—ญํ• ๋ณ„ ๋น ๋ฅธ ์ž‘์—… (์ธ์ฆ ํ•„์š”) +``` + +**์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ:** +```json +// GET /dashboard/stats - ์‚ฌ์šฉ์ž๋ณ„ ๋งž์ถค ํ†ต๊ณ„ +{ + "success": true, + "user_role": "admin", + "stats": { + "total_projects": 45, + "active_users": 12, + "system_status": "์ •์ƒ", + "today_uploads": 8 + // ์ฃผ์˜: quickActions, metrics ๋“ฑ์€ ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ชฉ ๋ฐ์ดํ„ฐ๋กœ ๋ณด์™„๋จ + } +} + +// GET /dashboard/activities - ์‚ฌ์šฉ์ž ํ™œ๋™ ์ด๋ ฅ +{ + "success": true, + "activities": [ + { + "id": 1, + "activity_type": "FILE_UPLOAD", + "activity_description": "ํŒŒ์ผ ์—…๋กœ๋“œ: ProjectX_Rev0.xlsx", + "created_at": "2025-08-30T08:30:00Z", + "target_id": 123, + "target_type": "FILE" + } + ] +} +``` + +#### **๐Ÿ”ง ํŠœ๋น™ ์‹œ์Šคํ…œ API (`/tubing/`)** +```http +GET /tubing/categories # ํŠœ๋น™ ์นดํ…Œ๊ณ ๋ฆฌ (์‚ฌ์šฉ์ž) +GET /tubing/manufacturers # ์ œ์กฐ์‚ฌ ๋ชฉ๋ก (์‚ฌ์šฉ์ž) +GET /tubing/specifications # ์‚ฌ์–‘ ๋ชฉ๋ก (์‚ฌ์šฉ์ž) +GET /tubing/products # ํŠœ๋น™ ์ œํ’ˆ ๋ชฉ๋ก (์‚ฌ์šฉ์ž) +POST /tubing/products # ํŠœ๋น™ ์ œํ’ˆ ์ƒ์„ฑ (์„ค๊ณ„์ž+) +POST /tubing/material-mapping # ์ž์žฌ-ํŠœ๋น™ ๋งคํ•‘ ์ƒ์„ฑ (์„ค๊ณ„์ž+) +GET /tubing/material-mappings/{material_id} # ์ž์žฌ๋ณ„ ํŠœ๋น™ ๋งคํ•‘ ์กฐํšŒ (์‚ฌ์šฉ์ž) +GET /tubing/search # ํŠœ๋น™ ์ œํ’ˆ ๊ฒ€์ƒ‰ (์‚ฌ์šฉ์ž) +``` + +--- + +### ๐Ÿ“ **API ๊ฐœ๋ฐœ ๊ฐ€์ด๋“œ๋ผ์ธ** (2025.01 ์‹ ๊ทœ) + +#### **1. ์ƒˆ API ๋ชจ๋“ˆ ์ถ”๊ฐ€ ์ ˆ์ฐจ** +```python +# 1. ๋ผ์šฐํ„ฐ ํŒŒ์ผ ์ƒ์„ฑ +# backend/app/routers/new_module.py + +from fastapi import APIRouter, Depends, HTTPException +from ..auth.middleware import get_current_user +from ..services.activity_logger import log_activity_from_request + +router = APIRouter(prefix="/new-module", tags=["new-module"]) + +@router.post("/action") +async def new_action( + request: Request, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + # ์‚ฌ์šฉ์ž ์ถ”์  ํ•„์ˆ˜ + log_activity_from_request( + db, request, current_user['username'], + "NEW_ACTION", "์ƒˆ ์•ก์…˜ ์‹คํ–‰" + ) + # ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง... +``` + +```python +# 2. main.py์— ๋ผ์šฐํ„ฐ ๋“ฑ๋ก +try: + from .routers import new_module + app.include_router(new_module.router, tags=["new-module"]) +except ImportError: + logger.warning("new_module ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") +``` + +```markdown +# 3. RULES.md ์—…๋ฐ์ดํŠธ (์ด ๋ฌธ์„œ) +#### **๐Ÿ†• ์ƒˆ ๋ชจ๋“ˆ API (`/new-module/`)** +GET /new-module/list # ๋ชฉ๋ก ์กฐํšŒ (์‚ฌ์šฉ์ž) +POST /new-module/action # ์•ก์…˜ ์‹คํ–‰ (๊ถŒํ•œ) โญ ์‚ฌ์šฉ์ž ์ถ”์  +``` + +#### **2. API ์‘๋‹ต ํ‘œ์ค€ ํ˜•์‹** +```json +// ์„ฑ๊ณต ์‘๋‹ต +{ + "success": true, + "message": "์ž‘์—…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "data": { ... }, + "timestamp": "2025-01-XX 12:00:00" +} + +// ์—๋Ÿฌ ์‘๋‹ต +{ + "success": false, + "error": "์—๋Ÿฌ ๋ฉ”์‹œ์ง€", + "error_code": "ERROR_CODE", + "detail": "์ƒ์„ธ ์—๋Ÿฌ ์ •๋ณด" +} +``` + +#### **2-1. ์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ ๋ฌธ์„œํ™” ๊ทœ์น™** โญ ์ค‘์š” +- **๋ชจ๋“  API๋Š” ์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ๋ฅผ RULES.md์— ๋ช…์‹œ ํ•„์ˆ˜** +- **ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ ์‹œ ์ฐธ์กฐํ•  ์ˆ˜ ์žˆ๋„๋ก JSON ์˜ˆ์‹œ ํฌํ•จ** +- **ํ•„๋“œ๋ช…, ๋ฐ์ดํ„ฐ ํƒ€์ž…, ์ค‘์ฒฉ ๊ตฌ์กฐ ๋ชจ๋‘ ์ •ํ™•ํžˆ ๊ธฐ๋ก** +- **๋ชฉ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ ์‹œ ์ฃผ์„์œผ๋กœ ๋ช…์‹œ** +- **API ๋ณ€๊ฒฝ ์‹œ ๋ฌธ์„œ๋„ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ** + +**๋ฌธ์„œํ™” ์˜ˆ์‹œ:** +```json +// GET /jobs/ - ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก (์‹ค์ œ ์‘๋‹ต) +{ + "success": true, + "total_count": 2, + "jobs": [ + { + "job_no": "J24-001", + "project_name": "ํ”„๋กœ์ ํŠธ๋ช…", + "status": "์ง„ํ–‰์ค‘", + "client_name": "๊ณ ๊ฐ์‚ฌ๋ช…" + // ... ๋ชจ๋“  ํ•„๋“œ ๋ช…์‹œ + } + ] +} +``` + +#### **3. ๊ถŒํ•œ ๋ ˆ๋ฒจ ์ •์˜** +- **๊ณต๊ฐœ**: ์ธ์ฆ ๋ถˆํ•„์š” +- **์‚ฌ์šฉ์ž**: ๋กœ๊ทธ์ธํ•œ ๋ชจ๋“  ์‚ฌ์šฉ์ž +- **์„ค๊ณ„์ž+**: designer, manager, admin +- **๊ตฌ๋งค์ž+**: purchaser, manager, admin +- **๋งค๋‹ˆ์ €+**: manager, admin +- **๊ด€๋ฆฌ์ž**: admin๋งŒ + +#### **4. ์‚ฌ์šฉ์ž ์ถ”์  ํ•„์ˆ˜ API** โญ +๋‹ค์Œ ์ž‘์—…์€ ๋ฐ˜๋“œ์‹œ ํ™œ๋™ ๋กœ๊ทธ๋ฅผ ๊ธฐ๋กํ•ด์•ผ ํ•จ: +- ํŒŒ์ผ ์—…๋กœ๋“œ/์‚ญ์ œ +- ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ +- ๊ตฌ๋งค ํ™•์ •/์ฃผ๋ฌธ ์ƒ์„ฑ +- ์ž์žฌ ๋ถ„๋ฅ˜/๊ฒ€์ฆ +- ์‹œ์Šคํ…œ ์„ค์ • ๋ณ€๊ฒฝ + +#### **5. API ๋ฒ„์ „ ๊ด€๋ฆฌ** +```http +# ํ˜„์žฌ ๋ฒ„์ „ (๊ธฐ๋ณธ) +GET /files/upload + +# ์ƒˆ ๋ฒ„์ „ (ํ•˜์œ„ ํ˜ธํ™˜์„ฑ ์œ ์ง€) +GET /v2/files/upload + +# ํ—ค๋” ๊ธฐ๋ฐ˜ ๋ฒ„์ „ ๊ด€๋ฆฌ +GET /files/upload +Accept: application/vnd.tkmp.v2+json +``` + +#### **6. ์„ฑ๋Šฅ ๊ณ ๋ ค์‚ฌํ•ญ** +- **ํŽ˜์ด์ง€๋„ค์ด์…˜**: ๋ชฉ๋ก API๋Š” limit/offset ์ง€์› +- **ํ•„ํ„ฐ๋ง**: ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํ•„ํ„ฐ ์กฐ๊ฑด ์ œ๊ณต +- **์บ์‹ฑ**: ์ž์ฃผ ์กฐํšŒ๋˜๋Š” ๋ฐ์ดํ„ฐ๋Š” Redis ์บ์‹ฑ +- **๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ**: ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์ฒ˜๋ฆฌ๋Š” ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… + +--- + +### ๐Ÿ”„ **API ๋ณ€๊ฒฝ ์ด๋ ฅ** (2025.01) + +#### **v2.2.0 (2025.09.05)** โญ ์ตœ์‹  +- โœ… **์ •๋ฆฌ**: API ์—”๋“œํฌ์ธํŠธ ํ‘œ์ค€ํ™” ๋ฐ ํ†ตํ•ฉ +- โœ… **๋ฌธ์„œํ™”**: ์ „์ฒด API ๋งต ์—…๋ฐ์ดํŠธ (์‹ค์ œ ๊ตฌํ˜„ ๊ธฐ์ค€) +- โœ… **๊ฐœ์„ **: ํ”„๋ก ํŠธ์—”๋“œ API ํ˜ธ์ถœ ํ‘œ์ค€ํ™” (`fetchMaterials` ํ•จ์ˆ˜ ์‚ฌ์šฉ) +- โœ… **์ˆ˜์ •**: `/files/materials` โ†’ `/files/materials-v2` ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ +- โœ… **์ถ”๊ฐ€**: API ์‚ฌ์šฉ ๊ฐ€์ด๋“œ๋ผ์ธ ๋ฐ ํ˜ผ๋™ ๋ฐฉ์ง€ ๊ทœ์น™ +- โœ… **์ถ”๊ฐ€**: ํŠœ๋น™ ์‹œ์Šคํ…œ API ๋ฌธ์„œํ™” + +#### **v2.1.1 (2025.08.30)** +- โœ… **๋ฌธ์„œํ™”**: `/jobs/` API ์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ ๋ช…์‹œ +- โœ… **๋ฌธ์„œํ™”**: `/dashboard/` API ์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ ๋ช…์‹œ +- โœ… **๊ฐœ์„ **: ํ”„๋ก ํŠธ์—”๋“œ-๋ฐฑ์—”๋“œ API ์‘๋‹ต ๊ตฌ์กฐ ๋ถˆ์ผ์น˜ ํ•ด๊ฒฐ +- โœ… **์ถ”๊ฐ€**: ์‹ค์ œ ์‘๋‹ต ๊ตฌ์กฐ ๋ฌธ์„œํ™” ๊ทœ์น™ ๋ฐ ๊ฐ€์ด๋“œ๋ผ์ธ + +#### **v2.1.0 (2025.01.XX)** +- โœ… **์ถ”๊ฐ€**: `/dashboard/` API ๋ชจ๋“ˆ (์‚ฌ์šฉ์ž๋ณ„ ๋งž์ถค ๋Œ€์‹œ๋ณด๋“œ) +- โœ… **๊ฐœ์„ **: ๋ชจ๋“  ์—…๋กœ๋“œ/์ˆ˜์ • API์— ์‚ฌ์šฉ์ž ์ถ”์  ์ถ”๊ฐ€ +- โœ… **๋ณ€๊ฒฝ**: `/files/upload` - `uploaded_by` ํ•„๋“œ ํ•„์ˆ˜ํ™” +- โš ๏ธ **์ค‘๋‹จ ์˜ˆ์ •**: `/old-endpoint` (v3.0์—์„œ ์ œ๊ฑฐ ์˜ˆ์ •) + +#### **v2.0.0 (2025.01.XX)** +- โœ… **์ถ”๊ฐ€**: ์ธ์ฆ ์‹œ์Šคํ…œ (`/auth/`) ์™„์ „ ๊ตฌํ˜„ +- โœ… **์ถ”๊ฐ€**: ์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ์‹œ์Šคํ…œ +- โœ… **๋ณ€๊ฒฝ**: ๋ชจ๋“  API์— JWT ํ† ํฐ ์ธ์ฆ ์ ์šฉ +- ๐Ÿ”„ **๋งˆ์ด๊ทธ๋ ˆ์ด์…˜**: ๊ธฐ์กด API ํ˜ธ์ถœ ์‹œ Authorization ํ—ค๋” ํ•„์ˆ˜ + +--- + +### ๐Ÿ“‹ **๊ฐœ๋ฐœ์ž ์ฒดํฌ๋ฆฌ์ŠคํŠธ** + +์ƒˆ API ๊ฐœ๋ฐœ ์‹œ ๋‹ค์Œ ์‚ฌํ•ญ์„ ํ™•์ธ: + +- [ ] **๋ฌธ์„œํ™”**: RULES.md API ๋งต์— ์ถ”๊ฐ€ +- [ ] **์ธ์ฆ**: ์ ์ ˆํ•œ ๊ถŒํ•œ ๋ ˆ๋ฒจ ์„ค์ • +- [ ] **์ถ”์ **: ์ค‘์š” ์ž‘์—…์€ ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก +- [ ] **๊ฒ€์ฆ**: ์ž…๋ ฅ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ (Pydantic) +- [ ] **์—๋Ÿฌ**: ํ‘œ์ค€ ์—๋Ÿฌ ์‘๋‹ต ํ˜•์‹ ์ค€์ˆ˜ +- [ ] **ํ…Œ์ŠคํŠธ**: ๋‹จ์œ„/ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ +- [ ] **๋กœ๊น…**: ์ ์ ˆํ•œ ๋กœ๊ทธ ๋ ˆ๋ฒจ๋กœ ๊ธฐ๋ก +- [ ] **์„ฑ๋Šฅ**: ๋Œ€์šฉ๋Ÿ‰ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๊ณ ๋ ค + ### ๐Ÿ“Š ๋ฐ์ดํ„ฐ ํ๋ฆ„๋„ ```mermaid @@ -392,11 +760,74 @@ const totalLength = quantity * unitLength; // ์ด ๊ธธ์ด = ์ˆ˜๋Ÿ‰ ร— ๋‹จ์œ„๊ธธ material_hash = hashlib.md5(f"{description}|{size_spec}|{material_grade}".encode()).hexdigest() ``` -### 4. ๋ฆฌ๋น„์ „ ๋น„๊ต ๋กœ์ง +### 4. ๋ฆฌ๋น„์ „ ๋น„๊ต ๋กœ์ง (2025.01 ์‹ ๊ทœ โญ) ```python # ์ด์ „ ๋ฆฌ๋น„์ „ ์ž๋™ ํƒ์ง€: ์ˆซ์ž ๊ธฐ๋ฐ˜ ๋น„๊ต current_rev_num = int(current_revision.replace("Rev.", "")) # Rev.0 โ†’ Rev.1 โ†’ Rev.2 ์ˆœ์„œ + +# ์ž์žฌ ํ•ด์‹ฑ ๊ทœ์น™ (RULES ์ค€์ˆ˜) +material_hash = hashlib.md5(f"{description}|{size}|{material}".encode()).hexdigest() + +# ๋ฆฌ๋น„์ „ ๋น„๊ต ์›Œํฌํ”Œ๋กœ์šฐ +if revision != "Rev.0": # ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ์ธ ๊ฒฝ์šฐ๋งŒ + revision_comparison = get_revision_comparison(db, job_no, revision, materials_data) + + if revision_comparison.get("has_previous_confirmation"): + # ๋ณ€๊ฒฝ์—†์Œ: ๊ธฐ์กด ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ์žฌ์‚ฌ์šฉ (confidence = 1.0) + # ๋ณ€๊ฒฝ๋จ + ์‹ ๊ทœ: ์žฌ๋ถ„๋ฅ˜ ํ•„์š” + materials_to_classify = changed_materials + new_materials + else: + # ์ด์ „ ํ™•์ • ์ž๋ฃŒ ์—†์Œ: ์ „์ฒด ๋ถ„๋ฅ˜ + materials_to_classify = all_materials +``` + +### 5. ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • ์›Œํฌํ”Œ๋กœ์šฐ (2025.01 ์‹ ๊ทœ โญ) +```python +# ํ™•์ • ๋ฐ์ดํ„ฐ ์ €์žฅ ๊ตฌ์กฐ +purchase_confirmations (๋งˆ์Šคํ„ฐ) โ†’ confirmed_purchase_items (์ƒ์„ธ) + +# ํ™•์ • ์‹œ ํŒŒ์ผ ์ƒํƒœ ์—…๋ฐ์ดํŠธ +files.purchase_confirmed = TRUE +files.confirmed_at = timestamp +files.confirmed_by = username + +# ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ์‹œ ์ตœ์ ํ™” +- ํ™•์ •๋œ ์ž๋ฃŒ ์žˆ์Œ: ๋ณ€๊ฒฝ๋œ ์ž์žฌ๋งŒ ๋ถ„๋ฅ˜ (์„ฑ๋Šฅ ํ–ฅ์ƒ) +- ํ™•์ •๋œ ์ž๋ฃŒ ์—†์Œ: ์ „์ฒด ์ž์žฌ ๋ถ„๋ฅ˜ (๊ธฐ์กด ๋ฐฉ์‹) +``` + +### 6. ๋Œ€์šฉ๋Ÿ‰ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๊ทœ์น™ (2025.01 ์‹ ๊ทœ โญ) +```python +# 413 ์˜ค๋ฅ˜ ๋ฐฉ์ง€: ์š”์ฒญ ๋ฐ์ดํ„ฐ ์ตœ์ ํ™” +# โŒ ์ „์ฒด ๋ฐ์ดํ„ฐ ์ „์†ก (์šฉ๋Ÿ‰ ์ดˆ๊ณผ) +purchase_items: List[dict] # ๋ชจ๋“  ํ•„๋“œ ํฌํ•จ + +# โœ… ํ•„์ˆ˜ ํ•„๋“œ๋งŒ ์ „์†ก (์šฉ๋Ÿ‰ ์ตœ์ ํ™”) +class PurchaseItemMinimal(BaseModel): + item_code: str + category: str + specification: str + size: str = "" + material: str = "" + bom_quantity: float + calculated_qty: float + unit: str = "EA" + safety_factor: float = 1.0 + +# ์„œ๋ฒ„ ์š”์ฒญ ํฌ๊ธฐ ์ œํ•œ ์„ค์ • +app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024) # 100MB + +# Nginx ํ”„๋ก์‹œ ์„ค์ • (์ค‘์š”!) +server { + client_max_body_size 100M; # ์ „์—ญ ์„ค์ • + + location /api/ { + proxy_pass http://backend:8000/; + client_max_body_size 100M; # API ๊ฒฝ๋กœ๋ณ„ ์„ค์ • + proxy_request_buffering off; # ๋Œ€์šฉ๋Ÿ‰ ์š”์ฒญ ์ตœ์ ํ™” + } +} ``` --- @@ -581,32 +1012,108 @@ navigate(`/bom-status?job_no=${jobNo}`); navigate(`/material-comparison?job_no=${jobNo}&revision=${revision}`); ``` +### 4. ํ”„๋กœ์ ํŠธ ์ค‘์‹ฌ ์›Œํฌํ”Œ๋กœ์šฐ (2025.01 ์‹ ๊ทœ) โญ + +#### **๊ธฐ๋ณธ ์›์น™** +- **ํ”„๋กœ์ ํŠธ ์šฐ์„ **: ์‚ฌ์šฉ์ž๋Š” ๋จผ์ € ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜๊ณ , ๊ทธ ๋‹ค์Œ์— ์—…๋ฌด๋ฅผ ์„ ํƒ +- **์ปจํ…์ŠคํŠธ ์œ ์ง€**: ์„ ํƒ๋œ ํ”„๋กœ์ ํŠธ ์ •๋ณด๋Š” ๋ชจ๋“  ํ•˜์œ„ ํŽ˜์ด์ง€์—์„œ ์œ ์ง€ +- **๊ถŒํ•œ ๊ธฐ๋ฐ˜ ๋ฉ”๋‰ด**: ์‚ฌ์šฉ์ž ์—ญํ• ์— ๋”ฐ๋ผ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—…๋ฌด๋งŒ ํ‘œ์‹œ + +#### **์›Œํฌํ”Œ๋กœ์šฐ ๊ตฌ์กฐ** +``` +๋ฉ”์ธ ๋Œ€์‹œ๋ณด๋“œ โ†’ ํ”„๋กœ์ ํŠธ ์„ ํƒ โ†’ ํ”„๋กœ์ ํŠธ ์›Œํฌ์ŠคํŽ˜์ด์Šค โ†’ ์—…๋ฌด ์ง„ํ–‰ +``` + +#### **์ฃผ์š” ์ปดํฌ๋„ŒํŠธ** + +**1. ProjectSelector (ํ”„๋กœ์ ํŠธ ์„ ํƒ๊ธฐ)** +- ๋“œ๋กญ๋‹ค์šด ํ˜•ํƒœ์˜ ํ”„๋กœ์ ํŠธ ์„ ํƒ UI +- ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ ์ง€์› (ํ”„๋กœ์ ํŠธ๋ช…, Job ๋ฒˆํ˜ธ) +- ์ง„ํ–‰๋ฅ  ํ‘œ์‹œ ๋ฐ ์ƒํƒœ ํ‘œ์‹œ +- ์„ ํƒ๋œ ํ”„๋กœ์ ํŠธ ์ •๋ณด ํ•˜์ด๋ผ์ดํŠธ + +**2. ProjectWorkspacePage (ํ”„๋กœ์ ํŠธ ์›Œํฌ์ŠคํŽ˜์ด์Šค)** +- ํ”„๋กœ์ ํŠธ๋ณ„ ๋งž์ถค ๋Œ€์‹œ๋ณด๋“œ +- ๊ถŒํ•œ ๊ธฐ๋ฐ˜ ์—…๋ฌด ๋ฉ”๋‰ด (์นด๋“œ ํ˜•ํƒœ) +- ํ”„๋กœ์ ํŠธ ํ†ต๊ณ„ ๋ฐ ์ตœ๊ทผ ํ™œ๋™ ํ‘œ์‹œ +- ๋น ๋ฅธ ์ž‘์—… ๋ฒ„ํŠผ + +**3. ๊ถŒํ•œ๋ณ„ ์—…๋ฌด ๋ฉ”๋‰ด** +```javascript +// ์„ค๊ณ„์ž ์—…๋ฌด +- BOM ํŒŒ์ผ ์—…๋กœ๋“œ +- BOM ๊ด€๋ฆฌ +- ์ž์žฌ ๋ถ„๋ฅ˜ ๊ฒ€์ฆ + +// ๊ตฌ๋งค์ž ์—…๋ฌด +- ๊ตฌ๋งค ๊ด€๋ฆฌ +- ๋ฆฌ๋น„์ „ ๋น„๊ต +- ๊ตฌ๋งค ํ™•์ • + +// ๊ณตํ†ต ์—…๋ฌด +- ํ”„๋กœ์ ํŠธ ํ˜„ํ™ฉ +- ๋ฆฌํฌํŠธ ์ƒ์„ฑ +``` + +#### **์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„ ์‚ฌํ•ญ** +- **์ง๊ด€์  ํ๋ฆ„**: ์‹ค์ œ ์—…๋ฌด ํ๋ฆ„๊ณผ ์ผ์น˜ํ•˜๋Š” ๋„ค๋น„๊ฒŒ์ด์…˜ +- **์ปจํ…์ŠคํŠธ ์ธ์‹**: ํ”„๋กœ์ ํŠธ ์ •๋ณด๊ฐ€ ์ž๋™์œผ๋กœ ์ „๋‹ฌ๋จ +- **ํšจ์œจ์„ฑ ์ฆ๋Œ€**: ๋ถˆํ•„์š”ํ•œ ํ”„๋กœ์ ํŠธ ์„ ํƒ ๋‹จ๊ณ„ ์ œ๊ฑฐ +- **์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ**: ์„ ํƒ๋œ ํ”„๋กœ์ ํŠธ์™€ ์ง„ํ–‰๋ฅ  ์‹œ๊ฐํ™” + --- ## ๐Ÿ”„ ๊ฐœ๋ฐœ ์›Œํฌํ”Œ๋กœ์šฐ -### 1. ์„œ๋ฒ„ ์‹คํ–‰ ๋ช…๋ น์–ด +### โญ 1. ๋„์ปค ์‹คํ–‰ (๊ถŒ์žฅ - ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ๊ณผ ๋™์ผ) +```bash +# TK-MP-Project ๋ฃจํŠธ ๋””๋ ‰ํ† ๋ฆฌ์—์„œ ์‹คํ–‰ +docker-compose up -d + +# ๋กœ๊ทธ ํ™•์ธ +docker-compose logs -f + +# ์„œ๋น„์Šค ์žฌ์‹œ์ž‘ (์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹œ) +docker-compose restart + +# ์™„์ „ ์žฌ๋นŒ๋“œ (Dockerfile ๋ณ€๊ฒฝ ์‹œ) +docker-compose down +docker-compose up --build -d +``` + +**๋„์ปค ์ ‘์† ์ฃผ์†Œ:** +- ํ”„๋ก ํŠธ์—”๋“œ: http://localhost:13000 +- ๋ฐฑ์—”๋“œ API: http://localhost:18000 +- API ๋ฌธ์„œ: http://localhost:18000/docs +- PostgreSQL: localhost:5432 +- Redis: localhost:6379 +- pgAdmin: http://localhost:5050 + +### 2. ๋กœ์ปฌ ๊ฐœ๋ฐœ ์‹คํ–‰ (๊ฐœ๋ฐœ/๋””๋ฒ„๊น… ์ „์šฉ) ```bash # ๋ฐฑ์—”๋“œ ์‹คํ–‰ (ํ„ฐ๋ฏธ๋„ 1๋ฒˆ) - TK-MP-Project ๋ฃจํŠธ์—์„œ source venv/bin/activate # ๊ฐ€์ƒํ™˜๊ฒฝ ํ™œ์„ฑํ™” (venv๋Š” ๋ฃจํŠธ์— ์žˆ์Œ) cd backend -python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 18000 # ํ”„๋ก ํŠธ์—”๋“œ ์‹คํ–‰ (ํ„ฐ๋ฏธ๋„ 2๋ฒˆ) - TK-MP-Project ๋ฃจํŠธ์—์„œ cd frontend npm run dev # npm start ์•„๋‹˜! ``` -**์ ‘์† ์ฃผ์†Œ:** -- ๋ฐฑ์—”๋“œ API: http://localhost:8000 -- API ๋ฌธ์„œ: http://localhost:8000/docs -- ํ”„๋ก ํŠธ์—”๋“œ: http://localhost:5173 +**๋กœ์ปฌ ๊ฐœ๋ฐœ ์ ‘์† ์ฃผ์†Œ:** +- ๋ฐฑ์—”๋“œ API: http://localhost:18000 +- API ๋ฌธ์„œ: http://localhost:18000/docs +- ํ”„๋ก ํŠธ์—”๋“œ: http://localhost:13000 (ํฌํŠธ ์ถฉ๋Œ ์‹œ ์ž๋™ ๋ณ€๊ฒฝ๋จ) -### 2. ๋ฐฑ์—”๋“œ ๋ณ€๊ฒฝ ์‹œ +### 3. ๋ฐฑ์—”๋“œ ๋ณ€๊ฒฝ ์‹œ ```bash -# ํ•ญ์ƒ ๊ฐ€์ƒํ™˜๊ฒฝ์—์„œ ์‹คํ–‰ (์‚ฌ์šฉ์ž ์„ ํ˜ธ์‚ฌํ•ญ) +# ๋„์ปค ํ™˜๊ฒฝ (๊ถŒ์žฅ) +docker-compose restart backend + +# ๋กœ์ปฌ ํ™˜๊ฒฝ (๋””๋ฒ„๊น…์šฉ) cd backend -python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 18000 ``` ### 3. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ ์‹œ @@ -621,6 +1128,18 @@ python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ์˜ˆ: "ํŒŒ์ดํ”„ ๊ธธ์ด ๊ณ„์‚ฐ ๋ฐ ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„๊ทธ ์ˆ˜์ •" ``` +### โš ๏ธ ์ค‘์š”: ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์„ ํƒ ๊ฐ€์ด๋“œ + +#### ๐Ÿณ ๋„์ปค ํ™˜๊ฒฝ (๊ถŒ์žฅ) +- **์‚ฌ์šฉ ์‹œ๊ธฐ**: ์ผ๋ฐ˜์ ์ธ ๊ฐœ๋ฐœ, ํ…Œ์ŠคํŠธ, ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ +- **์žฅ์ **: ํ™˜๊ฒฝ ์ผ๊ด€์„ฑ, NAS ๋ฐฐํฌ์™€ ๋™์ผํ•œ ํ™˜๊ฒฝ +- **๋‹จ์ **: ๋””๋ฒ„๊น…์ด ์•ฝ๊ฐ„ ๋ณต์žก + +#### ๐Ÿ’ป ๋กœ์ปฌ ํ™˜๊ฒฝ (์ œํ•œ์  ์‚ฌ์šฉ) +- **์‚ฌ์šฉ ์‹œ๊ธฐ**: ๋ฐฑ์—”๋“œ ๋””๋ฒ„๊น…, ์ƒˆ๋กœ์šด ํŒจํ‚ค์ง€ ํ…Œ์ŠคํŠธ +- **์žฅ์ **: ๋น ๋ฅธ ๋””๋ฒ„๊น…, IDE ํ†ตํ•ฉ +- **๋‹จ์ **: ํ™˜๊ฒฝ ์ฐจ์ด๋กœ ์ธํ•œ ๋ฐฐํฌ ๋ฌธ์ œ ๊ฐ€๋Šฅ์„ฑ + --- ## ๐Ÿ’ฐ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ทœ์น™ @@ -747,25 +1266,48 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5; --- -## ๐ŸŒ ์‹œ๋†€๋กœ์ง€ DSM ๋ฐฐํฌ ๊ฐ€์ด๋“œ +## ๐ŸŒ ์‹œ๋†€๋กœ์ง€ NAS ๋ฐฐํฌ ๊ฐ€์ด๋“œ โญ -### ์„œ๋น„์Šค ๊ตฌ์„ฑ -- **ํ”„๋ก ํŠธ์—”๋“œ**: React + Vite (ํฌํŠธ 10173) -- **๋ฐฑ์—”๋“œ**: FastAPI (ํฌํŠธ 10080) -- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL (ํฌํŠธ 15432) -- **์บ์‹œ**: Redis (ํฌํŠธ 16379) +### ๐Ÿณ ๋„์ปค ๊ธฐ๋ฐ˜ ๋ฐฐํฌ (๊ถŒ์žฅ) -### ์ž๋™ ๋ฐฐํฌ (๊ถŒ์žฅ) +#### ์„œ๋น„์Šค ๊ตฌ์„ฑ +- **ํ”„๋ก ํŠธ์—”๋“œ**: React + Nginx (ํฌํŠธ 13000) +- **๋ฐฑ์—”๋“œ**: FastAPI + Uvicorn (ํฌํŠธ 18000) +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL (ํฌํŠธ 5432) +- **์บ์‹œ**: Redis (ํฌํŠธ 6379) +- **๊ด€๋ฆฌ๋„๊ตฌ**: pgAdmin4 (ํฌํŠธ 5050) + +#### ๋ฐฐํฌ ๋ช…๋ น์–ด ```bash -./deploy-synology.sh 192.168.0.3 +# 1. ํ”„๋กœ์ ํŠธ ํŒŒ์ผ์„ NAS๋กœ ๋ณต์‚ฌ +scp -r TK-MP-Project/ admin@[NAS_IP]:/volume1/docker/ + +# 2. NAS SSH ์ ‘์† +ssh admin@[NAS_IP] + +# 3. ํ”„๋กœ์ ํŠธ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์ด๋™ +cd /volume1/docker/TK-MP-Project/ + +# 4. ๋„์ปค ์ปดํฌ์ฆˆ ์‹คํ–‰ +docker-compose up -d + +# 5. ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +docker-compose ps ``` -### ์ ‘์† ํ™•์ธ -- ํ”„๋ก ํŠธ์—”๋“œ: http://192.168.0.3:10173 -- ๋ฐฑ์—”๋“œ API ๋ฌธ์„œ: http://192.168.0.3:10080/docs +#### ์ ‘์† ์ฃผ์†Œ (NAS IP ๊ธฐ์ค€) +- **ํ”„๋ก ํŠธ์—”๋“œ**: http://[NAS_IP]:13000 +- **๋ฐฑ์—”๋“œ API**: http://[NAS_IP]:18000 +- **API ๋ฌธ์„œ**: http://[NAS_IP]:18000/docs +- **pgAdmin**: http://[NAS_IP]:5050 + +#### ์ž๋™ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •) +```bash +./deploy-synology.sh [NAS_IP] +``` ### ์ฃผ์˜์‚ฌํ•ญ -1. **ํฌํŠธ ์ถฉ๋Œ**: ์‹œ๋†€๋กœ์ง€์—์„œ 10080, 10173 ํฌํŠธ๊ฐ€ ์‚ฌ์šฉ ์ค‘์ด์ง€ ์•Š์€์ง€ ํ™•์ธ +1. **ํฌํŠธ ์ถฉ๋Œ**: NAS์—์„œ 13000, 18000 ํฌํŠธ๊ฐ€ ์‚ฌ์šฉ ์ค‘์ด์ง€ ์•Š์€์ง€ ํ™•์ธ 2. **๊ถŒํ•œ**: Docker ๋ช…๋ น์–ด๋Š” `sudo` ๊ถŒํ•œ ํ•„์š” 3. **๋ฐฉํ™”๋ฒฝ**: DSM ์ œ์–ดํŒ์—์„œ ํ•ด๋‹น ํฌํŠธ ํ—ˆ์šฉ ์„ค์ • 4. **๋ฆฌ์†Œ์Šค**: ๋ฐฑ์—”๋“œ ๋นŒ๋“œ ์‹œ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ @@ -1142,4 +1684,324 @@ RUN apt-get update && apt-get install -y \ --- -**๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ**: 2025๋…„ 1์›” (์ฝ”๋“œ ๊ตฌ์กฐ ์ •๋ฆฌ ๋ฐ ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ ์™„๋ฃŒ) +## ๐Ÿ“Š **์‚ฌ์šฉ์ž ์ถ”์  ๋ฐ ๋‹ด๋‹น์ž ๊ธฐ๋ก ๊ฐ€์ด๋“œ๋ผ์ธ** (2025.01 ์‹ ๊ทœ) + +### ๐ŸŽฏ **๊ธฐ๋ณธ ์›์น™** +- **๋ชจ๋“  ์—…๋ฌด ํ™œ๋™์€ ๋‹ด๋‹น์ž๊ฐ€ ๊ธฐ๋ก๋˜์–ด์•ผ ํ•จ** +- **์ถ”์  ๊ฐ€๋Šฅํ•œ ์—…๋ฌด ์ด๋ ฅ ๊ด€๋ฆฌ** +- **๊ฐœ์ธ๋ณ„ ๋งž์ถคํ˜• ๋Œ€์‹œ๋ณด๋“œ ์ œ๊ณต** +- **๊ถŒํ•œ๋ณ„ ์ฐจ๋ณ„ํ™”๋œ ์ •๋ณด ํ‘œ์‹œ** + +### ๐Ÿ“‹ **ํ•„์ˆ˜ ๊ธฐ๋ก ๋Œ€์ƒ** + +#### **1. ํŒŒ์ผ ๊ด€๋ฆฌ** +```sql +-- ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํ•„์ˆ˜ ๊ธฐ๋ก +uploaded_by VARCHAR(100) NOT NULL, -- ์—…๋กœ๋“œํ•œ ์‚ฌ์šฉ์ž +upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +updated_by VARCHAR(100), -- ์ˆ˜์ •ํ•œ ์‚ฌ์šฉ์ž (ํŒŒ์ผ ์ˆ˜์ • ์‹œ) +updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +``` + +#### **2. ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ** +```sql +-- ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ/์ˆ˜์ • ์‹œ ํ•„์ˆ˜ ๊ธฐ๋ก +created_by VARCHAR(100) NOT NULL, -- ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ์ž +created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +updated_by VARCHAR(100), -- ๋งˆ์ง€๋ง‰ ์ˆ˜์ •์ž +updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +assigned_to VARCHAR(100), -- ํ”„๋กœ์ ํŠธ ๋‹ด๋‹น์ž +``` + +#### **3. ์ž์žฌ ๊ด€๋ฆฌ** +```sql +-- ์ž์žฌ ๋ถ„๋ฅ˜/๊ฒ€์ฆ ์‹œ ํ•„์ˆ˜ ๊ธฐ๋ก +classified_by VARCHAR(100), -- ์ž์žฌ ๋ถ„๋ฅ˜ ๋‹ด๋‹น์ž +classified_at TIMESTAMP, +verified_by VARCHAR(100), -- ๊ฒ€์ฆ ๋‹ด๋‹น์ž +verified_at TIMESTAMP, +updated_by VARCHAR(100), -- ์ˆ˜์ • ๋‹ด๋‹น์ž +updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +``` + +#### **4. ๊ตฌ๋งค ๊ด€๋ฆฌ** +```sql +-- ๊ตฌ๋งค ํ™•์ •/๋ฐœ์ฃผ ์‹œ ํ•„์ˆ˜ ๊ธฐ๋ก +confirmed_by VARCHAR(100) NOT NULL, -- ๊ตฌ๋งค ํ™•์ •์ž +confirmed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +ordered_by VARCHAR(100), -- ๋ฐœ์ฃผ ๋‹ด๋‹น์ž +ordered_at TIMESTAMP, +approved_by VARCHAR(100), -- ์Šน์ธ์ž (๊ณ ์•ก ๊ตฌ๋งค ์‹œ) +approved_at TIMESTAMP +``` + +### ๐Ÿ” **๊ถŒํ•œ๋ณ„ ์ ‘๊ทผ ์ œ์–ด** + +#### **๊ด€๋ฆฌ์ž (admin)** +- ๋ชจ๋“  ํ”„๋กœ์ ํŠธ ์กฐํšŒ/์ˆ˜์ • ๊ฐ€๋Šฅ +- ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ ๋ฐ ๊ถŒํ•œ ์„ค์ • +- ์‹œ์Šคํ…œ ์„ค์ • ๋ฐ ๋ฐฑ์—… ๊ด€๋ฆฌ +- ์ „์ฒด ํ™œ๋™ ๋กœ๊ทธ ์กฐํšŒ + +#### **ํ”„๋กœ์ ํŠธ ๋งค๋‹ˆ์ € (manager)** +- ๋‹ด๋‹น ํ”„๋กœ์ ํŠธ ์ „์ฒด ๊ด€๋ฆฌ +- ํŒ€์› ์—…๋ฌด ํ• ๋‹น ๋ฐ ์ง„ํ–‰ ์ƒํ™ฉ ๋ชจ๋‹ˆํ„ฐ๋ง +- ๊ตฌ๋งค ์Šน์ธ ๊ถŒํ•œ (์ผ์ • ๊ธˆ์•ก ์ดํ•˜) +- ํ”„๋กœ์ ํŠธ๋ณ„ ๋ฆฌํฌํŠธ ์ƒ์„ฑ + +#### **์„ค๊ณ„ ๋‹ด๋‹น์ž (designer)** +- ๋‹ด๋‹น ํ”„๋กœ์ ํŠธ BOM ์—…๋กœ๋“œ/์ˆ˜์ • +- ์ž์žฌ ๋ถ„๋ฅ˜ ๋ฐ ๊ฒ€์ฆ +- ๋ฆฌ๋น„์ „ ๊ด€๋ฆฌ +- ๊ตฌ๋งค ์š”์ฒญ์„œ ์ž‘์„ฑ + +#### **๊ตฌ๋งค ๋‹ด๋‹น์ž (purchaser)** +- ๊ตฌ๋งค ํ’ˆ๋ชฉ ์กฐํšŒ ๋ฐ ๋ฐœ์ฃผ +- ๊ณต๊ธ‰์—…์ฒด ๊ด€๋ฆฌ +- ๊ตฌ๋งค ํ˜„ํ™ฉ ์ถ”์  +- ์ž…๊ณ  ๊ด€๋ฆฌ + +#### **์กฐํšŒ ์ „์šฉ (viewer)** +- ํ• ๋‹น๋œ ํ”„๋กœ์ ํŠธ ์กฐํšŒ๋งŒ ๊ฐ€๋Šฅ +- ๋ฆฌํฌํŠธ ๋‹ค์šด๋กœ๋“œ +- ์ง„ํ–‰ ์ƒํ™ฉ ํ™•์ธ + +### ๐Ÿ“ˆ **๊ฐœ์ธ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ ๊ตฌ์„ฑ** + +#### **1. ๋งž์ถคํ˜• ๋ฐฐ๋„ˆ ์‹œ์Šคํ…œ** +```javascript +// ์‚ฌ์šฉ์ž๋ณ„ ๋งž์ถค ์ •๋ณด ํ‘œ์‹œ +const personalizedBanner = { + admin: { + title: "์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž", + metrics: ["์ „์ฒด ํ”„๋กœ์ ํŠธ ์ˆ˜", "ํ™œ์„ฑ ์‚ฌ์šฉ์ž ์ˆ˜", "์‹œ์Šคํ…œ ์ƒํƒœ"], + quickActions: ["์‚ฌ์šฉ์ž ๊ด€๋ฆฌ", "์‹œ์Šคํ…œ ์„ค์ •", "๋ฐฑ์—… ๊ด€๋ฆฌ"] + }, + manager: { + title: "ํ”„๋กœ์ ํŠธ ๋งค๋‹ˆ์ €", + metrics: ["๋‹ด๋‹น ํ”„๋กœ์ ํŠธ", "ํŒ€ ์ง„ํ–‰๋ฅ ", "์Šน์ธ ๋Œ€๊ธฐ"], + quickActions: ["ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ", "ํŒ€ ๊ด€๋ฆฌ", "์ง„ํ–‰ ์ƒํ™ฉ"] + }, + designer: { + title: "์„ค๊ณ„ ๋‹ด๋‹น์ž", + metrics: ["๋‚ด BOM ํŒŒ์ผ", "๋ถ„๋ฅ˜ ์™„๋ฃŒ์œจ", "๊ฒ€์ฆ ๋Œ€๊ธฐ"], + quickActions: ["BOM ์—…๋กœ๋“œ", "์ž์žฌ ๋ถ„๋ฅ˜", "๋ฆฌ๋น„์ „ ๊ด€๋ฆฌ"] + }, + purchaser: { + title: "๊ตฌ๋งค ๋‹ด๋‹น์ž", + metrics: ["๊ตฌ๋งค ์š”์ฒญ", "๋ฐœ์ฃผ ์™„๋ฃŒ", "์ž…๊ณ  ๋Œ€๊ธฐ"], + quickActions: ["๊ตฌ๋งค ํ™•์ •", "๋ฐœ์ฃผ ๊ด€๋ฆฌ", "๊ณต๊ธ‰์—…์ฒด"] + } +}; +``` + +#### **2. ํ™œ๋™ ์ด๋ ฅ ์ถ”์ ** +```sql +-- ์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ํ…Œ์ด๋ธ” +CREATE TABLE user_activity_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(user_id), + username VARCHAR(100) NOT NULL, + activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' ๋“ฑ + activity_description TEXT, -- ์ƒ์„ธ ํ™œ๋™ ๋‚ด์šฉ + target_id INTEGER, -- ๋Œ€์ƒ ID (ํŒŒ์ผ, ํ”„๋กœ์ ํŠธ ๋“ฑ) + target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL' ๋“ฑ + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +#### **3. ๊ฐœ์ธ ์ž‘์—… ํ˜„ํ™ฉ** +- **๋‚ด๊ฐ€ ์—…๋กœ๋“œํ•œ ํŒŒ์ผ**: ์ตœ๊ทผ ์—…๋กœ๋“œํ•œ BOM ํŒŒ์ผ ๋ชฉ๋ก +- **๋‚ด๊ฐ€ ๋‹ด๋‹นํ•œ ํ”„๋กœ์ ํŠธ**: ํ• ๋‹น๋œ ํ”„๋กœ์ ํŠธ ์ง„ํ–‰ ์ƒํ™ฉ +- **๋‚ด ์—…๋ฌด ๋Œ€๊ธฐ**: ๋ถ„๋ฅ˜/๊ฒ€์ฆ/์Šน์ธ ๋Œ€๊ธฐ ์ค‘์ธ ์—…๋ฌด +- **์ตœ๊ทผ ํ™œ๋™**: ์ตœ๊ทผ 7์ผ๊ฐ„ ํ™œ๋™ ์š”์•ฝ + +### ๐Ÿš€ **๊ตฌํ˜„ ์šฐ์„ ์ˆœ์œ„** + +#### **Phase 1: ๊ธฐ๋ณธ ์‚ฌ์šฉ์ž ์ถ”์ ** (1์ฃผ) +1. ํ˜„์žฌ ํ…Œ์ด๋ธ”์— ๋‹ด๋‹น์ž ํ•„๋“œ ์ถ”๊ฐ€ +2. ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ธฐ๋ก +3. ๊ธฐ๋ณธ ํ™œ๋™ ๋กœ๊ทธ ์‹œ์Šคํ…œ ๊ตฌ์ถ• + +#### **Phase 2: ๊ฐœ์ธ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ** (2์ฃผ) +1. ๊ถŒํ•œ๋ณ„ ๋งž์ถคํ˜• ๋ฐฐ๋„ˆ ๊ตฌํ˜„ +2. ๊ฐœ์ธ ์ž‘์—… ํ˜„ํ™ฉ ํŽ˜์ด์ง€ +3. ํ™œ๋™ ์ด๋ ฅ ์กฐํšŒ ๊ธฐ๋Šฅ + +#### **Phase 3: ๊ณ ๋„ํ™”** (3์ฃผ) +1. ์ƒ์„ธ ๊ถŒํ•œ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +2. ํŒ€๋ณ„/๋ถ€์„œ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ +3. ์—…๋ฌด ํ• ๋‹น ๋ฐ ์•Œ๋ฆผ ์‹œ์Šคํ…œ + +### โš ๏ธ **์ฃผ์˜์‚ฌํ•ญ** +- **๊ฐœ์ธ์ •๋ณด ๋ณดํ˜ธ**: ์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ๋Š” ์—…๋ฌด ๋ชฉ์ ์œผ๋กœ๋งŒ ์‚ฌ์šฉ +- **๋ฐ์ดํ„ฐ ๋ณด์กด**: ํ™œ๋™ ๋กœ๊ทธ๋Š” ์ตœ๋Œ€ 1๋…„๊ฐ„ ๋ณด์กด ํ›„ ์ž๋™ ์‚ญ์ œ +- **์ ‘๊ทผ ๊ถŒํ•œ**: ๊ฐœ์ธ ํ™œ๋™ ์ด๋ ฅ์€ ๋ณธ์ธ๊ณผ ๊ด€๋ฆฌ์ž๋งŒ ์กฐํšŒ ๊ฐ€๋Šฅ +- **๊ฐ์‚ฌ ์ถ”์ **: ์ค‘์š” ์—…๋ฌด(๊ตฌ๋งค ํ™•์ •, ํ”„๋กœ์ ํŠธ ์‚ญ์ œ ๋“ฑ)๋Š” ๋ณ„๋„ ๊ฐ์‚ฌ ๋กœ๊ทธ ์œ ์ง€ + +--- + +## ๐Ÿšจ **ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๋ณด์•ˆ ์ฒดํฌ๋ฆฌ์ŠคํŠธ** (2025.01 ์‹ ๊ทœ) + +> โš ๏ธ **์ค‘์š”**: ํ˜„์žฌ ์„ค์ •์€ **ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์šฉ**์ž…๋‹ˆ๋‹ค. ์‹ค์ œ ์„œ๋น„์Šค ๋ฐฐํฌ ์ „ ๋ฐ˜๋“œ์‹œ ์•„๋ž˜ ํ•ญ๋ชฉ๋“ค์„ ์ˆ˜์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +### ๐Ÿ” **Critical Security Items (๋ฐฐํฌ ์ „ ํ•„์ˆ˜)** + +#### **1. JWT ์‹œํฌ๋ฆฟ ํ‚ค ํ™˜๊ฒฝ๋ณ€์ˆ˜ํ™”** +```bash +# โŒ ํ˜„์žฌ (ํ…Œ์ŠคํŠธ์šฉ) +SECRET_KEY = "test-secret-key" + +# โœ… ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๋ณ€๊ฒฝ +JWT_SECRET_KEY=your-super-secure-random-key-here # .env ํŒŒ์ผ์— ์ถ”๊ฐ€ +``` + +#### **2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณด์•ˆ** +```yaml +# โŒ ํ˜„์žฌ (ํ…Œ์ŠคํŠธ์šฉ) +POSTGRES_PASSWORD: tkmp_password_2025 + +# โœ… ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๋ณ€๊ฒฝ +POSTGRES_PASSWORD: ${DB_PASSWORD} # ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๋ถ„๋ฆฌ +``` + +#### **3. CORS ๋„๋ฉ”์ธ ์„ค์ •** +```python +# โŒ ํ˜„์žฌ (ํ…Œ์ŠคํŠธ์šฉ) +"production": [ + "https://your-domain.com", + "https://api.your-domain.com" +] + +# โœ… ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๋ณ€๊ฒฝ +"production": [ + "https://์‹ค์ œ๋„๋ฉ”์ธ.com", + "https://api.์‹ค์ œ๋„๋ฉ”์ธ.com" +] +``` + +#### **4. ๊ธฐ๋ณธ ๊ด€๋ฆฌ์ž ๊ณ„์ • ๋ณ€๊ฒฝ** +```sql +-- โŒ ํ˜„์žฌ (ํ…Œ์ŠคํŠธ์šฉ) +INSERT INTO users (username, password) VALUES ('admin', 'admin123'); + +-- โœ… ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๋ณ€๊ฒฝ +-- ๊ฐ•๋ ฅํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝ ๋ฐ ํ…Œ์ŠคํŠธ ๊ณ„์ • ์‚ญ์ œ +``` + +### ๐Ÿ›ก๏ธ **๋ฐฐํฌ ์ „ ๋ณด์•ˆ ์ฒดํฌ๋ฆฌ์ŠคํŠธ** + +- [ ] **ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋ถ„๋ฆฌ**: ๋ชจ๋“  ๋ฏผ๊ฐ ์ •๋ณด๋ฅผ .env ํŒŒ์ผ๋กœ ๋ถ„๋ฆฌ +- [ ] **HTTPS ์ ์šฉ**: SSL ์ธ์ฆ์„œ ์„ค์น˜ ๋ฐ HTTP โ†’ HTTPS ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ +- [ ] **๋ฐฉํ™”๋ฒฝ ์„ค์ •**: ํ•„์š”ํ•œ ํฌํŠธ๋งŒ ๊ฐœ๋ฐฉ (80, 443, SSH) +- [ ] **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ ‘๊ทผ ์ œํ•œ**: ์™ธ๋ถ€ ์ ‘๊ทผ ์ฐจ๋‹จ, ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋งŒ ์ ‘๊ทผ +- [ ] **๋กœ๊ทธ ํŒŒ์ผ ๋ณด์•ˆ**: ๋ฏผ๊ฐ ์ •๋ณด ๋กœ๊น… ๋ฐฉ์ง€, ๋กœ๊ทธ ํŒŒ์ผ ๊ถŒํ•œ ์„ค์ • +- [ ] **๋ฐฑ์—… ์ „๋žต**: ์ •๊ธฐ ๋ฐฑ์—… ๋ฐ ๋ณต๊ตฌ ํ…Œ์ŠคํŠธ +- [ ] **๋ชจ๋‹ˆํ„ฐ๋ง**: ์‹œ์Šคํ…œ ์ƒํƒœ ๋ฐ ๋ณด์•ˆ ์ด๋ฒคํŠธ ๋ชจ๋‹ˆํ„ฐ๋ง +- [ ] **์—…๋ฐ์ดํŠธ ๊ณ„ํš**: ๋ณด์•ˆ ํŒจ์น˜ ๋ฐ ์˜์กด์„ฑ ์—…๋ฐ์ดํŠธ ๊ณ„ํš + +### ๐Ÿ“‹ **๋ฐฐํฌ ํ™˜๊ฒฝ๋ณ„ ์„ค์ • ๊ฐ€์ด๋“œ** + +#### **๊ฐœ๋ฐœ ํ™˜๊ฒฝ (ํ˜„์žฌ)** +```bash +ENVIRONMENT=development +DEBUG=true +CORS_ORIGINS=http://localhost:3000,http://localhost:13000 +``` + +#### **์Šคํ…Œ์ด์ง• ํ™˜๊ฒฝ** +```bash +ENVIRONMENT=staging +DEBUG=false +CORS_ORIGINS=https://staging.your-domain.com +``` + +#### **ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ** +```bash +ENVIRONMENT=production +DEBUG=false +CORS_ORIGINS=https://your-domain.com +JWT_SECRET_KEY=๊ฐ•๋ ฅํ•œ-๋žœ๋ค-ํ‚ค +DB_PASSWORD=๊ฐ•๋ ฅํ•œ-๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค-๋น„๋ฐ€๋ฒˆํ˜ธ +``` + +--- + +## ๐Ÿ“‹ **API ์ •๋ฆฌ ์š”์•ฝ** (2025.09.05 ์™„๋ฃŒ) + +### โœ… **ํ•ด๊ฒฐ๋œ ๋ฌธ์ œ๋“ค** +1. **404 ์˜ค๋ฅ˜ ํ•ด๊ฒฐ**: `/files/materials` โ†’ `/files/materials-v2` ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ +2. **API ํ˜ธ์ถœ ํ‘œ์ค€ํ™”**: ์ง์ ‘ ํ˜ธ์ถœ โ†’ `fetchMaterials()` ํ•จ์ˆ˜ ์‚ฌ์šฉ +3. **ํ˜ผ๋™ ๋ฐฉ์ง€**: ๋ช…ํ™•ํ•œ API ์‚ฌ์šฉ ๊ฐ€์ด๋“œ๋ผ์ธ ์ˆ˜๋ฆฝ +4. **๋ฌธ์„œํ™” ์™„์„ฑ**: ์‹ค์ œ ๊ตฌํ˜„๋œ ๋ชจ๋“  API ์—”๋“œํฌ์ธํŠธ ์ •๋ฆฌ + +### ๐ŸŽฏ **ํ‘œ์ค€ํ™”๋œ ์‚ฌ์šฉ๋ฒ•** +```javascript +// โœ… ๊ถŒ์žฅ ๋ฐฉ๋ฒ• +import { fetchMaterials, fetchFiles, fetchJobs } from '../api'; + +// ํŒŒ์ผ๋ณ„ ์ž์žฌ ์กฐํšŒ +const materials = await fetchMaterials({ file_id: 123 }); + +// ํ”„๋กœ์ ํŠธ๋ณ„ ์ž์žฌ ์กฐํšŒ +const materials = await fetchMaterials({ job_no: 'J24-001' }); + +// ๋ฆฌ๋น„์ „๋ณ„ ์ž์žฌ ์กฐํšŒ +const materials = await fetchMaterials({ + job_no: 'J24-001', + revision: 'Rev.1' +}); +``` + +### ๐Ÿšจ **์ค‘์š” ๊ทœ์น™** +- **๋ชจ๋“  ์ž์žฌ API ํ˜ธ์ถœ์€ `fetchMaterials()` ํ•จ์ˆ˜ ์‚ฌ์šฉ** +- **์ง์ ‘ API ํ˜ธ์ถœ ๊ธˆ์ง€** (ํŠน๋ณ„ํ•œ ๊ฒฝ์šฐ ์ œ์™ธ) +- **์ƒˆ API ์ถ”๊ฐ€ ์‹œ RULES.md ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ** +- **API ๋ณ€๊ฒฝ ์‹œ ํ•˜์œ„ ํ˜ธํ™˜์„ฑ ๊ณ ๋ ค** + +--- + +## ๐Ÿ” ์ž์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™ + +### ํ•ต์‹ฌ ๋ถ„๋ฅ˜ ์›์น™ + +#### 1. ๋‹ˆํ”Œ(NIPPLE) ํŠน์ˆ˜ ๊ทœ์น™ โš ๏ธ +- **๋ถ„๋ฅ˜ ๋ฐฉ์‹**: ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜๊ธฐ(pipe_classifier)๋กœ ๋ถ„๋ฅ˜ํ•˜์ง€๋งŒ **์นดํ…Œ๊ณ ๋ฆฌ๋Š” FITTING์œผ๋กœ ์ฒ˜๋ฆฌ** +- **์ด์œ **: ๋‹ˆํ”Œ์€ ํŒŒ์ดํ”„์™€ ๋™์ผํ•œ ์žฌ์งˆ/์ŠคํŽ™์„ ๊ฐ€์ง€์ง€๋งŒ, ์šฉ๋„์ƒ ํ”ผํŒ…๋ฅ˜๋กœ ์ทจ๊ธ‰ +- **๊ธธ์ด ๊ธฐ๋ฐ˜ ๊ทธ๋ฃนํ•‘**: ๊ฐ™์€ ์ŠคํŽ™์ด๋ผ๋„ ๊ธธ์ด๊ฐ€ ๋‹ค๋ฅด๋ฉด ๋ณ„๋„ ํ•ญ๋ชฉ์œผ๋กœ ๋ถ„๋ฆฌ + - ์˜ˆ: `NIPPLE 1" 75mm` vs `NIPPLE 1" 100mm` +- **์ด๊ธธ์ด ๊ณ„์‚ฐ**: ๊ฐœ๋ณ„ ๋‹ˆํ”Œ ๊ธธ์ด ร— ์ˆ˜๋Ÿ‰์„ ํ•ฉ์‚ฐํ•˜์—ฌ ์‹ค์ œ ์ด๊ธธ์ด ํ‘œ์‹œ +- **๋๋‹จ ๊ฐ€๊ณต ์ฒ˜๋ฆฌ**: ํŒŒ์ดํ”„์™€ ๋™์ผํ•˜๊ฒŒ PBE, BBE, POE ๋“ฑ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ๋ถ„๋ฆฌ ์ €์žฅ +- **๊ทธ๋ฃนํ•‘ ํ‚ค**: `clean_description|size_spec|material_grade|length_mm` + +#### 2. ํŒŒ์ดํ”„(PIPE) ๋ถ„๋ฅ˜ ๊ทœ์น™ +- **๊ทธ๋ฃนํ•‘ ํ‚ค**: `clean_description|size_spec|material_grade` +- **๋๋‹จ ๊ฐ€๊ณต ์ œ์™ธ**: ๊ตฌ๋งค์šฉ ๊ทธ๋ฃนํ•‘์—์„œ๋Š” BBE, POE, PBE ๋“ฑ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ œ์™ธ +- **๊ฐœ๋ณ„ ์ •๋ณด ๋ณด์กด**: ๊ฐ ํŒŒ์ดํ”„์˜ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด๋Š” `pipe_end_preparations` ํ…Œ์ด๋ธ”์— ๋ณ„๋„ ์ €์žฅ +- **์ด๊ธธ์ด ๊ณ„์‚ฐ**: ๋™์ผ ์ŠคํŽ™ ํŒŒ์ดํ”„๋“ค์˜ ๊ฐœ๋ณ„ ๊ธธ์ด ํ•ฉ์‚ฐ + +#### 3. ๊ธฐํƒ€ ํ”ผํŒ…(FITTING) ๋ถ„๋ฅ˜ ๊ทœ์น™ +- **์ผ๋ฐ˜ ํ”ผํŒ…**: ์ˆ˜๋Ÿ‰ ๊ธฐ๋ฐ˜ ์ง‘๊ณ„ (ELBOW, TEE, REDUCER ๋“ฑ) +- **๊ธธ์ด ์ •๋ณด ์—†์Œ**: ๋‹ˆํ”Œ์„ ์ œ์™ธํ•œ ์ผ๋ฐ˜ ํ”ผํŒ…์€ ๊ธธ์ด ๊ธฐ๋ฐ˜ ๊ทธ๋ฃนํ•‘ ๋ถˆํ•„์š” + +### ๋ถ„๋ฅ˜ ์šฐ์„ ์ˆœ์œ„ +1. **PIPE**: ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜๊ธฐ ์šฐ์„  ์ ์šฉ +2. **FITTING**: ๋‹ˆํ”Œ ํฌํ•จ, ํ”ผํŒ… ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ +3. **VALVE**: ๋ฐธ๋ธŒ ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ +4. **FLANGE**: ํ”Œ๋žœ์ง€ ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ +5. **BOLT**: ๋ณผํŠธ ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ +6. **GASKET**: ๊ฐ€์Šค์ผ“ ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ +7. **INSTRUMENT**: ๊ณ„๊ธฐ ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ + +### ๋๋‹จ ๊ฐ€๊ณต ์ฝ”๋“œ ์ •์˜ +- **PBE**: Plain Both Ends (์–‘์ชฝ ๋ฌด๊ฐœ์„ ) - ๊ธฐ๋ณธ๊ฐ’ +- **BBE**: Both Ends Beveled (์–‘์ชฝ ๊ฐœ์„ ) +- **POE**: Plain One End (ํ•œ์ชฝ ๋ฌด๊ฐœ์„ ) +- **BOE**: Beveled One End (ํ•œ์ชฝ ๊ฐœ์„ ) +- **TOE**: Threaded One End (ํ•œ์ชฝ ๋‚˜์‚ฌ) + +--- + +**๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ**: 2025๋…„ 9์›” (์ž์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™ ๋ฐ API ์ •๋ฆฌ ์™„๋ฃŒ) diff --git a/backend/app.py b/backend/app.py deleted file mode 100644 index ee620fa..0000000 --- a/backend/app.py +++ /dev/null @@ -1,166 +0,0 @@ -from flask import Flask, request, jsonify -import psycopg2 -from contextlib import contextmanager - -app = Flask(__name__) - -@contextmanager -def get_db_connection(): - conn = psycopg2.connect( - host="localhost", - database="tkmp_db", - user="tkmp_user", - password="tkmp2024!", - port="5432" - ) - try: - yield conn - finally: - conn.close() - -@app.route('/') -def home(): - return {"message": "API ์ž‘๋™ ์ค‘"} - -@app.route('/api/materials') -def get_materials(): - job_number = request.args.get('job_number') - - if not job_number: - return {"error": "job_number ํ•„์š”"}, 400 - - try: - with get_db_connection() as conn: - cur = conn.cursor() - - cur.execute(""" - SELECT id, job_number, item_number, description, - category, quantity, unit, created_at - FROM materials - WHERE job_number = %s - ORDER BY item_number - """, (job_number,)) - - rows = cur.fetchall() - - materials = [] - for r in rows: - item = { - 'id': r[0], - 'job_number': r[1], - 'item_number': r[2], - 'description': r[3], - 'category': r[4], - 'quantity': r[5], - 'unit': r[6], - 'created_at': str(r[7]) if r[7] else None - } - materials.append(item) - - return { - 'success': True, - 'data': materials, - 'count': len(materials) - } - - except Exception as e: - return {"error": f"DB ์˜ค๋ฅ˜: {str(e)}"}, 500 - -if __name__ == '__main__': - print("๐Ÿš€ ์„œ๋ฒ„ ์‹œ์ž‘: http://localhost:5000") - app.run(debug=True, port=5000) -# ์ˆ˜์ •๋œ get_materials API (์˜ฌ๋ฐ”๋ฅธ ์ปฌ๋Ÿผ๋ช… ์‚ฌ์šฉ) -@app.route('/api/materials-fixed', methods=['GET']) -def get_materials_fixed(): - """์˜ฌ๋ฐ”๋ฅธ ์ปฌ๋Ÿผ๋ช…์„ ์‚ฌ์šฉํ•œ ์ž์žฌ ์กฐํšŒ API""" - try: - file_id = request.args.get('file_id') - - if not file_id: - return jsonify({ - 'success': False, - 'error': 'file_id parameter is required' - }), 400 - - with get_db_connection() as conn: - cur = conn.cursor() - - cur.execute(""" - SELECT - id, file_id, line_number, original_description, - classified_category, classified_subcategory, - quantity, unit, created_at - FROM materials - WHERE file_id = %s - ORDER BY line_number - """, (file_id,)) - - materials = [] - for item in cur.fetchall(): - material = { - 'id': item[0], - 'file_id': item[1], - 'line_number': item[2], - 'original_description': item[3], - 'classified_category': item[4], - 'classified_subcategory': item[5], - 'quantity': float(item[6]) if item[6] else 0, - 'unit': item[7], - 'created_at': item[8].isoformat() if item[8] else None - } - materials.append(material) - - return jsonify({ - 'success': True, - 'data': materials, - 'count': len(materials), - 'file_id': file_id - }) - - except Exception as e: - print(f"Error in get_materials_fixed: {e}") - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - -@app.get("/api/materials-test") -def get_materials_test(file_id: int): - """ํ…Œ์ŠคํŠธ์šฉ ์ž์žฌ ์กฐํšŒ API""" - try: - with get_db_connection() as conn: - cur = conn.cursor() - - cur.execute(""" - SELECT - id, file_id, line_number, original_description, - classified_category, quantity, unit - FROM materials - WHERE file_id = %s - ORDER BY line_number - LIMIT 5 - """, (file_id,)) - - rows = cur.fetchall() - - materials = [] - for r in rows: - materials.append({ - 'id': r[0], - 'file_id': r[1], - 'line_number': r[2], - 'description': r[3], - 'category': r[4], - 'quantity': float(r[5]) if r[5] else 0, - 'unit': r[6] - }) - - return { - 'success': True, - 'data': materials, - 'count': len(materials) - } - - except Exception as e: - return {'error': str(e)} - diff --git a/backend/app/api/file_management.py b/backend/app/api/file_management.py deleted file mode 100644 index 25fc580..0000000 --- a/backend/app/api/file_management.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -ํŒŒ์ผ ๊ด€๋ฆฌ API -main.py์—์„œ ๋ถ„๋ฆฌ๋œ ํŒŒ์ผ ๊ด€๋ จ ์—”๋“œํฌ์ธํŠธ๋“ค -""" -from fastapi import APIRouter, Depends -from sqlalchemy import text -from sqlalchemy.orm import Session -from typing import Optional - -from ..database import get_db -from ..utils.logger import get_logger -from ..schemas import FileListResponse, FileDeleteResponse, FileInfo -from ..services.file_service import get_file_service - -router = APIRouter() -logger = get_logger(__name__) - - -@router.get("/files", response_model=FileListResponse) -async def get_files( - job_no: Optional[str] = None, - show_history: bool = False, - use_cache: bool = True, - db: Session = Depends(get_db) -) -> FileListResponse: - """ํŒŒ์ผ ๋ชฉ๋ก ์กฐํšŒ (BOM๋ณ„ ๊ทธ๋ฃนํ™”)""" - file_service = get_file_service(db) - - # ์„œ๋น„์Šค ๋ ˆ์ด์–ด ํ˜ธ์ถœ - files, cache_hit = await file_service.get_files(job_no, show_history, use_cache) - - return FileListResponse( - success=True, - message="ํŒŒ์ผ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต" + (" (์บ์‹œ)" if cache_hit else ""), - data=files, - total_count=len(files), - cache_hit=cache_hit - ) - - -@router.delete("/files/{file_id}", response_model=FileDeleteResponse) -async def delete_file( - file_id: int, - db: Session = Depends(get_db) -) -> FileDeleteResponse: - """ํŒŒ์ผ ์‚ญ์ œ""" - file_service = get_file_service(db) - - # ์„œ๋น„์Šค ๋ ˆ์ด์–ด ํ˜ธ์ถœ - result = await file_service.delete_file(file_id) - - return FileDeleteResponse( - success=result["success"], - message=result["message"], - deleted_file_id=result["deleted_file_id"] - ) diff --git a/backend/app/api/files.py b/backend/app/api/files.py deleted file mode 100644 index f1ce8fc..0000000 --- a/backend/app/api/files.py +++ /dev/null @@ -1,1180 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from sqlalchemy.orm import Session -from sqlalchemy import text -from typing import List, Optional -import os -import shutil -from datetime import datetime -import uuid -import pandas as pd -import re -import json -from pathlib import Path - -from ..database import get_db -from app.services.material_classifier import classify_material -from app.services.bolt_classifier import classify_bolt -from app.services.flange_classifier import classify_flange -from app.services.fitting_classifier import classify_fitting -from app.services.gasket_classifier import classify_gasket -from app.services.instrument_classifier import classify_instrument -from app.services.pipe_classifier import classify_pipe -from app.services.valve_classifier import classify_valve - -router = APIRouter() - -UPLOAD_DIR = Path("uploads") -UPLOAD_DIR.mkdir(exist_ok=True) -ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} - -@router.get("/") -async def get_files_info(): - return { - "message": "ํŒŒ์ผ ๊ด€๋ฆฌ API", - "allowed_extensions": list(ALLOWED_EXTENSIONS), - "upload_directory": str(UPLOAD_DIR) - } - -@router.get("/test") -async def test_endpoint(): - return {"status": "ํŒŒ์ผ API๊ฐ€ ์ •์ƒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค!"} - -@router.post("/add-missing-columns") -async def add_missing_columns(db: Session = Depends(get_db)): - """๋ˆ„๋ฝ๋œ ์ปฌ๋Ÿผ๋“ค ์ถ”๊ฐ€""" - try: - db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0")) - db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER")) - db.commit() - - return { - "success": True, - "message": "๋ˆ„๋ฝ๋œ ์ปฌ๋Ÿผ๋“ค์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค", - "added_columns": ["files.parsed_count", "materials.row_number"] - } - except Exception as e: - db.rollback() - return {"success": False, "error": f"์ปฌ๋Ÿผ ์ถ”๊ฐ€ ์‹คํŒจ: {str(e)}"} - -def validate_file_extension(filename: str) -> bool: - return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS - -def generate_unique_filename(original_filename: str) -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - unique_id = str(uuid.uuid4())[:8] - stem = Path(original_filename).stem - suffix = Path(original_filename).suffix - return f"{stem}_{timestamp}_{unique_id}{suffix}" - -def parse_dataframe(df): - df = df.dropna(how='all') - # ์›๋ณธ ์ปฌ๋Ÿผ๋ช… ์œ ์ง€ (์†Œ๋ฌธ์ž ๋ณ€ํ™˜ํ•˜์ง€ ์•Š์Œ) - df.columns = df.columns.str.strip() - - column_mapping = { - 'description': ['description', 'item', 'material', 'ํ’ˆ๋ช…', '์ž์žฌ๋ช…'], - 'quantity': ['qty', 'quantity', 'ea', '์ˆ˜๋Ÿ‰'], - 'main_size': ['main_nom', 'nominal_diameter', 'nd', '์ฃผ๋ฐฐ๊ด€'], - 'red_size': ['red_nom', 'reduced_diameter', '์ถ•์†Œ๋ฐฐ๊ด€'], - 'length': ['length', 'len', '๊ธธ์ด'], - 'weight': ['weight', 'wt', '์ค‘๋Ÿ‰'], - 'dwg_name': ['dwg_name', 'drawing', '๋„๋ฉด๋ช…'], - 'line_num': ['line_num', 'line_number', '๋ผ์ธ๋ฒˆํ˜ธ'] - } - - mapped_columns = {} - for standard_col, possible_names in column_mapping.items(): - for possible_name in possible_names: - # ๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„ ์—†์ด ๋งคํ•‘ - for col in df.columns: - if possible_name.lower() == col.lower(): - mapped_columns[standard_col] = col - break - if standard_col in mapped_columns: - break - - print(f"์ฐพ์€ ์ปฌ๋Ÿผ ๋งคํ•‘: {mapped_columns}") - - materials = [] - for index, row in df.iterrows(): - description = str(row.get(mapped_columns.get('description', ''), '')) - quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) - - try: - quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0 - except: - quantity = 0 - - # ๊ธธ์ด ์ •๋ณด ํŒŒ์‹ฑ - length_raw = row.get(mapped_columns.get('length', ''), None) - length_value = None - if pd.notna(length_raw) and length_raw != '': - try: - length_value = float(length_raw) - except: - length_value = None - - material_grade = "" - if "ASTM" in description.upper(): - astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper()) - if astm_match: - material_grade = astm_match.group(0).strip() - - main_size = str(row.get(mapped_columns.get('main_size', ''), '')) - red_size = str(row.get(mapped_columns.get('red_size', ''), '')) - - if main_size != 'nan' and red_size != 'nan' and red_size != '': - size_spec = f"{main_size} x {red_size}" - elif main_size != 'nan' and main_size != '': - size_spec = main_size - else: - size_spec = "" - - if description and description not in ['nan', 'None', '']: - materials.append({ - 'original_description': description, - 'quantity': quantity, - 'unit': "EA", - 'size_spec': size_spec, - 'material_grade': material_grade, - 'length': length_value, - 'line_number': index + 1, - 'row_number': index + 1 - }) - - return materials - -def parse_file_data(file_path): - file_extension = Path(file_path).suffix.lower() - - try: - if file_extension == ".csv": - df = pd.read_csv(file_path, encoding='utf-8') - elif file_extension in [".xlsx", ".xls"]: - df = pd.read_excel(file_path, sheet_name=0) - else: - raise HTTPException(status_code=400, detail="์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹") - - return parse_dataframe(df) - except Exception as e: - raise HTTPException(status_code=400, detail=f"ํŒŒ์ผ ํŒŒ์‹ฑ ์‹คํŒจ: {str(e)}") - -@router.post("/upload") -async def upload_file( - file: UploadFile = File(...), - project_id: int = Form(...), - revision: str = Form("Rev.0"), - db: Session = Depends(get_db) -): - if not validate_file_extension(str(file.filename)): - raise HTTPException( - status_code=400, - detail=f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค. ํ—ˆ์šฉ๋œ ํ™•์žฅ์ž: {', '.join(ALLOWED_EXTENSIONS)}" - ) - - if file.size and file.size > 10 * 1024 * 1024: - raise HTTPException(status_code=400, detail="ํŒŒ์ผ ํฌ๊ธฐ๋Š” 10MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") - - unique_filename = generate_unique_filename(str(file.filename)) - file_path = UPLOAD_DIR / unique_filename - - try: - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - except Exception as e: - raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ: {str(e)}") - - try: - materials_data = parse_file_data(str(file_path)) - parsed_count = len(materials_data) - - # ํŒŒ์ผ ์ •๋ณด ์ €์žฅ - file_insert_query = text(""" - INSERT INTO files (filename, original_filename, file_path, project_id, revision, description, file_size, parsed_count, is_active) - VALUES (:filename, :original_filename, :file_path, :project_id, :revision, :description, :file_size, :parsed_count, :is_active) - RETURNING id - """) - - file_result = db.execute(file_insert_query, { - "filename": unique_filename, - "original_filename": file.filename, - "file_path": str(file_path), - "project_id": project_id, - "revision": revision, - "description": f"BOM ํŒŒ์ผ - {parsed_count}๊ฐœ ์ž์žฌ", - "file_size": file.size, - "parsed_count": parsed_count, - "is_active": True - }) - - file_id = file_result.fetchone()[0] - - # ์ž์žฌ ๋ฐ์ดํ„ฐ ์ €์žฅ (๋ถ„๋ฅ˜ ํฌํ•จ) - materials_inserted = 0 - for material_data in materials_data: - # ์ž์žฌ ํƒ€์ž… ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ (PIPE, FITTING, VALVE ๋“ฑ) - description = material_data["original_description"] - size_spec = material_data["size_spec"] - - # ๊ฐ ๋ถ„๋ฅ˜๊ธฐ๋กœ ์‹œ๋„ (์˜ฌ๋ฐ”๋ฅธ ๋งค๊ฐœ๋ณ€์ˆ˜ ์‚ฌ์šฉ) - print(f"๋ถ„๋ฅ˜ ์‹œ๋„: {description}") - - # ๋ถ„๋ฅ˜๊ธฐ ํ˜ธ์ถœ ์‹œ ํƒ€์ž„์•„์›ƒ ๋ฐ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ - classification_result = None - try: - # ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜๊ธฐ ํ˜ธ์ถœ ์‹œ length ๋งค๊ฐœ๋ณ€์ˆ˜ ์ „๋‹ฌ - length_value = None - if 'length' in material_data: - try: - length_value = float(material_data['length']) - except: - length_value = None - # None์ด๋ฉด 0.0์œผ๋กœ ๋Œ€์ฒด - if length_value is None: - length_value = 0.0 - - # ํƒ€์ž„์•„์›ƒ ์„ค์ • (10์ดˆ) - import signal - def timeout_handler(signum, frame): - raise TimeoutError("๋ถ„๋ฅ˜๊ธฐ ์‹คํ–‰ ์‹œ๊ฐ„ ์ดˆ๊ณผ") - - signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(10) # 10์ดˆ ํƒ€์ž„์•„์›ƒ - - try: - classification_result = classify_pipe("", description, size_spec, length_value) - print(f"PIPE ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) # ํƒ€์ž„์•„์›ƒ ํ•ด์ œ - - if classification_result.get("overall_confidence", 0) < 0.5: - signal.alarm(10) - try: - classification_result = classify_fitting("", description, size_spec) - print(f"FITTING ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) - - if classification_result.get("overall_confidence", 0) < 0.5: - signal.alarm(10) - try: - classification_result = classify_valve("", description, size_spec) - print(f"VALVE ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) - - if classification_result.get("overall_confidence", 0) < 0.5: - signal.alarm(10) - try: - classification_result = classify_flange("", description, size_spec) - print(f"FLANGE ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) - - if classification_result.get("overall_confidence", 0) < 0.5: - signal.alarm(10) - try: - classification_result = classify_bolt("", description, size_spec) - print(f"BOLT ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) - - if classification_result.get("overall_confidence", 0) < 0.5: - signal.alarm(10) - try: - classification_result = classify_gasket("", description, size_spec) - print(f"GASKET ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) - - if classification_result.get("overall_confidence", 0) < 0.5: - signal.alarm(10) - try: - classification_result = classify_instrument("", description, size_spec) - print(f"INSTRUMENT ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')} (์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)})") - finally: - signal.alarm(0) - - except (TimeoutError, Exception) as e: - print(f"๋ถ„๋ฅ˜๊ธฐ ์‹คํ–‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}") - # ๊ธฐ๋ณธ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ์ƒ์„ฑ - classification_result = { - "category": "UNKNOWN", - "overall_confidence": 0.0, - "reason": f"๋ถ„๋ฅ˜๊ธฐ ์˜ค๋ฅ˜: {str(e)}" - } - - print(f"์ตœ์ข… ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {classification_result.get('category', 'UNKNOWN')}") - - # ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ์—์„œ ์ƒ์„ธ ์ •๋ณด ์ถ”์ถœ - if classification_result.get('category') == 'PIPE': - classification_details = classification_result - elif classification_result.get('category') == 'FITTING': - classification_details = classification_result - elif classification_result.get('category') == 'VALVE': - classification_details = classification_result - else: - classification_details = {} - # DB์— ์ €์žฅ ์‹œ JSON ์ง๋ ฌํ™” - classification_details = json.dumps(classification_details, ensure_ascii=False) - - # ๋””๋ฒ„๊น…: ์ €์žฅ ์ง์ „ ๋ฐ์ดํ„ฐ ํ™•์ธ - print(f"=== ์ž์žฌ[{materials_inserted + 1}] ์ €์žฅ ์ง์ „ ===") - print(f"์ž์žฌ๋ช…: {material_data['original_description']}") - print(f"๋ถ„๋ฅ˜๊ฒฐ๊ณผ: {classification_result.get('category')}") - print(f"์‹ ๋ขฐ๋„: {classification_result.get('overall_confidence', 0)}") - print(f"classification_details ๊ธธ์ด: {len(classification_details)}") - print(f"classification_details ์ƒ˜ํ”Œ: {classification_details[:200]}...") - print("=" * 50) - material_insert_query = text(""" - INSERT INTO materials ( - file_id, original_description, quantity, unit, size_spec, - material_grade, line_number, row_number, classified_category, - classification_confidence, classification_details, is_verified, created_at - ) - VALUES ( - :file_id, :original_description, :quantity, :unit, :size_spec, - :material_grade, :line_number, :row_number, :classified_category, - :classification_confidence, :classification_details, :is_verified, :created_at - ) - """) - - db.execute(material_insert_query, { - "file_id": file_id, - "original_description": material_data["original_description"], - "quantity": material_data["quantity"], - "unit": material_data["unit"], - "size_spec": material_data["size_spec"], - "material_grade": material_data["material_grade"], - "line_number": material_data["line_number"], - "row_number": material_data["row_number"], - "classified_category": classification_result.get("category", "UNKNOWN"), - "classification_confidence": classification_result.get("overall_confidence", 0.0), - "classification_details": classification_details, - "is_verified": False, - "created_at": datetime.now() - }) - - # ๊ฐ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ์ƒ์„ธ ํ…Œ์ด๋ธ”์— ์ €์žฅ - category = classification_result.get('category') - confidence = classification_result.get('overall_confidence', 0) - - if category == 'PIPE' and confidence >= 0.5: - try: - # ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ์—์„œ ํŒŒ์ดํ”„ ์ƒ์„ธ ์ •๋ณด ์ถ”์ถœ - pipe_info = classification_result - - # cutting_dimensions์—์„œ length ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ - cutting_dims = pipe_info.get('cutting_dimensions', {}) - length_mm = cutting_dims.get('length_mm') - - # length_mm๊ฐ€ ์—†์œผ๋ฉด ์›๋ณธ ๋ฐ์ดํ„ฐ์˜ length ์‚ฌ์šฉ - if not length_mm and material_data.get('length'): - length_mm = material_data['length'] - - pipe_insert_query = text(""" - INSERT INTO pipe_details ( - material_id, file_id, outer_diameter, schedule, - material_spec, manufacturing_method, length_mm - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :outer_diameter, :schedule, - :material_spec, :manufacturing_method, :length_mm - ) - """) - - db.execute(pipe_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "outer_diameter": pipe_info.get('nominal_diameter', ''), - "schedule": pipe_info.get('schedule', ''), - "material_spec": pipe_info.get('material_spec', ''), - "manufacturing_method": pipe_info.get('manufacturing_method', ''), - "length_mm": length_mm, - - }) - - print(f"PIPE ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"PIPE ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - # ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ์ „์ฒด ํ”„๋กœ์„ธ์Šค๋Š” ๊ณ„์† ์ง„ํ–‰ - - elif category == 'FITTING' and confidence >= 0.5: - try: - fitting_info = classification_result - - fitting_insert_query = text(""" - INSERT INTO fitting_details ( - material_id, file_id, fitting_type, fitting_subtype, - connection_method, connection_code, pressure_rating, max_pressure, - manufacturing_method, material_standard, material_grade, material_type, - main_size, reduced_size, classification_confidence, additional_info - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :fitting_type, :fitting_subtype, - :connection_method, :connection_code, :pressure_rating, :max_pressure, - :manufacturing_method, :material_standard, :material_grade, :material_type, - :main_size, :reduced_size, :classification_confidence, :additional_info - ) - """) - - db.execute(fitting_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "fitting_type": fitting_info.get('fitting_type', {}).get('type', ''), - "fitting_subtype": fitting_info.get('fitting_type', {}).get('subtype', ''), - "connection_method": fitting_info.get('connection_method', {}).get('method', ''), - "connection_code": fitting_info.get('connection_method', {}).get('matched_code', ''), - "pressure_rating": fitting_info.get('pressure_rating', {}).get('rating', ''), - "max_pressure": fitting_info.get('pressure_rating', {}).get('max_pressure', ''), - "manufacturing_method": fitting_info.get('manufacturing', {}).get('method', ''), - "material_standard": fitting_info.get('material', {}).get('standard', ''), - "material_grade": fitting_info.get('material', {}).get('grade', ''), - "material_type": fitting_info.get('material', {}).get('material_type', ''), - "main_size": fitting_info.get('size_info', {}).get('main_size', ''), - "reduced_size": fitting_info.get('size_info', {}).get('reduced_size', ''), - "classification_confidence": confidence, - "additional_info": json.dumps(fitting_info, ensure_ascii=False) - }) - - print(f"FITTING ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"FITTING ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - - elif category == 'VALVE' and confidence >= 0.5: - try: - valve_info = classification_result - - valve_insert_query = text(""" - INSERT INTO valve_details ( - material_id, file_id, valve_type, valve_subtype, actuator_type, - connection_method, pressure_rating, pressure_class, - body_material, trim_material, size_inches, - fire_safe, low_temp_service, classification_confidence, additional_info - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :valve_type, :valve_subtype, :actuator_type, - :connection_method, :pressure_rating, :pressure_class, - :body_material, :trim_material, :size_inches, - :fire_safe, :low_temp_service, :classification_confidence, :additional_info - ) - """) - - db.execute(valve_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "valve_type": valve_info.get('valve_type', ''), - "valve_subtype": valve_info.get('valve_subtype', ''), - "actuator_type": valve_info.get('actuator_type', ''), - "connection_method": valve_info.get('connection_method', ''), - "pressure_rating": valve_info.get('pressure_rating', ''), - "pressure_class": valve_info.get('pressure_class', ''), - "body_material": valve_info.get('body_material', ''), - "trim_material": valve_info.get('trim_material', ''), - "size_inches": valve_info.get('size', ''), - "fire_safe": valve_info.get('fire_safe', False), - "low_temp_service": valve_info.get('low_temp_service', False), - "classification_confidence": confidence, - "additional_info": json.dumps(valve_info, ensure_ascii=False) - }) - - print(f"VALVE ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"VALVE ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - - elif category == 'FLANGE' and confidence >= 0.5: - try: - flange_info = classification_result - - flange_insert_query = text(""" - INSERT INTO flange_details ( - material_id, file_id, flange_type, facing_type, - pressure_rating, material_standard, material_grade, - size_inches, classification_confidence, additional_info - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :flange_type, :facing_type, - :pressure_rating, :material_standard, :material_grade, - :size_inches, :classification_confidence, :additional_info - ) - """) - - db.execute(flange_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "flange_type": flange_info.get('flange_type', {}).get('type', ''), - "facing_type": flange_info.get('face_finish', {}).get('finish', ''), - "pressure_rating": flange_info.get('pressure_rating', {}).get('rating', ''), - "material_standard": flange_info.get('material', {}).get('standard', ''), - "material_grade": flange_info.get('material', {}).get('grade', ''), - "size_inches": material_data.get('size_spec', ''), - "classification_confidence": confidence, - "additional_info": json.dumps(flange_info, ensure_ascii=False) - }) - - print(f"FLANGE ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"FLANGE ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - - elif category == 'BOLT' and confidence >= 0.5: - try: - bolt_info = classification_result - - bolt_insert_query = text(""" - INSERT INTO bolt_details ( - material_id, file_id, bolt_type, thread_type, - diameter, length, material_standard, material_grade, - coating_type, includes_nut, includes_washer, - classification_confidence, additional_info - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :bolt_type, :thread_type, - :diameter, :length, :material_standard, :material_grade, - :coating_type, :includes_nut, :includes_washer, - :classification_confidence, :additional_info - ) - """) - - # BOLT ๋ถ„๋ฅ˜๊ธฐ ๊ฒฐ๊ณผ ๊ตฌ์กฐ์— ๋งž๊ฒŒ ๋ฐ์ดํ„ฐ ์ถ”์ถœ - bolt_details = bolt_info.get('bolt_details', {}) - material_info = bolt_info.get('material', {}) - - db.execute(bolt_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "bolt_type": bolt_details.get('type', ''), - "thread_type": bolt_details.get('thread_type', ''), - "diameter": bolt_details.get('diameter', ''), - "length": bolt_details.get('length', ''), - "material_standard": material_info.get('standard', ''), - "material_grade": material_info.get('grade', ''), - "coating_type": material_info.get('coating', ''), - "includes_nut": bolt_details.get('includes_nut', False), - "includes_washer": bolt_details.get('includes_washer', False), - "classification_confidence": confidence, - "additional_info": json.dumps(bolt_info, ensure_ascii=False) - }) - - print(f"BOLT ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"BOLT ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - - elif category == 'GASKET' and confidence >= 0.5: - try: - gasket_info = classification_result - - gasket_insert_query = text(""" - INSERT INTO gasket_details ( - material_id, file_id, gasket_type, gasket_subtype, - material_type, size_inches, pressure_rating, - thickness, temperature_range, fire_safe, - classification_confidence, additional_info - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :gasket_type, :gasket_subtype, - :material_type, :size_inches, :pressure_rating, - :thickness, :temperature_range, :fire_safe, - :classification_confidence, :additional_info - ) - """) - - # GASKET ๋ถ„๋ฅ˜๊ธฐ ๊ฒฐ๊ณผ ๊ตฌ์กฐ์— ๋งž๊ฒŒ ๋ฐ์ดํ„ฐ ์ถ”์ถœ - gasket_type_info = gasket_info.get('gasket_type', {}) - gasket_material_info = gasket_info.get('gasket_material', {}) - pressure_info = gasket_info.get('pressure_rating', {}) - size_info = gasket_info.get('size_info', {}) - temp_info = gasket_info.get('temperature_info', {}) - - # SWG ์ƒ์„ธ ์ •๋ณด ์ถ”์ถœ - swg_details = gasket_material_info.get('swg_details', {}) - additional_info = { - "swg_details": swg_details, - "face_type": swg_details.get('face_type', ''), - "construction": swg_details.get('detailed_construction', ''), - "filler": swg_details.get('filler', ''), - "outer_ring": swg_details.get('outer_ring', ''), - "inner_ring": swg_details.get('inner_ring', '') - } - - db.execute(gasket_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "gasket_type": gasket_type_info.get('type', ''), - "gasket_subtype": gasket_type_info.get('subtype', ''), - "material_type": gasket_material_info.get('material', ''), - "size_inches": material_data.get('main_nom', '') or material_data.get('size_spec', ''), - "pressure_rating": pressure_info.get('rating', ''), - "thickness": swg_details.get('thickness', None), - "temperature_range": temp_info.get('range', ''), - "fire_safe": gasket_info.get('fire_safe', False), - "classification_confidence": confidence, - "additional_info": json.dumps(additional_info, ensure_ascii=False) - }) - - print(f"GASKET ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"GASKET ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - - elif category == 'INSTRUMENT' and confidence >= 0.5: - try: - inst_info = classification_result - - inst_insert_query = text(""" - INSERT INTO instrument_details ( - material_id, file_id, instrument_type, instrument_subtype, - measurement_type, measurement_range, accuracy, - connection_type, connection_size, body_material, - classification_confidence, additional_info - ) - VALUES ( - (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), - :file_id, :instrument_type, :instrument_subtype, - :measurement_type, :measurement_range, :accuracy, - :connection_type, :connection_size, :body_material, - :classification_confidence, :additional_info - ) - """) - - # INSTRUMENT ๋ถ„๋ฅ˜๊ธฐ ๊ฒฐ๊ณผ ๊ตฌ์กฐ์— ๋งž๊ฒŒ ๋ฐ์ดํ„ฐ ์ถ”์ถœ - inst_type_info = inst_info.get('instrument_type', {}) - measurement_info = inst_info.get('measurement', {}) - connection_info = inst_info.get('connection', {}) - - db.execute(inst_insert_query, { - "file_id": file_id, - "description": material_data["original_description"], - "row_number": material_data["row_number"], - "instrument_type": inst_type_info.get('type', ''), - "instrument_subtype": inst_type_info.get('subtype', ''), - "measurement_type": measurement_info.get('type', ''), - "measurement_range": measurement_info.get('range', ''), - "accuracy": measurement_info.get('accuracy', ''), - "connection_type": connection_info.get('type', ''), - "connection_size": connection_info.get('size', ''), - "body_material": inst_info.get('material', ''), - "classification_confidence": confidence, - "additional_info": json.dumps(inst_info, ensure_ascii=False) - }) - - print(f"INSTRUMENT ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์™„๋ฃŒ: {material_data['original_description']}") - - except Exception as e: - print(f"INSTRUMENT ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์‹คํŒจ: {e}") - - materials_inserted += 1 - - db.commit() - - return { - "success": True, - "message": f"์™„์ „ํ•œ DB ์ €์žฅ ์„ฑ๊ณต! {materials_inserted}๊ฐœ ์ž์žฌ ์ €์žฅ๋จ", - "original_filename": file.filename, - "file_id": file_id, - "parsed_materials_count": parsed_count, - "saved_materials_count": materials_inserted, - "sample_materials": materials_data[:3] if materials_data else [] - } - - except Exception as e: - db.rollback() - if os.path.exists(file_path): - os.remove(file_path) - raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ฒ˜๋ฆฌ ์‹คํŒจ: {str(e)}") -@router.get("/materials") -async def get_materials( - project_id: Optional[int] = None, - file_id: Optional[int] = None, - job_no: Optional[str] = None, - filename: Optional[str] = None, - revision: Optional[str] = None, - skip: int = 0, - limit: int = 100, - search: Optional[str] = None, - item_type: Optional[str] = None, - material_grade: Optional[str] = None, - size_spec: Optional[str] = None, - file_filter: Optional[str] = None, - sort_by: Optional[str] = None, - db: Session = Depends(get_db) -): - """ - ์ €์žฅ๋œ ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ (job_no, filename, revision 3๊ฐ€์ง€๋กœ ํ•„ํ„ฐ๋ง ๊ฐ€๋Šฅ) - """ - try: - query = """ - SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, - m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number, - m.classified_category, m.classification_confidence, m.classification_details, - m.created_at, - f.original_filename, f.project_id, f.job_no, f.revision, - p.official_project_code, p.project_name - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - LEFT JOIN projects p ON f.project_id = p.id - WHERE 1=1 - """ - params = {} - if project_id: - query += " AND f.project_id = :project_id" - params["project_id"] = project_id - if file_id: - query += " AND m.file_id = :file_id" - params["file_id"] = file_id - if job_no: - query += " AND f.job_no = :job_no" - params["job_no"] = job_no - if filename: - query += " AND f.original_filename = :filename" - params["filename"] = filename - if revision: - query += " AND f.revision = :revision" - params["revision"] = revision - if search: - query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" - params["search"] = f"%{search}%" - if item_type: - query += " AND m.classified_category = :item_type" - params["item_type"] = item_type - if material_grade: - query += " AND m.material_grade ILIKE :material_grade" - params["material_grade"] = f"%{material_grade}%" - if size_spec: - query += " AND m.size_spec ILIKE :size_spec" - params["size_spec"] = f"%{size_spec}%" - if file_filter: - query += " AND f.original_filename ILIKE :file_filter" - params["file_filter"] = f"%{file_filter}%" - - # ์ •๋ ฌ ์ฒ˜๋ฆฌ - if sort_by: - if sort_by == "quantity_desc": - query += " ORDER BY m.quantity DESC" - elif sort_by == "quantity_asc": - query += " ORDER BY m.quantity ASC" - elif sort_by == "name_asc": - query += " ORDER BY m.original_description ASC" - elif sort_by == "name_desc": - query += " ORDER BY m.original_description DESC" - elif sort_by == "created_desc": - query += " ORDER BY m.created_at DESC" - elif sort_by == "created_asc": - query += " ORDER BY m.created_at ASC" - else: - query += " ORDER BY m.line_number ASC" - else: - query += " ORDER BY m.line_number ASC" - - query += " LIMIT :limit OFFSET :skip" - params["limit"] = limit - params["skip"] = skip - - result = db.execute(text(query), params) - materials = result.fetchall() - - # ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ - count_query = """ - SELECT COUNT(*) as total - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - WHERE 1=1 - """ - count_params = {} - - if project_id: - count_query += " AND f.project_id = :project_id" - count_params["project_id"] = project_id - - if file_id: - count_query += " AND m.file_id = :file_id" - count_params["file_id"] = file_id - - if search: - count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" - count_params["search"] = f"%{search}%" - - if item_type: - count_query += " AND m.classified_category = :item_type" - count_params["item_type"] = item_type - - if material_grade: - count_query += " AND m.material_grade ILIKE :material_grade" - count_params["material_grade"] = f"%{material_grade}%" - - if size_spec: - count_query += " AND m.size_spec ILIKE :size_spec" - count_params["size_spec"] = f"%{size_spec}%" - - if file_filter: - count_query += " AND f.original_filename ILIKE :file_filter" - count_params["file_filter"] = f"%{file_filter}%" - - count_result = db.execute(text(count_query), count_params) - total_count = count_result.fetchone()[0] - - return { - "success": True, - "total_count": total_count, - "returned_count": len(materials), - "skip": skip, - "limit": limit, - "materials": [ - { - "id": m.id, - "file_id": m.file_id, - "filename": m.original_filename, - "project_id": m.project_id, - "project_code": m.official_project_code, - "project_name": m.project_name, - "original_description": m.original_description, - "quantity": float(m.quantity) if m.quantity else 0, - "unit": m.unit, - "size_spec": m.size_spec, - "material_grade": m.material_grade, - "line_number": m.line_number, - "row_number": m.row_number, - "classified_category": m.classified_category, - "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0, - "classification_details": json.loads(m.classification_details) if m.classification_details else None, - "created_at": m.created_at - } - for m in materials - ] - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"์ž์žฌ ์กฐํšŒ ์‹คํŒจ: {str(e)}") - -@router.get("/materials/summary") -async def get_materials_summary( - project_id: Optional[int] = None, - file_id: Optional[int] = None, - db: Session = Depends(get_db) -): - """์ž์žฌ ์š”์•ฝ ํ†ต๊ณ„""" - try: - query = """ - SELECT - COUNT(*) as total_items, - COUNT(DISTINCT m.original_description) as unique_descriptions, - COUNT(DISTINCT m.size_spec) as unique_sizes, - COUNT(DISTINCT m.material_grade) as unique_materials, - SUM(m.quantity) as total_quantity, - AVG(m.quantity) as avg_quantity, - MIN(m.created_at) as earliest_upload, - MAX(m.created_at) as latest_upload - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - WHERE 1=1 - """ - - params = {} - - if project_id: - query += " AND f.project_id = :project_id" - params["project_id"] = project_id - - if file_id: - query += " AND m.file_id = :file_id" - params["file_id"] = file_id - - result = db.execute(text(query), params) - summary = result.fetchone() - - return { - "success": True, - "summary": { - "total_items": summary.total_items, - "unique_descriptions": summary.unique_descriptions, - "unique_sizes": summary.unique_sizes, - "unique_materials": summary.unique_materials, - "total_quantity": float(summary.total_quantity) if summary.total_quantity else 0, - "avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0, - "earliest_upload": summary.earliest_upload, - "latest_upload": summary.latest_upload - } - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"์š”์•ฝ ์กฐํšŒ ์‹คํŒจ: {str(e)}") - -@router.get("/materials/compare-revisions") -async def compare_revisions( - job_no: str, - filename: str, - old_revision: str, - new_revision: str, - db: Session = Depends(get_db) -): - """ - ๋ฆฌ๋น„์ „ ๊ฐ„ ์ž์žฌ ๋น„๊ต - """ - try: - # ๊ธฐ์กด ๋ฆฌ๋น„์ „ ์ž์žฌ ์กฐํšŒ - old_materials_query = text(""" - SELECT m.original_description, m.quantity, m.unit, m.size_spec, - m.material_grade, m.classified_category, m.classification_confidence - FROM materials m - JOIN files f ON m.file_id = f.id - WHERE f.job_no = :job_no - AND f.original_filename = :filename - AND f.revision = :old_revision - """) - - old_result = db.execute(old_materials_query, { - "job_no": job_no, - "filename": filename, - "old_revision": old_revision - }) - old_materials = old_result.fetchall() - - # ์ƒˆ ๋ฆฌ๋น„์ „ ์ž์žฌ ์กฐํšŒ - new_materials_query = text(""" - SELECT m.original_description, m.quantity, m.unit, m.size_spec, - m.material_grade, m.classified_category, m.classification_confidence - FROM materials m - JOIN files f ON m.file_id = f.id - WHERE f.job_no = :job_no - AND f.original_filename = :filename - AND f.revision = :new_revision - """) - - new_result = db.execute(new_materials_query, { - "job_no": job_no, - "filename": filename, - "new_revision": new_revision - }) - new_materials = new_result.fetchall() - - # ์ž์žฌ ํ‚ค ์ƒ์„ฑ ํ•จ์ˆ˜ - def create_material_key(material): - return f"{material.original_description}_{material.size_spec}_{material.material_grade}" - - # ๊ธฐ์กด ์ž์žฌ๋ฅผ ๋”•์…”๋„ˆ๋ฆฌ๋กœ ๋ณ€ํ™˜ - old_materials_dict = {} - for material in old_materials: - key = create_material_key(material) - old_materials_dict[key] = { - "original_description": material.original_description, - "quantity": float(material.quantity) if material.quantity else 0, - "unit": material.unit, - "size_spec": material.size_spec, - "material_grade": material.material_grade, - "classified_category": material.classified_category, - "classification_confidence": material.classification_confidence - } - - # ์ƒˆ ์ž์žฌ๋ฅผ ๋”•์…”๋„ˆ๋ฆฌ๋กœ ๋ณ€ํ™˜ - new_materials_dict = {} - for material in new_materials: - key = create_material_key(material) - new_materials_dict[key] = { - "original_description": material.original_description, - "quantity": float(material.quantity) if material.quantity else 0, - "unit": material.unit, - "size_spec": material.size_spec, - "material_grade": material.material_grade, - "classified_category": material.classified_category, - "classification_confidence": material.classification_confidence - } - - # ๋ณ€๊ฒฝ ์‚ฌํ•ญ ๋ถ„์„ - all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys()) - - added_items = [] - removed_items = [] - changed_items = [] - - for key in all_keys: - old_item = old_materials_dict.get(key) - new_item = new_materials_dict.get(key) - - if old_item and not new_item: - # ์‚ญ์ œ๋œ ํ•ญ๋ชฉ - removed_items.append({ - "key": key, - "item": old_item, - "change_type": "removed" - }) - elif not old_item and new_item: - # ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ - added_items.append({ - "key": key, - "item": new_item, - "change_type": "added" - }) - elif old_item and new_item: - # ์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ ํ™•์ธ - if old_item["quantity"] != new_item["quantity"]: - changed_items.append({ - "key": key, - "old_item": old_item, - "new_item": new_item, - "quantity_change": new_item["quantity"] - old_item["quantity"], - "change_type": "quantity_changed" - }) - - # ๋ถ„๋ฅ˜๋ณ„ ํ†ต๊ณ„ - def calculate_category_stats(items): - stats = {} - for item in items: - category = item.get("item", {}).get("classified_category", "OTHER") - if category not in stats: - stats[category] = {"count": 0, "total_quantity": 0} - stats[category]["count"] += 1 - stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0) - return stats - - added_stats = calculate_category_stats(added_items) - removed_stats = calculate_category_stats(removed_items) - changed_stats = calculate_category_stats(changed_items) - - return { - "success": True, - "comparison": { - "old_revision": old_revision, - "new_revision": new_revision, - "filename": filename, - "job_no": job_no, - "summary": { - "added_count": len(added_items), - "removed_count": len(removed_items), - "changed_count": len(changed_items), - "total_changes": len(added_items) + len(removed_items) + len(changed_items) - }, - "changes": { - "added": added_items, - "removed": removed_items, - "changed": changed_items - }, - "category_stats": { - "added": added_stats, - "removed": removed_stats, - "changed": changed_stats - } - } - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"๋ฆฌ๋น„์ „ ๋น„๊ต ์‹คํŒจ: {str(e)}") - -@router.post("/materials/update-classification-details") -async def update_classification_details( - file_id: Optional[int] = None, - db: Session = Depends(get_db) -): - """๊ธฐ์กด ์ž์žฌ๋“ค์˜ classification_details ์—…๋ฐ์ดํŠธ""" - try: - # ์—…๋ฐ์ดํŠธํ•  ์ž์žฌ๋“ค ์กฐํšŒ - query = """ - SELECT id, original_description, size_spec, classified_category - FROM materials - WHERE classification_details IS NULL OR classification_details = '{}' - """ - params = {} - if file_id: - query += " AND file_id = :file_id" - params["file_id"] = file_id - - query += " ORDER BY id" - result = db.execute(text(query), params) - materials = result.fetchall() - - if not materials: - return { - "success": True, - "message": "์—…๋ฐ์ดํŠธํ•  ์ž์žฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", - "updated_count": 0 - } - - updated_count = 0 - for material in materials: - material_id = material.id - description = material.original_description - size_spec = material.size_spec - category = material.classified_category - - print(f"์ž์žฌ {material_id} ์žฌ๋ถ„๋ฅ˜ ์ค‘: {description}") - - # ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ์ ์ ˆํ•œ ๋ถ„๋ฅ˜๊ธฐ ํ˜ธ์ถœ - classification_result = None - - if category == 'PIPE': - classification_result = classify_pipe("", description, size_spec, 0.0) - elif category == 'FITTING': - classification_result = classify_fitting("", description, size_spec) - elif category == 'VALVE': - classification_result = classify_valve("", description, size_spec) - elif category == 'FLANGE': - classification_result = classify_flange("", description, size_spec) - elif category == 'BOLT': - classification_result = classify_bolt("", description, size_spec) - elif category == 'GASKET': - classification_result = classify_gasket("", description, size_spec) - elif category == 'INSTRUMENT': - classification_result = classify_instrument("", description, size_spec) - else: - # ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๋ชจ๋“  ๋ถ„๋ฅ˜๊ธฐ ์‹œ๋„ - classification_result = classify_pipe("", description, size_spec, 0.0) - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_fitting("", description, size_spec) - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_valve("", description, size_spec) - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_flange("", description, size_spec) - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_bolt("", description, size_spec) - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_gasket("", description, size_spec) - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_instrument("", description, size_spec) - - if classification_result: - # classification_details๋ฅผ JSON์œผ๋กœ ์ง๋ ฌํ™” - classification_details = json.dumps(classification_result, ensure_ascii=False) - - # DB ์—…๋ฐ์ดํŠธ - update_query = text(""" - UPDATE materials - SET classification_details = :classification_details, - updated_at = NOW() - WHERE id = :material_id - """) - - db.execute(update_query, { - "material_id": material_id, - "classification_details": classification_details - }) - - updated_count += 1 - print(f"์ž์žฌ {material_id} ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ") - - db.commit() - - return { - "success": True, - "message": f"{updated_count}๊ฐœ ์ž์žฌ์˜ ๋ถ„๋ฅ˜ ์ƒ์„ธ์ •๋ณด๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - "updated_count": updated_count, - "total_materials": len(materials) - } - - except Exception as e: - db.rollback() - raise HTTPException(status_code=500, detail=f"๋ถ„๋ฅ˜ ์ƒ์„ธ์ •๋ณด ์—…๋ฐ์ดํŠธ ์‹คํŒจ: {str(e)}") diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index 915a424..c7dbd82 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -61,3 +61,19 @@ __all__ = [ 'RolePermission', 'UserRepository' ] + + + + + + + + + + + + + + + + diff --git a/backend/app/auth/auth_controller.py b/backend/app/auth/auth_controller.py index a50c916..d6347e7 100644 --- a/backend/app/auth/auth_controller.py +++ b/backend/app/auth/auth_controller.py @@ -391,3 +391,19 @@ async def delete_user( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="์‚ฌ์šฉ์ž ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค" ) + + + + + + + + + + + + + + + + diff --git a/backend/app/auth/jwt_service.py b/backend/app/auth/jwt_service.py index a975ead..c2d5165 100644 --- a/backend/app/auth/jwt_service.py +++ b/backend/app/auth/jwt_service.py @@ -249,3 +249,19 @@ class JWTService: # JWT ์„œ๋น„์Šค ์ธ์Šคํ„ด์Šค jwt_service = JWTService() + + + + + + + + + + + + + + + + diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index 3389ea7..3d00ad2 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -303,3 +303,19 @@ async def get_current_user_optional( except Exception as e: logger.debug(f"Optional auth failed: {str(e)}") return None + + + + + + + + + + + + + + + + diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py index 5795364..a11b42a 100644 --- a/backend/app/auth/models.py +++ b/backend/app/auth/models.py @@ -352,3 +352,19 @@ class UserRepository: self.db.rollback() logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}") raise + + + + + + + + + + + + + + + + diff --git a/backend/app/config.py b/backend/app/config.py index 6a19fbc..37e1f37 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -205,8 +205,10 @@ class Settings(BaseSettings): "development": [ "http://localhost:3000", "http://localhost:5173", + "http://localhost:13000", "http://127.0.0.1:3000", - "http://127.0.0.1:5173" + "http://127.0.0.1:5173", + "http://127.0.0.1:13000" ], "production": [ "https://your-domain.com", diff --git a/backend/app/main.py b/backend/app/main.py index 5cf6d5a..af5cfab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,7 +18,7 @@ settings = get_settings() # ๋กœ๊ฑฐ ์„ค์ • logger = get_logger(__name__) -# FastAPI ์•ฑ ์ƒ์„ฑ +# FastAPI ์•ฑ ์ƒ์„ฑ (์š”์ฒญ ํฌ๊ธฐ ์ œํ•œ ์ฆ๊ฐ€) app = FastAPI( title=settings.app_name, description="์ž์žฌ ๋ถ„๋ฅ˜ ๋ฐ ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ", @@ -26,6 +26,27 @@ app = FastAPI( debug=settings.debug ) +# ์š”์ฒญ ํฌ๊ธฐ ์ œํ•œ ์„ค์ • (100MB๋กœ ์ฆ๊ฐ€) +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, max_request_size: int = 100 * 1024 * 1024): # 100MB + super().__init__(app) + self.max_request_size = max_request_size + + async def dispatch(self, request: Request, call_next): + if "content-length" in request.headers: + content_length = int(request.headers["content-length"]) + if content_length > self.max_request_size: + return Response("Request Entity Too Large", status_code=413) + return await call_next(request) + +# ์š”์ฒญ ํฌ๊ธฐ ์ œํ•œ ๋ฏธ๋“ค์›จ์–ด ์ถ”๊ฐ€ +app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024) + # ์—๋Ÿฌ ํ•ธ๋“ค๋Ÿฌ ์„ค์ • setup_error_handlers(app) @@ -38,10 +59,11 @@ app.add_middleware( logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}") -# ๋ผ์šฐํ„ฐ๋“ค import ๋ฐ ๋“ฑ๋ก +# ๋ผ์šฐํ„ฐ๋“ค import ๋ฐ ๋“ฑ๋ก - files ๋ผ์šฐํ„ฐ๋ฅผ ์ตœ์šฐ์„ ์œผ๋กœ ๋“ฑ๋ก try: from .routers import files app.include_router(files.router, prefix="/files", tags=["files"]) + logger.info("FILES ๋ผ์šฐํ„ฐ ๋“ฑ๋ก ์™„๋ฃŒ - ์ตœ์šฐ์„ ") except ImportError: logger.warning("files ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") @@ -63,19 +85,26 @@ try: except ImportError: logger.warning("material_comparison ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") +try: + from .routers import dashboard + app.include_router(dashboard.router, tags=["dashboard"]) +except ImportError: + logger.warning("dashboard ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + try: from .routers import tubing app.include_router(tubing.router, prefix="/tubing", tags=["tubing"]) except ImportError: logger.warning("tubing ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") -# ํŒŒ์ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก -try: - from .api import file_management - app.include_router(file_management.router, tags=["file-management"]) - logger.info("ํŒŒ์ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก ์™„๋ฃŒ") -except ImportError as e: - logger.warning(f"ํŒŒ์ผ ๊ด€๋ฆฌ ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {e}") +# ํŒŒ์ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก (๋น„ํ™œ์„ฑํ™” - files ๋ผ์šฐํ„ฐ์™€ ์ถฉ๋Œ ๋ฐฉ์ง€) +# try: +# from .api import file_management +# app.include_router(file_management.router, tags=["file-management"]) +# logger.info("ํŒŒ์ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก ์™„๋ฃŒ") +# except ImportError as e: +# logger.warning(f"ํŒŒ์ผ ๊ด€๋ฆฌ ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {e}") +logger.info("ํŒŒ์ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ ๋น„ํ™œ์„ฑํ™”๋จ (files ๋ผ์šฐํ„ฐ ์‚ฌ์šฉ)") # ์ธ์ฆ API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก try: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..4a4abb8 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,427 @@ +""" +๋Œ€์‹œ๋ณด๋“œ API +์‚ฌ์šฉ์ž๋ณ„ ๋งž์ถคํ˜• ๋Œ€์‹œ๋ณด๋“œ ๋ฐ์ดํ„ฐ ์ œ๊ณต +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text, func +from typing import Optional, Dict, Any, List +from datetime import datetime, timedelta + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..services.activity_logger import ActivityLogger +from ..utils.logger import get_logger + +logger = get_logger(__name__) +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +@router.get("/stats") +async def get_dashboard_stats( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + ์‚ฌ์šฉ์ž๋ณ„ ๋งž์ถคํ˜• ๋Œ€์‹œ๋ณด๋“œ ํ†ต๊ณ„ ๋ฐ์ดํ„ฐ ์กฐํšŒ + + Returns: + dict: ์‚ฌ์šฉ์ž ์—ญํ• ์— ๋งž๋Š” ํ†ต๊ณ„ ๋ฐ์ดํ„ฐ + """ + try: + username = current_user.get('username') + user_role = current_user.get('role', 'user') + + # ์—ญํ• ๋ณ„ ๋งž์ถค ํ†ต๊ณ„ ์ƒ์„ฑ + if user_role == 'admin': + stats = await get_admin_stats(db) + elif user_role == 'manager': + stats = await get_manager_stats(db, username) + elif user_role == 'designer': + stats = await get_designer_stats(db, username) + elif user_role == 'purchaser': + stats = await get_purchaser_stats(db, username) + else: + stats = await get_user_stats(db, username) + + return { + "success": True, + "user_role": user_role, + "stats": stats + } + + except Exception as e: + logger.error(f"Dashboard stats error: {str(e)}") + raise HTTPException(status_code=500, detail=f"๋Œ€์‹œ๋ณด๋“œ ํ†ต๊ณ„ ์กฐํšŒ ์‹คํŒจ: {str(e)}") + + +@router.get("/activities") +async def get_user_activities( + current_user: dict = Depends(get_current_user), + limit: int = Query(10, ge=1, le=50), + db: Session = Depends(get_db) +): + """ + ์‚ฌ์šฉ์ž ํ™œ๋™ ์ด๋ ฅ ์กฐํšŒ + + Args: + limit: ์กฐํšŒํ•  ํ™œ๋™ ์ˆ˜ (1-50) + + Returns: + dict: ์‚ฌ์šฉ์ž ํ™œ๋™ ์ด๋ ฅ + """ + try: + username = current_user.get('username') + + activity_logger = ActivityLogger(db) + activities = activity_logger.get_user_activities( + username=username, + limit=limit + ) + + return { + "success": True, + "activities": activities, + "total": len(activities) + } + + except Exception as e: + logger.error(f"User activities error: {str(e)}") + raise HTTPException(status_code=500, detail=f"ํ™œ๋™ ์ด๋ ฅ ์กฐํšŒ ์‹คํŒจ: {str(e)}") + + +@router.get("/recent-activities") +async def get_recent_activities( + current_user: dict = Depends(get_current_user), + days: int = Query(7, ge=1, le=30), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + ์ตœ๊ทผ ์ „์ฒด ํ™œ๋™ ์กฐํšŒ (๊ด€๋ฆฌ์ž/๋งค๋‹ˆ์ €์šฉ) + + Args: + days: ์กฐํšŒ ๊ธฐ๊ฐ„ (์ผ) + limit: ์กฐํšŒํ•  ํ™œ๋™ ์ˆ˜ + + Returns: + dict: ์ตœ๊ทผ ํ™œ๋™ ์ด๋ ฅ + """ + try: + user_role = current_user.get('role', 'user') + + # ๊ด€๋ฆฌ์ž์™€ ๋งค๋‹ˆ์ €๋งŒ ์ „์ฒด ํ™œ๋™ ์กฐํšŒ ๊ฐ€๋Šฅ + if user_role not in ['admin', 'manager']: + raise HTTPException(status_code=403, detail="๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค") + + activity_logger = ActivityLogger(db) + activities = activity_logger.get_recent_activities( + days=days, + limit=limit + ) + + return { + "success": True, + "activities": activities, + "period_days": days, + "total": len(activities) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Recent activities error: {str(e)}") + raise HTTPException(status_code=500, detail=f"์ตœ๊ทผ ํ™œ๋™ ์กฐํšŒ ์‹คํŒจ: {str(e)}") + + +async def get_admin_stats(db: Session) -> Dict[str, Any]: + """๊ด€๋ฆฌ์ž์šฉ ํ†ต๊ณ„""" + try: + # ์ „์ฒด ํ”„๋กœ์ ํŠธ ์ˆ˜ + total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'") + total_projects = db.execute(total_projects_query).scalar() + + # ํ™œ์„ฑ ์‚ฌ์šฉ์ž ์ˆ˜ (์ตœ๊ทผ 30์ผ ๋กœ๊ทธ์ธ) + active_users_query = text(""" + SELECT COUNT(DISTINCT username) + FROM user_activity_logs + WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' + """) + active_users = db.execute(active_users_query).scalar() or 0 + + # ์˜ค๋Š˜ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ˆ˜ + today_uploads_query = text(""" + SELECT COUNT(*) + FROM files + WHERE DATE(upload_date) = CURRENT_DATE + """) + today_uploads = db.execute(today_uploads_query).scalar() or 0 + + # ์ „์ฒด ์ž์žฌ ์ˆ˜ + total_materials_query = text("SELECT COUNT(*) FROM materials") + total_materials = db.execute(total_materials_query).scalar() or 0 + + return { + "title": "์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž", + "subtitle": "์ „์ฒด ์‹œ์Šคํ…œ์„ ๊ด€๋ฆฌํ•˜๊ณ  ๋ชจ๋‹ˆํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค", + "metrics": [ + {"label": "์ „์ฒด ํ”„๋กœ์ ํŠธ ์ˆ˜", "value": total_projects, "icon": "๐Ÿ“‹", "color": "#667eea"}, + {"label": "ํ™œ์„ฑ ์‚ฌ์šฉ์ž ์ˆ˜", "value": active_users, "icon": "๐Ÿ‘ฅ", "color": "#48bb78"}, + {"label": "์‹œ์Šคํ…œ ์ƒํƒœ", "value": "์ •์ƒ", "icon": "๐ŸŸข", "color": "#38b2ac"}, + {"label": "์˜ค๋Š˜ ์—…๋กœ๋“œ", "value": today_uploads, "icon": "๐Ÿ“ค", "color": "#ed8936"} + ] + } + + except Exception as e: + logger.error(f"Admin stats error: {str(e)}") + raise + + +async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]: + """๋งค๋‹ˆ์ €์šฉ ํ†ต๊ณ„""" + try: + # ๋‹ด๋‹น ํ”„๋กœ์ ํŠธ ์ˆ˜ (ํ–ฅํ›„ assigned_to ํ•„๋“œ ํ™œ์šฉ) + assigned_projects_query = text(""" + SELECT COUNT(*) + FROM jobs + WHERE (assigned_to = :username OR created_by = :username) + AND status != 'deleted' + """) + assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0 + + # ์ด๋ฒˆ ์ฃผ ์™„๋ฃŒ๋œ ์ž‘์—… (ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ฐ˜) + week_completed_query = text(""" + SELECT COUNT(*) + FROM user_activity_logs + WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM') + AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """) + week_completed = db.execute(week_completed_query).scalar() or 0 + + # ์Šน์ธ ๋Œ€๊ธฐ (๊ตฌ๋งค ํ™•์ • ๋Œ€๊ธฐ ๋“ฑ) + pending_approvals_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status = 'PENDING' + OR purchase_status = 'REQUESTED' + """) + pending_approvals = db.execute(pending_approvals_query).scalar() or 0 + + return { + "title": "ํ”„๋กœ์ ํŠธ ๋งค๋‹ˆ์ €", + "subtitle": "ํŒ€ ํ”„๋กœ์ ํŠธ๋ฅผ ๊ด€๋ฆฌํ•˜๊ณ  ์ง„ํ–‰์ƒํ™ฉ์„ ๋ชจ๋‹ˆํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค", + "metrics": [ + {"label": "๋‹ด๋‹น ํ”„๋กœ์ ํŠธ", "value": assigned_projects, "icon": "๐Ÿ“‹", "color": "#667eea"}, + {"label": "ํŒ€ ์ง„ํ–‰๋ฅ ", "value": "87%", "icon": "๐Ÿ“ˆ", "color": "#48bb78"}, + {"label": "์Šน์ธ ๋Œ€๊ธฐ", "value": pending_approvals, "icon": "โณ", "color": "#ed8936"}, + {"label": "์ด๋ฒˆ ์ฃผ ์™„๋ฃŒ", "value": week_completed, "icon": "โœ…", "color": "#38b2ac"} + ] + } + + except Exception as e: + logger.error(f"Manager stats error: {str(e)}") + raise + + +async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]: + """์„ค๊ณ„์ž์šฉ ํ†ต๊ณ„""" + try: + # ๋‚ด๊ฐ€ ์—…๋กœ๋“œํ•œ BOM ํŒŒ์ผ ์ˆ˜ + my_files_query = text(""" + SELECT COUNT(*) + FROM files + WHERE uploaded_by = :username + AND is_active = true + """) + my_files = db.execute(my_files_query, {"username": username}).scalar() or 0 + + # ๋ถ„๋ฅ˜๋œ ์ž์žฌ ์ˆ˜ + classified_materials_query = text(""" + SELECT COUNT(*) + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.uploaded_by = :username + AND m.classified_category IS NOT NULL + """) + classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0 + + # ๊ฒ€์ฆ ๋Œ€๊ธฐ ์ž์žฌ ์ˆ˜ + pending_verification_query = text(""" + SELECT COUNT(*) + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.uploaded_by = :username + AND m.is_verified = false + """) + pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0 + + # ์ด๋ฒˆ ์ฃผ ์—…๋กœ๋“œ ์ˆ˜ + week_uploads_query = text(""" + SELECT COUNT(*) + FROM files + WHERE uploaded_by = :username + AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """) + week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0 + + # ๋ถ„๋ฅ˜ ์™„๋ฃŒ์œจ ๊ณ„์‚ฐ + total_materials_query = text(""" + SELECT COUNT(*) + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.uploaded_by = :username + """) + total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1 + + classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%" + + return { + "title": "์„ค๊ณ„ ๋‹ด๋‹น์ž", + "subtitle": "BOM ํŒŒ์ผ์„ ๊ด€๋ฆฌํ•˜๊ณ  ์ž์žฌ๋ฅผ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค", + "metrics": [ + {"label": "๋‚ด BOM ํŒŒ์ผ", "value": my_files, "icon": "๐Ÿ“„", "color": "#667eea"}, + {"label": "๋ถ„๋ฅ˜ ์™„๋ฃŒ์œจ", "value": classification_rate, "icon": "๐ŸŽฏ", "color": "#48bb78"}, + {"label": "๊ฒ€์ฆ ๋Œ€๊ธฐ", "value": pending_verification, "icon": "โณ", "color": "#ed8936"}, + {"label": "์ด๋ฒˆ ์ฃผ ์—…๋กœ๋“œ", "value": week_uploads, "icon": "๐Ÿ“ค", "color": "#9f7aea"} + ] + } + + except Exception as e: + logger.error(f"Designer stats error: {str(e)}") + raise + + +async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]: + """๊ตฌ๋งค์ž์šฉ ํ†ต๊ณ„""" + try: + # ๊ตฌ๋งค ์š”์ฒญ ์ˆ˜ + purchase_requests_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status IN ('PENDING', 'REQUESTED') + """) + purchase_requests = db.execute(purchase_requests_query).scalar() or 0 + + # ๋ฐœ์ฃผ ์™„๋ฃŒ ์ˆ˜ + orders_completed_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status = 'CONFIRMED' + AND confirmed_by = :username + """) + orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0 + + # ์ž…๊ณ  ๋Œ€๊ธฐ ์ˆ˜ + receiving_pending_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status = 'ORDERED' + """) + receiving_pending = db.execute(receiving_pending_query).scalar() or 0 + + # ์ด๋ฒˆ ๋‹ฌ ๊ตฌ๋งค ๊ธˆ์•ก (์ž„์‹œ ๋ฐ์ดํ„ฐ) + monthly_amount = "โ‚ฉ2.3M" # ์‹ค์ œ๋กœ๋Š” ๊ณ„์‚ฐ ํ•„์š” + + return { + "title": "๊ตฌ๋งค ๋‹ด๋‹น์ž", + "subtitle": "๊ตฌ๋งค ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ๋ฐœ์ฃผ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค", + "metrics": [ + {"label": "๊ตฌ๋งค ์š”์ฒญ", "value": purchase_requests, "icon": "๐Ÿ›’", "color": "#667eea"}, + {"label": "๋ฐœ์ฃผ ์™„๋ฃŒ", "value": orders_completed, "icon": "โœ…", "color": "#48bb78"}, + {"label": "์ž…๊ณ  ๋Œ€๊ธฐ", "value": receiving_pending, "icon": "๐Ÿ“ฆ", "color": "#ed8936"}, + {"label": "์ด๋ฒˆ ๋‹ฌ ๊ธˆ์•ก", "value": monthly_amount, "icon": "๐Ÿ’ฐ", "color": "#9f7aea"} + ] + } + + except Exception as e: + logger.error(f"Purchaser stats error: {str(e)}") + raise + + +async def get_user_stats(db: Session, username: str) -> Dict[str, Any]: + """์ผ๋ฐ˜ ์‚ฌ์šฉ์ž์šฉ ํ†ต๊ณ„""" + try: + # ๋‚ด ํ™œ๋™ ์ˆ˜ (์ตœ๊ทผ 7์ผ) + my_activities_query = text(""" + SELECT COUNT(*) + FROM user_activity_logs + WHERE username = :username + AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """) + my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0 + + # ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ํ”„๋กœ์ ํŠธ ์ˆ˜ (์ž„์‹œ) + accessible_projects = 5 + + return { + "title": "์ผ๋ฐ˜ ์‚ฌ์šฉ์ž", + "subtitle": "ํ• ๋‹น๋œ ์—…๋ฌด๋ฅผ ์ˆ˜ํ–‰ํ•˜๊ณ  ํ”„๋กœ์ ํŠธ์— ์ฐธ์—ฌํ•ฉ๋‹ˆ๋‹ค", + "metrics": [ + {"label": "๋‚ด ์—…๋ฌด", "value": 6, "icon": "๐Ÿ“‹", "color": "#667eea"}, + {"label": "์™„๋ฃŒ์œจ", "value": "75%", "icon": "๐Ÿ“ˆ", "color": "#48bb78"}, + {"label": "๋Œ€๊ธฐ ์ค‘", "value": 2, "icon": "โณ", "color": "#ed8936"}, + {"label": "์ด๋ฒˆ ์ฃผ ํ™œ๋™", "value": my_activities, "icon": "๐ŸŽฏ", "color": "#9f7aea"} + ] + } + + except Exception as e: + logger.error(f"User stats error: {str(e)}") + raise + + +@router.get("/quick-actions") +async def get_quick_actions( + current_user: dict = Depends(get_current_user) +): + """ + ์‚ฌ์šฉ์ž ์—ญํ• ๋ณ„ ๋น ๋ฅธ ์ž‘์—… ๋ฉ”๋‰ด ์กฐํšŒ + + Returns: + dict: ์—ญํ• ๋ณ„ ๋น ๋ฅธ ์ž‘์—… ๋ชฉ๋ก + """ + try: + user_role = current_user.get('role', 'user') + + quick_actions = { + "admin": [ + {"title": "์‚ฌ์šฉ์ž ๊ด€๋ฆฌ", "icon": "๐Ÿ‘ค", "path": "/admin/users", "color": "#667eea"}, + {"title": "์‹œ์Šคํ…œ ์„ค์ •", "icon": "โš™๏ธ", "path": "/admin/settings", "color": "#48bb78"}, + {"title": "๋ฐฑ์—… ๊ด€๋ฆฌ", "icon": "๐Ÿ’พ", "path": "/admin/backup", "color": "#ed8936"}, + {"title": "ํ™œ๋™ ๋กœ๊ทธ", "icon": "๐Ÿ“Š", "path": "/admin/logs", "color": "#9f7aea"} + ], + "manager": [ + {"title": "ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ", "icon": "โž•", "path": "/projects/new", "color": "#667eea"}, + {"title": "ํŒ€ ๊ด€๋ฆฌ", "icon": "๐Ÿ‘ฅ", "path": "/team", "color": "#48bb78"}, + {"title": "์ง„ํ–‰ ์ƒํ™ฉ", "icon": "๐Ÿ“Š", "path": "/progress", "color": "#38b2ac"}, + {"title": "์Šน์ธ ์ฒ˜๋ฆฌ", "icon": "โœ…", "path": "/approvals", "color": "#ed8936"} + ], + "designer": [ + {"title": "BOM ์—…๋กœ๋“œ", "icon": "๐Ÿ“ค", "path": "/upload", "color": "#667eea"}, + {"title": "์ž์žฌ ๋ถ„๋ฅ˜", "icon": "๐Ÿ”ง", "path": "/materials", "color": "#48bb78"}, + {"title": "๋ฆฌ๋น„์ „ ๊ด€๋ฆฌ", "icon": "๐Ÿ”„", "path": "/revisions", "color": "#38b2ac"}, + {"title": "๋ถ„๋ฅ˜ ๊ฒ€์ฆ", "icon": "โœ…", "path": "/verify", "color": "#ed8936"} + ], + "purchaser": [ + {"title": "๊ตฌ๋งค ํ™•์ •", "icon": "๐Ÿ›’", "path": "/purchase", "color": "#667eea"}, + {"title": "๋ฐœ์ฃผ ๊ด€๋ฆฌ", "icon": "๐Ÿ“‹", "path": "/orders", "color": "#48bb78"}, + {"title": "๊ณต๊ธ‰์—…์ฒด", "icon": "๐Ÿข", "path": "/suppliers", "color": "#38b2ac"}, + {"title": "์ž…๊ณ  ์ฒ˜๋ฆฌ", "icon": "๐Ÿ“ฆ", "path": "/receiving", "color": "#ed8936"} + ], + "user": [ + {"title": "๋‚ด ์—…๋ฌด", "icon": "๐Ÿ“‹", "path": "/my-tasks", "color": "#667eea"}, + {"title": "ํ”„๋กœ์ ํŠธ ๋ณด๊ธฐ", "icon": "๐Ÿ‘๏ธ", "path": "/projects", "color": "#48bb78"}, + {"title": "๋ฆฌํฌํŠธ ๋‹ค์šด๋กœ๋“œ", "icon": "๐Ÿ“Š", "path": "/reports", "color": "#38b2ac"}, + {"title": "๋„์›€๋ง", "icon": "โ“", "path": "/help", "color": "#9f7aea"} + ] + } + + return { + "success": True, + "user_role": user_role, + "quick_actions": quick_actions.get(user_role, quick_actions["user"]) + } + + except Exception as e: + logger.error(f"Quick actions error: {str(e)}") + raise HTTPException(status_code=500, detail=f"๋น ๋ฅธ ์ž‘์—… ์กฐํšŒ ์‹คํŒจ: {str(e)}") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index a416ba4..e91ed98 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Body from sqlalchemy.orm import Session from sqlalchemy import text -from typing import List, Optional +from typing import List, Optional, Dict import os import shutil from datetime import datetime @@ -12,6 +12,8 @@ from pathlib import Path import json from ..database import get_db +from ..auth.middleware import get_current_user +from ..services.activity_logger import ActivityLogger, log_activity_from_request from ..utils.logger import get_logger from app.services.material_classifier import classify_material @@ -25,6 +27,7 @@ from app.services.gasket_classifier import classify_gasket from app.services.instrument_classifier import classify_instrument from app.services.pipe_classifier import classify_pipe from app.services.valve_classifier import classify_valve +from app.services.revision_comparator import get_revision_comparison router = APIRouter() @@ -32,13 +35,7 @@ UPLOAD_DIR = Path("uploads") UPLOAD_DIR.mkdir(exist_ok=True) ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} -@router.get("/") -async def get_files_info(): - return { - "message": "ํŒŒ์ผ ๊ด€๋ฆฌ API", - "allowed_extensions": list(ALLOWED_EXTENSIONS), - "upload_directory": str(UPLOAD_DIR) - } +# API ์ •๋ณด๋Š” /info ์—”๋“œํฌ์ธํŠธ๋กœ ์ด๋™๋จ @router.get("/test") async def test_endpoint(): @@ -74,9 +71,9 @@ def generate_unique_filename(original_filename: str) -> str: def parse_dataframe(df): df = df.dropna(how='all') # ์›๋ณธ ์ปฌ๋Ÿผ๋ช… ์ถœ๋ ฅ - print(f"์›๋ณธ ์ปฌ๋Ÿผ๋“ค: {list(df.columns)}") + # ๋กœ๊ทธ ์ œ๊ฑฐ df.columns = df.columns.str.strip().str.lower() - print(f"์†Œ๋ฌธ์ž ๋ณ€ํ™˜ ํ›„: {list(df.columns)}") + # ๋กœ๊ทธ ์ œ๊ฑฐ column_mapping = { 'description': ['description', 'item', 'material', 'ํ’ˆ๋ช…', '์ž์žฌ๋ช…'], @@ -96,7 +93,7 @@ def parse_dataframe(df): mapped_columns[standard_col] = possible_name break - print(f"์ฐพ์€ ์ปฌ๋Ÿผ ๋งคํ•‘: {mapped_columns}") + # ๋กœ๊ทธ ์ œ๊ฑฐ materials = [] for index, row in df.iterrows(): @@ -172,20 +169,16 @@ def parse_file_data(file_path): @router.post("/upload") async def upload_file( + request: Request, file: UploadFile = File(...), job_no: str = Form(...), revision: str = Form("Rev.0"), # ๊ธฐ๋ณธ๊ฐ’์€ Rev.0 (์ƒˆ BOM) parent_file_id: Optional[int] = Form(None), # ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ์‹œ ๋ถ€๋ชจ ํŒŒ์ผ ID bom_name: Optional[str] = Form(None), # BOM ์ด๋ฆ„ (์‚ฌ์šฉ์ž ์ž…๋ ฅ) - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) ): - print(f"๐Ÿ“ฅ ์—…๋กœ๋“œ ์š”์ฒญ ๋ฐ›์Œ:") - print(f" - ํŒŒ์ผ๋ช…: {file.filename}") - print(f" - job_no: {job_no}") - print(f" - revision: {revision}") - print(f" - parent_file_id: {parent_file_id}") - print(f" - bom_name: {bom_name}") - print(f" - parent_file_id ํƒ€์ž…: {type(parent_file_id)}") + # ๋กœ๊ทธ ์ œ๊ฑฐ if not validate_file_extension(file.filename): raise HTTPException( status_code=400, @@ -199,22 +192,22 @@ async def upload_file( file_path = UPLOAD_DIR / unique_filename try: - print("ํŒŒ์ผ ์ €์žฅ ์‹œ์ž‘") + # ๋กœ๊ทธ ์ œ๊ฑฐ with open(file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) - print(f"ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {file_path}") + # ๋กœ๊ทธ ์ œ๊ฑฐ except Exception as e: raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ: {str(e)}") try: - print("ํŒŒ์ผ ํŒŒ์‹ฑ ์‹œ์ž‘") + # ๋กœ๊ทธ ์ œ๊ฑฐ materials_data = parse_file_data(str(file_path)) parsed_count = len(materials_data) - print(f"ํŒŒ์‹ฑ ์™„๋ฃŒ: {parsed_count}๊ฐœ ์ž์žฌ") + # ๋กœ๊ทธ ์ œ๊ฑฐ # ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ์ธ ๊ฒฝ์šฐ๋งŒ ์ž๋™ ๋ฆฌ๋น„์ „ ์ƒ์„ฑ if parent_file_id is not None: - print(f"๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ๋ชจ๋“œ: parent_file_id = {parent_file_id}") + # ๋กœ๊ทธ ์ œ๊ฑฐ # ๋ถ€๋ชจ ํŒŒ์ผ์˜ ์ •๋ณด ์กฐํšŒ parent_query = text(""" SELECT original_filename, revision, bom_name FROM files @@ -268,11 +261,14 @@ async def upload_file( # ์ผ๋ฐ˜ ์—…๋กœ๋“œ (์ƒˆ BOM) print(f"์ผ๋ฐ˜ ์—…๋กœ๋“œ ๋ชจ๋“œ: ์ƒˆ BOM ํŒŒ์ผ (Rev.0)") - # ํŒŒ์ผ ์ •๋ณด ์ €์žฅ + # ํŒŒ์ผ ์ •๋ณด ์ €์žฅ (์‚ฌ์šฉ์ž ์ •๋ณด ํฌํ•จ) print("DB ์ €์žฅ ์‹œ์ž‘") + username = current_user.get('username', 'unknown') + user_id = current_user.get('user_id') + file_insert_query = text(""" - INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active) - VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active) + INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active, uploaded_by) + VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active, :uploaded_by) RETURNING id """) @@ -286,11 +282,43 @@ async def upload_file( "description": f"BOM ํŒŒ์ผ - {parsed_count}๊ฐœ ์ž์žฌ", "file_size": file.size, "parsed_count": parsed_count, - "is_active": True + "is_active": True, + "uploaded_by": username }) file_id = file_result.fetchone()[0] - print(f"ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: file_id = {file_id}") + print(f"ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: file_id = {file_id}, uploaded_by = {username}") + + # ๐Ÿ”„ ๋ฆฌ๋น„์ „ ๋น„๊ต ์ˆ˜ํ–‰ (RULES.md ์ฝ”๋”ฉ ์ปจ๋ฒค์…˜ ์ค€์ˆ˜) + revision_comparison = None + materials_to_classify = materials_data + + if revision != "Rev.0": # ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ์ธ ๊ฒฝ์šฐ๋งŒ ๋น„๊ต + # ๋กœ๊ทธ ์ œ๊ฑฐ + try: + revision_comparison = get_revision_comparison(db, job_no, revision, materials_data) + + if revision_comparison.get("has_previous_confirmation", False): + print(f"๐Ÿ“Š ๋ฆฌ๋น„์ „ ๋น„๊ต ๊ฒฐ๊ณผ:") + print(f" - ๋ณ€๊ฒฝ์—†์Œ: {revision_comparison.get('unchanged_count', 0)}๊ฐœ") + print(f" - ๋ณ€๊ฒฝ๋จ: {revision_comparison.get('changed_count', 0)}๊ฐœ") + print(f" - ์‹ ๊ทœ: {revision_comparison.get('new_count', 0)}๊ฐœ") + print(f" - ์‚ญ์ œ๋จ: {revision_comparison.get('removed_count', 0)}๊ฐœ") + print(f" - ๋ถ„๋ฅ˜ ํ•„์š”: {revision_comparison.get('classification_needed', 0)}๊ฐœ") + + # ๋ถ„๋ฅ˜๊ฐ€ ํ•„์š”ํ•œ ์ž์žฌ๋งŒ ์ถ”์ถœ (๋ณ€๊ฒฝ๋จ + ์‹ ๊ทœ) + materials_to_classify = ( + revision_comparison.get("changed_materials", []) + + revision_comparison.get("new_materials", []) + ) + else: + print("๐Ÿ“ ์ด์ „ ํ™•์ • ์ž๋ฃŒ ์—†์Œ - ์ „์ฒด ์ž์žฌ ๋ถ„๋ฅ˜") + + except Exception as e: + logger.error(f"๋ฆฌ๋น„์ „ ๋น„๊ต ์‹คํŒจ: {str(e)}") + print(f"โš ๏ธ ๋ฆฌ๋น„์ „ ๋น„๊ต ์‹คํŒจ, ์ „์ฒด ์ž์žฌ ๋ถ„๋ฅ˜๋กœ ์ง„ํ–‰: {str(e)}") + + print(f"๐Ÿ”ง ์ž์žฌ ๋ถ„๋ฅ˜ ์‹œ์ž‘: {len(materials_to_classify)}๊ฐœ ์ž์žฌ") # ์ž์žฌ ๋ฐ์ดํ„ฐ ์ €์žฅ (๋ถ„๋ฅ˜ ํฌํ•จ) - ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋กœ ์„ฑ๋Šฅ ๊ฐœ์„  materials_to_insert = [] @@ -301,7 +329,30 @@ async def upload_file( flange_details_to_insert = [] materials_inserted = 0 - for material_data in materials_data: + + # ๋ณ€๊ฒฝ์—†๋Š” ์ž์žฌ ๋จผ์ € ์ฒ˜๋ฆฌ (๊ธฐ์กด ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ์žฌ์‚ฌ์šฉ) + if revision_comparison and revision_comparison.get("has_previous_confirmation", False): + unchanged_materials = revision_comparison.get("unchanged_materials", []) + for material_data in unchanged_materials: + previous_item = material_data.get("previous_item", {}) + + # ๊ธฐ์กด ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ์žฌ์‚ฌ์šฉ + materials_to_insert.append({ + "file_id": file_id, + "original_description": material_data["original_description"], + "classified_category": previous_item.get("category", "UNKNOWN"), + "confidence": 1.0, # ํ™•์ •๋œ ์ž๋ฃŒ์ด๋ฏ€๋กœ ์‹ ๋ขฐ๋„ 100% + "quantity": material_data["quantity"], + "unit": material_data.get("unit", "EA"), + "size_spec": material_data.get("size_spec", ""), + "material_grade": previous_item.get("material", ""), + "specification": previous_item.get("specification", ""), + "reused_from_confirmation": True + }) + materials_inserted += 1 + + # ๋ถ„๋ฅ˜๊ฐ€ ํ•„์š”ํ•œ ์ž์žฌ ์ฒ˜๋ฆฌ + for material_data in materials_to_classify: # ์ž์žฌ ํƒ€์ž… ๋ถ„๋ฅ˜๊ธฐ ์ ์šฉ (PIPE, FITTING, VALVE ๋“ฑ) description = material_data["original_description"] size_spec = material_data["size_spec"] @@ -338,7 +389,8 @@ async def upload_file( material_type = integrated_result.get('category', 'UNKNOWN') if material_type == "PIPE": - classification_result = classify_pipe("", description, main_nom or "", length_value) + from ..services.pipe_classifier import classify_pipe_for_purchase + classification_result = classify_pipe_for_purchase("", description, main_nom or "", length_value) elif material_type == "FITTING": classification_result = classify_fitting("", description, main_nom or "", red_nom) elif material_type == "FLANGE": @@ -414,8 +466,39 @@ async def upload_file( if classification_result.get("category") == "PIPE": print("PIPE ์ƒ์„ธ ์ •๋ณด ์ €์žฅ ์‹œ์ž‘") - # ๊ธธ์ด ์ •๋ณด ์ถ”์ถœ - material_data์—์„œ ์ง์ ‘ ๊ฐ€์ ธ์˜ด - length_mm = material_data.get("length", 0.0) if material_data.get("length") else None + # ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ถ”์ถœ ๋ฐ ์ €์žฅ + from ..services.pipe_classifier import extract_end_preparation_info + end_prep_info = extract_end_preparation_info(description) + + # ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ํ…Œ์ด๋ธ”์— ์ €์žฅ + end_prep_insert_query = text(""" + INSERT INTO pipe_end_preparations ( + material_id, file_id, end_preparation_type, end_preparation_code, + machining_required, cutting_note, original_description, clean_description, + confidence, matched_pattern + ) VALUES ( + :material_id, :file_id, :end_preparation_type, :end_preparation_code, + :machining_required, :cutting_note, :original_description, :clean_description, + :confidence, :matched_pattern + ) + """) + + db.execute(end_prep_insert_query, { + "material_id": material_id, + "file_id": file_id, + "end_preparation_type": end_prep_info["end_preparation_type"], + "end_preparation_code": end_prep_info["end_preparation_code"], + "machining_required": end_prep_info["machining_required"], + "cutting_note": end_prep_info["cutting_note"], + "original_description": end_prep_info["original_description"], + "clean_description": end_prep_info["clean_description"], + "confidence": end_prep_info["confidence"], + "matched_pattern": end_prep_info["matched_pattern"] + }) + + # ๊ธธ์ด ์ •๋ณด ์ถ”์ถœ - ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ์˜ length_info ์šฐ์„  ์‚ฌ์šฉ + length_info = classification_result.get("length_info", {}) + length_mm = length_info.get("length_mm") or material_data.get("length", 0.0) if material_data.get("length") else None # material_id๋„ ํ•จ๊ป˜ ์ €์žฅํ•˜๋„๋ก ์ˆ˜์ • pipe_detail_insert_query = text(""" @@ -961,6 +1044,25 @@ async def upload_file( db.commit() print(f"์ž์žฌ ์ €์žฅ ์™„๋ฃŒ: {materials_inserted}๊ฐœ") + # ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก + try: + activity_logger = ActivityLogger(db) + activity_logger.log_file_upload( + username=username, + file_id=file_id, + filename=file.filename, + file_size=file.size or 0, + job_no=job_no, + revision=revision, + user_id=user_id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get('user-agent') + ) + print(f"ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก ์™„๋ฃŒ: {username} - ํŒŒ์ผ ์—…๋กœ๋“œ") + except Exception as e: + print(f"ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก ์‹คํŒจ: {str(e)}") + # ๋กœ๊ทธ ์‹คํŒจ๋Š” ์—…๋กœ๋“œ ์„ฑ๊ณต์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Œ + return { "success": True, "message": f"์—…๋กœ๋“œ ์„ฑ๊ณต! {materials_inserted}๊ฐœ ์ž์žฌ๊ฐ€ ๋ถ„๋ฅ˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", @@ -969,6 +1071,7 @@ async def upload_file( "materials_count": materials_inserted, "saved_materials_count": materials_inserted, "revision": revision, # ์ƒ์„ฑ๋œ ๋ฆฌ๋น„์ „ ์ •๋ณด ์ถ”๊ฐ€ + "uploaded_by": username, # ์—…๋กœ๋“œํ•œ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”๊ฐ€ "parsed_count": parsed_count } @@ -978,7 +1081,7 @@ async def upload_file( os.remove(file_path) raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ฒ˜๋ฆฌ ์‹คํŒจ: {str(e)}") -@router.get("/files") +@router.get("/") async def get_files( job_no: Optional[str] = None, db: Session = Depends(get_db) @@ -986,7 +1089,7 @@ async def get_files( """ํŒŒ์ผ ๋ชฉ๋ก ์กฐํšŒ""" try: query = """ - SELECT id, filename, original_filename, job_no, revision, + SELECT id, filename, original_filename, bom_name, job_no, revision, description, file_size, parsed_count, upload_date, is_active FROM files WHERE is_active = TRUE @@ -1007,6 +1110,7 @@ async def get_files( "id": file.id, "filename": file.filename, "original_filename": file.original_filename, + "bom_name": file.bom_name, "job_no": file.job_no, "revision": file.revision, "description": file.description, @@ -1062,7 +1166,7 @@ async def get_files_stats(db: Session = Depends(get_db)): except Exception as e: raise HTTPException(status_code=500, detail=f"ํ†ต๊ณ„ ์กฐํšŒ ์‹คํŒจ: {str(e)}") -@router.delete("/files/{file_id}") +@router.delete("/delete/{file_id}") async def delete_file(file_id: int, db: Session = Depends(get_db)): """ํŒŒ์ผ ์‚ญ์ œ""" try: @@ -1086,7 +1190,7 @@ async def delete_file(file_id: int, db: Session = Depends(get_db)): db.rollback() raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ: {str(e)}") -@router.get("/materials") +@router.get("/materials-v2") # ์™„์ „ํžˆ ์ƒˆ๋กœ์šด ์—”๋“œํฌ์ธํŠธ async def get_materials( project_id: Optional[int] = None, file_id: Optional[int] = None, @@ -1104,22 +1208,56 @@ async def get_materials( db: Session = Depends(get_db) ): """ - ์ €์žฅ๋œ ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ (job_no, filename, revision 3๊ฐ€์ง€๋กœ ํ•„ํ„ฐ๋ง ๊ฐ€๋Šฅ) + ์ €์žฅ๋œ ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ (job_no, filename, revision 3๊ฐ€์ง€๋กœ ํ•„ํ„ฐ๋ง ๊ฐ€๋Šฅ) - ์‹ ๋ฒ„์ „ """ try: + # ๋กœ๊ทธ ์ œ๊ฑฐ - ๊ณผ๋„ํ•œ ์ถœ๋ ฅ ๋ฐฉ์ง€ query = """ SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number, m.created_at, m.classified_category, m.classification_confidence, m.classification_details, + m.is_verified, m.verified_by, m.verified_at, f.original_filename, f.project_id, f.job_no, f.revision, p.official_project_code, p.project_name, pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method, - pd.end_preparation, pd.length_mm + pd.end_preparation, pd.length_mm, + pep.end_preparation_type, pep.end_preparation_code, pep.machining_required, + pep.cutting_note, pep.clean_description as pipe_clean_description, + fd.fitting_type, fd.fitting_subtype, fd.connection_method, fd.pressure_rating, + fd.material_standard, fd.material_grade as fitting_material_grade, fd.main_size, + fd.reduced_size, fd.length_mm as fitting_length_mm, fd.schedule as fitting_schedule, + mpt.confirmed_quantity, mpt.purchase_status, mpt.confirmed_by, mpt.confirmed_at, + -- ๊ตฌ๋งค์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ์—์„œ ๋ถ„๋ฅ˜๋œ ์ •๋ณด๋ฅผ ์šฐ์„  ์‚ฌ์šฉ + CASE + WHEN mpt.id IS NOT NULL THEN + CASE + WHEN mpt.description LIKE '%PIPE%' OR mpt.description LIKE '%ํŒŒ์ดํ”„%' THEN 'PIPE' + WHEN mpt.description LIKE '%FITTING%' OR mpt.description LIKE '%ํ”ผํŒ…%' OR mpt.description LIKE '%NIPPLE%' OR mpt.description LIKE '%ELBOW%' OR mpt.description LIKE '%TEE%' OR mpt.description LIKE '%REDUCER%' THEN 'FITTING' + WHEN mpt.description LIKE '%VALVE%' OR mpt.description LIKE '%๋ฐธ๋ธŒ%' THEN 'VALVE' + WHEN mpt.description LIKE '%FLANGE%' OR mpt.description LIKE '%ํ”Œ๋žœ์ง€%' THEN 'FLANGE' + WHEN mpt.description LIKE '%BOLT%' OR mpt.description LIKE '%๋ณผํŠธ%' OR mpt.description LIKE '%STUD%' THEN 'BOLT' + WHEN mpt.description LIKE '%GASKET%' OR mpt.description LIKE '%๊ฐ€์Šค์ผ“%' THEN 'GASKET' + WHEN mpt.description LIKE '%INSTRUMENT%' OR mpt.description LIKE '%๊ณ„๊ธฐ%' THEN 'INSTRUMENT' + ELSE m.classified_category + END + ELSE m.classified_category + END as final_classified_category, + -- ๊ตฌ๋งค์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์™„๋ฃŒ ์—ฌ๋ถ€ + CASE WHEN mpt.id IS NOT NULL THEN true ELSE m.is_verified END as final_is_verified, + CASE WHEN mpt.id IS NOT NULL THEN 'purchase_calculation' ELSE m.verified_by END as final_verified_by FROM materials m LEFT JOIN files f ON m.file_id = f.id LEFT JOIN projects p ON f.project_id = p.id LEFT JOIN pipe_details pd ON m.id = pd.material_id + LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id + LEFT JOIN fitting_details fd ON m.id = fd.material_id + LEFT JOIN valve_details vd ON m.id = vd.material_id + LEFT JOIN material_purchase_tracking mpt ON ( + m.material_hash = mpt.material_hash + AND f.job_no = mpt.job_no + AND f.revision = mpt.revision + ) WHERE 1=1 """ params = {} @@ -1220,9 +1358,34 @@ async def get_materials( count_result = db.execute(text(count_query), count_params) total_count = count_result.fetchone()[0] + # ํŒŒ์ดํ”„ ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ + pipe_groups = {} + # ๋‹ˆํ”Œ ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ (๊ธธ์ด ๊ธฐ๋ฐ˜) + nipple_groups = {} + # ์ผ๋ฐ˜ ํ”ผํŒ… ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ (์ˆ˜๋Ÿ‰ ๊ธฐ๋ฐ˜) + fitting_groups = {} + # ํ”Œ๋žœ์ง€ ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ + flange_groups = {} + # ๋ฐธ๋ธŒ ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ + valve_groups = {} + # ๋ณผํŠธ ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ + bolt_groups = {} + # ๊ฐ€์Šค์ผ“ ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ + gasket_groups = {} + # UNKNOWN ๊ทธ๋ฃนํ•‘์„ ์œ„ํ•œ ๋”•์…”๋„ˆ๋ฆฌ + unknown_groups = {} + # ๊ฐ ์ž์žฌ์˜ ์ƒ์„ธ ์ •๋ณด๋„ ๊ฐ€์ ธ์˜ค๊ธฐ material_list = [] + valve_count = 0 for m in materials: + if m.classified_category == 'VALVE': + valve_count += 1 + # ๋””๋ฒ„๊น…: ์ฒซ ๋ฒˆ์งธ ์ž์žฌ์˜ ๋ชจ๋“  ์†์„ฑ ์ถœ๋ ฅ + if len(material_list) == 0: + # ๋กœ๊ทธ ์ œ๊ฑฐ + pass + material_dict = { "id": m.id, "file_id": m.file_id, @@ -1239,9 +1402,18 @@ async def get_materials( "material_grade": m.material_grade, "line_number": m.line_number, "row_number": m.row_number, - "classified_category": m.classified_category, + # ๊ตฌ๋งค์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ์—์„œ ๋ถ„๋ฅ˜๋œ ์ •๋ณด๋ฅผ ์šฐ์„  ์‚ฌ์šฉ + "classified_category": m.final_classified_category or m.classified_category, "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, "classification_details": m.classification_details, + "is_verified": m.final_is_verified if m.final_is_verified is not None else m.is_verified, + "verified_by": m.final_verified_by or m.verified_by, + "verified_at": m.verified_at, + "purchase_confirmed": bool(m.confirmed_quantity), + "confirmed_quantity": float(m.confirmed_quantity) if m.confirmed_quantity else None, + "purchase_status": m.purchase_status, + "purchase_confirmed_by": m.confirmed_by, + "purchase_confirmed_at": m.confirmed_at, "created_at": m.created_at } @@ -1249,7 +1421,7 @@ async def get_materials( if m.classified_category == 'PIPE': # JOIN๋œ ๊ฒฐ๊ณผ์—์„œ pipe_details ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ if hasattr(m, 'outer_diameter') and m.outer_diameter is not None: - material_dict['pipe_details'] = { + pipe_details = { "outer_diameter": m.outer_diameter, "schedule": m.schedule, "material_spec": m.material_spec, @@ -1257,23 +1429,119 @@ async def get_materials( "end_preparation": m.end_preparation, "length_mm": float(m.length_mm) if m.length_mm else None } + + # ํŒŒ์ดํ”„ ๊ทธ๋ฃนํ•‘ ํ‚ค ์ƒ์„ฑ (๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ œ์™ธํ•˜๊ณ  ๊ทธ๋ฃนํ•‘) + # pep ํ…Œ์ด๋ธ”์—์„œ clean_description์„ ๊ฐ€์ ธ์˜ค๊ฑฐ๋‚˜, ์—†์œผ๋ฉด ์ง์ ‘ ๊ณ„์‚ฐ + if hasattr(m, 'pipe_clean_description') and m.pipe_clean_description: + clean_description = m.pipe_clean_description + else: + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + pipe_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + # ๋กœ๊ทธ ์ œ๊ฑฐ - ๊ณผ๋„ํ•œ ์ถœ๋ ฅ ๋ฐฉ์ง€ + + if pipe_key not in pipe_groups: + pipe_groups[pipe_key] = { + "total_length_mm": 0, + "total_quantity": 0, + "materials": [] + } + + # ๊ฐœ๋ณ„ ํŒŒ์ดํ”„ ๊ธธ์ด ํ•ฉ์‚ฐ (DB์— ์ €์žฅ๋œ ์‹ค์ œ ๊ธธ์ด ์‚ฌ์šฉ) + if pipe_details["length_mm"]: + # โœ… DB์—์„œ ๊ฐ€์ ธ์˜จ length_mm๋Š” ์ด๋ฏธ ๊ฐœ๋ณ„ ํŒŒ์ดํ”„์˜ ์‹ค์ œ ๊ธธ์ด์ด๋ฏ€๋กœ ์ˆ˜๋Ÿ‰์„ ๊ณฑํ•˜์ง€ ์•Š์Œ + individual_length = float(pipe_details["length_mm"]) + pipe_groups[pipe_key]["total_length_mm"] += individual_length + pipe_groups[pipe_key]["total_quantity"] += 1 # ํŒŒ์ดํ”„ ๊ฐœ์ˆ˜๋Š” 1๊ฐœ์”ฉ ์ฆ๊ฐ€ + pipe_groups[pipe_key]["materials"].append(material_dict) + + # ๊ฐœ๋ณ„ ๊ธธ์ด ์ •๋ณด๋ฅผ pipe_details์— ์ถ”๊ฐ€ + pipe_details["individual_total_length"] = individual_length + + # ๊ตฌ๋งค์šฉ ๊นจ๋—ํ•œ ์„ค๋ช…๋„ ์ถ”๊ฐ€ + material_dict['clean_description'] = clean_description + material_dict['pipe_details'] = pipe_details elif m.classified_category == 'FITTING': - fitting_query = text("SELECT * FROM fitting_details WHERE material_id = :material_id") - fitting_result = db.execute(fitting_query, {"material_id": m.id}) - fitting_detail = fitting_result.fetchone() - if fitting_detail: - material_dict['fitting_details'] = { - "fitting_type": fitting_detail.fitting_type, - "fitting_subtype": fitting_detail.fitting_subtype, - "connection_method": fitting_detail.connection_method, - "pressure_rating": fitting_detail.pressure_rating, - "material_standard": fitting_detail.material_standard, - "material_grade": fitting_detail.material_grade, - "main_size": fitting_detail.main_size, - "reduced_size": fitting_detail.reduced_size, - "length_mm": float(fitting_detail.length_mm) if fitting_detail.length_mm else None, - "schedule": fitting_detail.schedule + # CAP๊ณผ PLUG ๋จผ์ € ์ฒ˜๋ฆฌ (fitting_type์ด ์—†์„ ์ˆ˜ ์žˆ์Œ) + if 'CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper(): + # CAP๊ณผ PLUG ๊ทธ๋ฃนํ•‘ + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + if fitting_key not in fitting_groups: + fitting_groups[fitting_key] = { + "total_quantity": 0, + "materials": [] + } + + fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"] + fitting_groups[fitting_key]["materials"].append(material_dict) + material_dict['clean_description'] = clean_description + # JOIN๋œ fitting_details ๋ฐ์ดํ„ฐ ์ง์ ‘ ์‚ฌ์šฉ + elif hasattr(m, 'fitting_type') and m.fitting_type is not None: + # ๋กœ๊ทธ ์ œ๊ฑฐ - ๊ณผ๋„ํ•œ ์ถœ๋ ฅ ๋ฐฉ์ง€ + + fitting_details = { + "fitting_type": m.fitting_type, + "fitting_subtype": m.fitting_subtype, + "connection_method": m.connection_method, + "pressure_rating": m.pressure_rating, + "material_standard": m.material_standard, + "material_grade": m.fitting_material_grade, + "main_size": m.main_size, + "reduced_size": m.reduced_size, + "length_mm": float(m.fitting_length_mm) if m.fitting_length_mm else None, + "schedule": m.fitting_schedule } + material_dict['fitting_details'] = fitting_details + + # ๋‹ˆํ”Œ์ธ ๊ฒฝ์šฐ ๊ธธ์ด ๊ธฐ๋ฐ˜ ๊ทธ๋ฃนํ•‘ + if 'NIPPLE' in m.original_description.upper() and m.fitting_length_mm: + # ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ œ๊ฑฐ + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + nipple_key = f"{clean_description}|{m.size_spec}|{m.material_grade}|{m.fitting_length_mm}mm" + + # ๋กœ๊ทธ ์ œ๊ฑฐ - ๊ณผ๋„ํ•œ ์ถœ๋ ฅ ๋ฐฉ์ง€ + + if nipple_key not in nipple_groups: + nipple_groups[nipple_key] = { + "total_length_mm": 0, + "total_quantity": 0, + "materials": [] + } + + # ๊ฐœ๋ณ„ ๋‹ˆํ”Œ ๊ธธ์ด ํ•ฉ์‚ฐ (์ˆ˜๋Ÿ‰ ร— ๋‹จ์œ„๊ธธ์ด) - ํƒ€์ž… ๋ณ€ํ™˜ + individual_total_length = float(material_dict["quantity"]) * float(m.fitting_length_mm) + nipple_groups[nipple_key]["total_length_mm"] += individual_total_length + nipple_groups[nipple_key]["total_quantity"] += material_dict["quantity"] + nipple_groups[nipple_key]["materials"].append(material_dict) + + # ์ด๊ธธ์ด ์ •๋ณด๋ฅผ fitting_details์— ์ถ”๊ฐ€ + fitting_details["individual_total_length"] = individual_total_length + fitting_details["is_nipple"] = True + + # ๊ตฌ๋งค์šฉ ๊นจ๋—ํ•œ ์„ค๋ช…๋„ ์ถ”๊ฐ€ + material_dict['clean_description'] = clean_description + else: + # ์ผ๋ฐ˜ ํ”ผํŒ… (๋‹ˆํ”Œ์ด ์•„๋‹Œ ๊ฒฝ์šฐ) - ์ˆ˜๋Ÿ‰ ๊ธฐ๋ฐ˜ ๊ทธ๋ฃนํ•‘ + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + if fitting_key not in fitting_groups: + fitting_groups[fitting_key] = { + "total_quantity": 0, + "materials": [] + } + + fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"] + fitting_groups[fitting_key]["materials"].append(material_dict) + + # ๊ตฌ๋งค์šฉ ๊นจ๋—ํ•œ ์„ค๋ช…๋„ ์ถ”๊ฐ€ + material_dict['clean_description'] = clean_description elif m.classified_category == 'FLANGE': flange_query = text("SELECT * FROM flange_details WHERE material_id = :material_id") flange_result = db.execute(flange_query, {"material_id": m.id}) @@ -1287,6 +1555,21 @@ async def get_materials( "material_grade": flange_detail.material_grade, "size_inches": flange_detail.size_inches } + + # ํ”Œ๋žœ์ง€ ๊ทธ๋ฃนํ•‘ ์ถ”๊ฐ€ + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + flange_key = f"{m.size_spec}|{m.material_grade}|{flange_detail.pressure_rating if flange_detail else ''}|{flange_detail.flange_type if flange_detail else ''}" + + if flange_key not in flange_groups: + flange_groups[flange_key] = { + "total_quantity": 0, + "materials": [] + } + + flange_groups[flange_key]["total_quantity"] += material_dict["quantity"] + flange_groups[flange_key]["materials"].append(material_dict) + material_dict['clean_description'] = clean_description elif m.classified_category == 'GASKET': gasket_query = text("SELECT * FROM gasket_details WHERE material_id = :material_id") gasket_result = db.execute(gasket_query, {"material_id": m.id}) @@ -1300,6 +1583,20 @@ async def get_materials( "thickness": gasket_detail.thickness, "temperature_range": gasket_detail.temperature_range } + + # ๊ฐ€์Šค์ผ“ ๊ทธ๋ฃนํ•‘ - ํฌ๊ธฐ, ์••๋ ฅ, ์žฌ์งˆ๋กœ ๊ทธ๋ฃนํ•‘ + # original_description์—์„œ ์ฃผ์š” ์ •๋ณด ์ถ”์ถœ + description = m.original_description or '' + gasket_key = f"{m.size_spec}|{description}" + + if gasket_key not in gasket_groups: + gasket_groups[gasket_key] = { + "total_quantity": 0, + "materials": [] + } + + gasket_groups[gasket_key]["total_quantity"] += material_dict["quantity"] + gasket_groups[gasket_key]["materials"].append(material_dict) elif m.classified_category == 'VALVE': valve_query = text("SELECT * FROM valve_details WHERE material_id = :material_id") valve_result = db.execute(valve_query, {"material_id": m.id}) @@ -1314,6 +1611,21 @@ async def get_materials( "body_material": valve_detail.body_material, "size_inches": valve_detail.size_inches } + + # ๋ฐธ๋ธŒ ๊ทธ๋ฃนํ•‘ ์ถ”๊ฐ€ + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + valve_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + if valve_key not in valve_groups: + valve_groups[valve_key] = { + "total_quantity": 0, + "materials": [] + } + + valve_groups[valve_key]["total_quantity"] += material_dict["quantity"] + valve_groups[valve_key]["materials"].append(material_dict) + material_dict['clean_description'] = clean_description elif m.classified_category == 'BOLT': bolt_query = text("SELECT * FROM bolt_details WHERE material_id = :material_id") bolt_result = db.execute(bolt_query, {"material_id": m.id}) @@ -1329,8 +1641,199 @@ async def get_materials( "coating_type": bolt_detail.coating_type, "pressure_rating": bolt_detail.pressure_rating } + + # ๋ณผํŠธ ๊ทธ๋ฃนํ•‘ ์ถ”๊ฐ€ - ํฌ๊ธฐ, ์žฌ์งˆ, ๊ธธ์ด๋กœ ๊ทธ๋ฃนํ•‘ + # ์›๋ณธ ์„ค๋ช…์—์„œ ๊ธธ์ด ์ถ”์ถœ + import re + length_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:LG|MM)', m.original_description.upper()) + bolt_length = length_match.group(1) if length_match else 'UNKNOWN' + + bolt_key = f"{m.size_spec}|{m.material_grade}|{bolt_length}" + + if bolt_key not in bolt_groups: + bolt_groups[bolt_key] = { + "total_quantity": 0, + "materials": [] + } + + bolt_groups[bolt_key]["total_quantity"] += material_dict["quantity"] + bolt_groups[bolt_key]["materials"].append(material_dict) - material_list.append(material_dict) + # ํŒŒ์ดํ”„, ๋‹ˆํ”Œ, ์ผ๋ฐ˜ ํ”ผํŒ…, ํ”Œ๋žœ์ง€๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ ๋ฐ”๋กœ ์ถ”๊ฐ€ (์ด๋“ค์€ ๊ทธ๋ฃนํ•‘ ํ›„ ์ถ”๊ฐ€) + is_nipple = (m.classified_category == 'FITTING' and + ('NIPPLE' in m.original_description.upper() or + (hasattr(m, 'fitting_type') and m.fitting_type == 'NIPPLE'))) + + # CAP๊ณผ PLUG๋„ ์ผ๋ฐ˜ ํ”ผํŒ…์œผ๋กœ ์ฒ˜๋ฆฌ + is_cap_or_plug = (m.classified_category == 'FITTING' and + ('CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper())) + + is_general_fitting = (m.classified_category == 'FITTING' and not is_nipple and + ((hasattr(m, 'fitting_type') and m.fitting_type is not None) or is_cap_or_plug)) + + is_flange = (m.classified_category == 'FLANGE') + + is_valve = (m.classified_category == 'VALVE') + + is_bolt = (m.classified_category == 'BOLT') + + is_gasket = (m.classified_category == 'GASKET') + + # UNKNOWN ์นดํ…Œ๊ณ ๋ฆฌ ๊ทธ๋ฃนํ•‘ ์ฒ˜๋ฆฌ + if m.classified_category == 'UNKNOWN': + unknown_key = m.original_description or 'UNKNOWN' + + if unknown_key not in unknown_groups: + unknown_groups[unknown_key] = { + "total_quantity": 0, + "materials": [] + } + + unknown_groups[unknown_key]["total_quantity"] += material_dict["quantity"] + unknown_groups[unknown_key]["materials"].append(material_dict) + elif m.classified_category != 'PIPE' and not is_nipple and not is_general_fitting and not is_flange and not is_valve and not is_bolt and not is_gasket: + material_list.append(material_dict) + + # ํŒŒ์ดํ”„ ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ํŒŒ์ดํ”„ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + for pipe_key, group_info in pipe_groups.items(): + if group_info["materials"]: + # ๊ทธ๋ฃน์˜ ์ฒซ ๋ฒˆ์งธ ํŒŒ์ดํ”„๋ฅผ ๋Œ€ํ‘œ๋กœ ์‚ฌ์šฉ + representative_pipe = group_info["materials"][0].copy() + + # ๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ ์—…๋ฐ์ดํŠธ + representative_pipe['quantity'] = group_info["total_quantity"] + representative_pipe['original_description'] = representative_pipe['clean_description'] # ๊นจ๋—ํ•œ ์„ค๋ช… ์‚ฌ์šฉ + + if 'pipe_details' in representative_pipe: + representative_pipe['pipe_details']['total_length_mm'] = group_info["total_length_mm"] + representative_pipe['pipe_details']['pipe_count'] = group_info["total_quantity"] # โœ… pipe_count ์ถ”๊ฐ€ + representative_pipe['pipe_details']['group_total_quantity'] = group_info["total_quantity"] + # ํ‰๊ท  ๋‹จ์œ„ ๊ธธ์ด ๊ณ„์‚ฐ + if group_info["total_quantity"] > 0: + representative_pipe['pipe_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"] + + material_list.append(representative_pipe) + + # ๋‹ˆํ”Œ ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ๋‹ˆํ”Œ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + try: + for nipple_key, group_info in nipple_groups.items(): + if group_info["materials"]: + # ๊ทธ๋ฃน์˜ ์ฒซ ๋ฒˆ์งธ ๋‹ˆํ”Œ์„ ๋Œ€ํ‘œ๋กœ ์‚ฌ์šฉ + representative_nipple = group_info["materials"][0].copy() + + # ๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ ์—…๋ฐ์ดํŠธ + representative_nipple['quantity'] = group_info["total_quantity"] + representative_nipple['original_description'] = representative_nipple.get('clean_description', representative_nipple['original_description']) # ๊นจ๋—ํ•œ ์„ค๋ช… ์‚ฌ์šฉ + + if 'fitting_details' in representative_nipple: + representative_nipple['fitting_details']['total_length_mm'] = group_info["total_length_mm"] + representative_nipple['fitting_details']['group_total_quantity'] = group_info["total_quantity"] + # ํ‰๊ท  ๋‹จ์œ„ ๊ธธ์ด ๊ณ„์‚ฐ + if group_info["total_quantity"] > 0: + representative_nipple['fitting_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"] + + material_list.append(representative_nipple) + except Exception as nipple_error: + # ๋กœ๊ทธ ์ œ๊ฑฐ + # ๋‹ˆํ”Œ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass + + # ์ผ๋ฐ˜ ํ”ผํŒ… ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ํ”ผํŒ… ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + try: + for fitting_key, group_info in fitting_groups.items(): + if group_info["materials"]: + representative_fitting = group_info["materials"][0].copy() + representative_fitting['quantity'] = group_info["total_quantity"] + representative_fitting['original_description'] = representative_fitting.get('clean_description', representative_fitting['original_description']) + + if 'fitting_details' in representative_fitting: + representative_fitting['fitting_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_fitting) + except Exception as fitting_error: + # ๋กœ๊ทธ ์ œ๊ฑฐ + # ํ”ผํŒ… ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass + + # ํ”Œ๋žœ์ง€ ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ํ”Œ๋žœ์ง€ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + try: + for flange_key, group_info in flange_groups.items(): + if group_info["materials"]: + representative_flange = group_info["materials"][0].copy() + representative_flange['quantity'] = group_info["total_quantity"] + # original_description์€ ๊ทธ๋Œ€๋กœ ์œ ์ง€ (SCH ์ •๋ณด ๋ณด์กด) + # representative_flange['original_description'] = representative_flange.get('clean_description', representative_flange['original_description']) + + if 'flange_details' in representative_flange: + representative_flange['flange_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_flange) + except Exception as flange_error: + # ํ”Œ๋žœ์ง€ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass + + # ๋ฐธ๋ธŒ ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ๋ฐธ๋ธŒ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + print(f"DEBUG: ์ „์ฒด ๋ฐธ๋ธŒ ์ˆ˜: {valve_count}, valve_groups ์ˆ˜: {len(valve_groups)}") + try: + for valve_key, group_info in valve_groups.items(): + if group_info["materials"]: + representative_valve = group_info["materials"][0].copy() + representative_valve['quantity'] = group_info["total_quantity"] + + if 'valve_details' in representative_valve: + representative_valve['valve_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_valve) + print(f"DEBUG: ๋ฐธ๋ธŒ ์ถ”๊ฐ€๋จ - {valve_key}, ์ˆ˜๋Ÿ‰: {group_info['total_quantity']}") + except Exception as valve_error: + print(f"ERROR: ๋ฐธ๋ธŒ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ - {valve_error}") + # ๋ฐธ๋ธŒ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass + + # ๋ณผํŠธ ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ๋ณผํŠธ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + print(f"DEBUG: bolt_groups ์ˆ˜: {len(bolt_groups)}") + try: + for bolt_key, group_info in bolt_groups.items(): + if group_info["materials"]: + representative_bolt = group_info["materials"][0].copy() + representative_bolt['quantity'] = group_info["total_quantity"] + + if 'bolt_details' in representative_bolt: + representative_bolt['bolt_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_bolt) + print(f"DEBUG: ๋ณผํŠธ ์ถ”๊ฐ€๋จ - {bolt_key}, ์ˆ˜๋Ÿ‰: {group_info['total_quantity']}") + except Exception as bolt_error: + print(f"ERROR: ๋ณผํŠธ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ - {bolt_error}") + # ๋ณผํŠธ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass + + # ๊ฐ€์Šค์ผ“ ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ๊ฐ€์Šค์ผ“ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + print(f"DEBUG: gasket_groups ์ˆ˜: {len(gasket_groups)}") + try: + for gasket_key, group_info in gasket_groups.items(): + if group_info["materials"]: + representative_gasket = group_info["materials"][0].copy() + representative_gasket['quantity'] = group_info["total_quantity"] + + if 'gasket_details' in representative_gasket: + representative_gasket['gasket_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_gasket) + print(f"DEBUG: ๊ฐ€์Šค์ผ“ ์ถ”๊ฐ€๋จ - {gasket_key}, ์ˆ˜๋Ÿ‰: {group_info['total_quantity']}") + except Exception as gasket_error: + print(f"ERROR: ๊ฐ€์Šค์ผ“ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ - {gasket_error}") + # ๊ฐ€์Šค์ผ“ ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass + + # UNKNOWN ๊ทธ๋ฃน๋ณ„๋กœ ๋Œ€ํ‘œ ํ•ญ๋ชฉ ํ•˜๋‚˜๋งŒ ์ถ”๊ฐ€ (๊ทธ๋ฃนํ•‘๋œ ์ •๋ณด๋กœ) + print(f"DEBUG: unknown_groups ์ˆ˜: {len(unknown_groups)}") + try: + for unknown_key, group_info in unknown_groups.items(): + if group_info["materials"]: + representative_unknown = group_info["materials"][0].copy() + representative_unknown['quantity'] = group_info["total_quantity"] + material_list.append(representative_unknown) + print(f"DEBUG: UNKNOWN ์ถ”๊ฐ€๋จ - {unknown_key[:50]}, ์ˆ˜๋Ÿ‰: {group_info['total_quantity']}") + except Exception as unknown_error: + print(f"ERROR: UNKNOWN ๊ทธ๋ฃนํ•‘ ์‹คํŒจ - {unknown_error}") + # UNKNOWN ๊ทธ๋ฃนํ•‘ ์‹คํŒจ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + pass return { "success": True, @@ -1840,4 +2343,251 @@ async def create_user_requirement( except Exception as e: db.rollback() - raise HTTPException(status_code=500, detail=f"์š”๊ตฌ์‚ฌํ•ญ ์ƒ์„ฑ ์‹คํŒจ: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"์š”๊ตฌ์‚ฌํ•ญ ์ƒ์„ฑ ์‹คํŒจ: {str(e)}") + +@router.post("/materials/{material_id}/verify") +async def verify_material_classification( + material_id: int, + request: Request, + verified_category: Optional[str] = None, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + ์ž์žฌ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ๊ฒ€์ฆ + """ + try: + username = current_user.get('username', 'unknown') + + # ์ž์žฌ ์กด์žฌ ํ™•์ธ + material_query = text("SELECT * FROM materials WHERE id = :material_id") + material_result = db.execute(material_query, {"material_id": material_id}) + material = material_result.fetchone() + + if not material: + raise HTTPException(status_code=404, detail="์ž์žฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + # ๊ฒ€์ฆ ์ •๋ณด ์—…๋ฐ์ดํŠธ + update_query = text(""" + UPDATE materials + SET is_verified = TRUE, + verified_by = :username, + verified_at = CURRENT_TIMESTAMP, + classified_category = COALESCE(:verified_category, classified_category) + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "username": username, + "verified_category": verified_category + }) + + # ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก + try: + from ..services.activity_logger import log_activity_from_request + log_activity_from_request( + db, request, username, + "MATERIAL_VERIFY", + f"์ž์žฌ ๋ถ„๋ฅ˜ ๊ฒ€์ฆ: {material.original_description}" + ) + except Exception as e: + print(f"ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก ์‹คํŒจ: {str(e)}") + + db.commit() + + return { + "success": True, + "message": "์ž์žฌ ๋ถ„๋ฅ˜๊ฐ€ ๊ฒ€์ฆ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "material_id": material_id, + "verified_by": username + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"์ž์žฌ ๊ฒ€์ฆ ์‹คํŒจ: {str(e)}") + +@router.put("/materials/{material_id}/update-classification") +async def update_material_classification( + material_id: int, + request: Request, + classified_category: str = Form(...), + classified_subcategory: str = Form(None), + material_grade: str = Form(None), + schedule: str = Form(None), + size_spec: str = Form(None), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """BOM ๊ด€๋ฆฌ ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉ์ž๊ฐ€ ๋ถ„๋ฅ˜๋ฅผ ์ˆ˜์ •ํ•˜๋Š” API""" + try: + username = current_user.get("username", "unknown") + + # ์ž์žฌ ์กด์žฌ ํ™•์ธ + check_query = text("SELECT id, original_description FROM materials WHERE id = :material_id") + result = db.execute(check_query, {"material_id": material_id}) + material = result.fetchone() + + if not material: + raise HTTPException(status_code=404, detail="์ž์žฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + # ๋ถ„๋ฅ˜ ์ •๋ณด ์—…๋ฐ์ดํŠธ + update_query = text(""" + UPDATE materials + SET classified_category = :classified_category, + classified_subcategory = :classified_subcategory, + material_grade = :material_grade, + schedule = :schedule, + size_spec = :size_spec, + is_verified = true, + verified_by = :verified_by, + verified_at = NOW(), + updated_at = NOW() + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "classified_category": classified_category, + "classified_subcategory": classified_subcategory or "", + "material_grade": material_grade or "", + "schedule": schedule or "", + "size_spec": size_spec or "", + "verified_by": username + }) + + db.commit() + + # ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก + await log_activity_from_request( + request, + db, + "material_classification_update", + f"์ž์žฌ ๋ถ„๋ฅ˜ ์ˆ˜์ •: {material.original_description} -> {classified_category}", + {"material_id": material_id, "category": classified_category} + ) + + return { + "success": True, + "message": "์ž์žฌ ๋ถ„๋ฅ˜๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "material_id": material_id, + "classified_category": classified_category + } + + except Exception as e: + db.rollback() + print(f"์ž์žฌ ๋ถ„๋ฅ˜ ์—…๋ฐ์ดํŠธ ์‹คํŒจ: {str(e)}") + raise HTTPException(status_code=500, detail=f"์ž์žฌ ๋ถ„๋ฅ˜ ์—…๋ฐ์ดํŠธ ์‹คํŒจ: {str(e)}") + +@router.post("/materials/confirm-purchase") +async def confirm_material_purchase_api( + request: Request, + job_no: str = Query(...), + revision: str = Query(...), + confirmed_by: str = Query("user"), + confirmations_data: List[Dict] = Body(...), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """์ž์žฌ ๊ตฌ๋งค์ˆ˜๋Ÿ‰ ํ™•์ • API (ํ”„๋ก ํŠธ์—”๋“œ ํ˜ธํ™˜)""" + try: + + # ์ž…๋ ฅ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ + if not job_no or not revision: + raise HTTPException(status_code=400, detail="Job ๋ฒˆํ˜ธ์™€ ๋ฆฌ๋น„์ „์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + + if not confirmations_data: + raise HTTPException(status_code=400, detail="ํ™•์ •ํ•  ์ž์žฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + # ๊ฐ ํ™•์ • ํ•ญ๋ชฉ ๊ฒ€์ฆ + for i, confirmation in enumerate(confirmations_data): + if not confirmation.get("material_hash"): + raise HTTPException(status_code=400, detail=f"{i+1}๋ฒˆ์งธ ํ•ญ๋ชฉ์˜ material_hash๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + confirmed_qty = confirmation.get("confirmed_quantity") + if confirmed_qty is None or confirmed_qty < 0: + raise HTTPException(status_code=400, detail=f"{i+1}๋ฒˆ์งธ ํ•ญ๋ชฉ์˜ ํ™•์ • ์ˆ˜๋Ÿ‰์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค") + + confirmed_items = [] + + for confirmation in confirmations_data: + # ๋ฐœ์ฃผ ์ถ”์  ํ…Œ์ด๋ธ”์— ์ €์žฅ/์—…๋ฐ์ดํŠธ + upsert_query = text(""" + INSERT INTO material_purchase_tracking ( + job_no, material_hash, revision, description, size_spec, unit, + bom_quantity, calculated_quantity, confirmed_quantity, + purchase_status, supplier_name, unit_price, total_price, + confirmed_by, confirmed_at + ) + SELECT + :job_no, m.material_hash, :revision, m.original_description, + m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty, + 'CONFIRMED', :supplier_name, :unit_price, :total_price, + :confirmed_by, CURRENT_TIMESTAMP + FROM materials m + WHERE m.material_hash = :material_hash + AND m.file_id = ( + SELECT id FROM files + WHERE job_no = :job_no AND revision = :revision + ORDER BY upload_date DESC LIMIT 1 + ) + LIMIT 1 + ON CONFLICT (job_no, material_hash, revision) + DO UPDATE SET + confirmed_quantity = :confirmed_qty, + purchase_status = 'CONFIRMED', + supplier_name = :supplier_name, + unit_price = :unit_price, + total_price = :total_price, + confirmed_by = :confirmed_by, + confirmed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING id, description, confirmed_quantity + """) + + calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"]) + total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0) + + result = db.execute(upsert_query, { + "job_no": job_no, + "revision": revision, + "material_hash": confirmation["material_hash"], + "calculated_qty": calculated_qty, + "confirmed_qty": confirmation["confirmed_quantity"], + "supplier_name": confirmation.get("supplier_name", ""), + "unit_price": confirmation.get("unit_price", 0), + "total_price": total_price, + "confirmed_by": confirmed_by + }) + + confirmed_item = result.fetchone() + if confirmed_item: + confirmed_items.append({ + "id": confirmed_item[0], + "description": confirmed_item[1], + "confirmed_quantity": confirmed_item[2] + }) + + db.commit() + + # ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก + await log_activity_from_request( + request, + db, + "material_purchase_confirm", + f"๊ตฌ๋งค์ˆ˜๋Ÿ‰ ํ™•์ •: {job_no} {revision} - {len(confirmed_items)}๊ฐœ ํ’ˆ๋ชฉ", + {"job_no": job_no, "revision": revision, "items_count": len(confirmed_items)} + ) + + return { + "success": True, + "message": f"{len(confirmed_items)}๊ฐœ ํ’ˆ๋ชฉ์˜ ๊ตฌ๋งค์ˆ˜๋Ÿ‰์ด ํ™•์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "job_no": job_no, + "revision": revision, + "confirmed_items": confirmed_items + } + + except Exception as e: + db.rollback() + print(f"๊ตฌ๋งค์ˆ˜๋Ÿ‰ ํ™•์ • ์‹คํŒจ: {str(e)}") + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค์ˆ˜๋Ÿ‰ ํ™•์ • ์‹คํŒจ: {str(e)}") \ No newline at end of file diff --git a/backend/app/routers/files.py.backup2 b/backend/app/routers/files.py.backup2 deleted file mode 100644 index 3af406b..0000000 --- a/backend/app/routers/files.py.backup2 +++ /dev/null @@ -1,399 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from sqlalchemy.orm import Session -from sqlalchemy import text -from typing import List, Optional -import os -import shutil -from datetime import datetime -import uuid -import pandas as pd -import re -from pathlib import Path - -from ..database import get_db - -router = APIRouter() - -UPLOAD_DIR = Path("uploads") -UPLOAD_DIR.mkdir(exist_ok=True) -ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} - -@router.get("/") -async def get_files_info(): - return { - "message": "ํŒŒ์ผ ๊ด€๋ฆฌ API", - "allowed_extensions": list(ALLOWED_EXTENSIONS), - "upload_directory": str(UPLOAD_DIR) - } - -@router.get("/test") -async def test_endpoint(): - return {"status": "ํŒŒ์ผ API๊ฐ€ ์ •์ƒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค!"} - -@router.post("/add-missing-columns") -async def add_missing_columns(db: Session = Depends(get_db)): - """๋ˆ„๋ฝ๋œ ์ปฌ๋Ÿผ๋“ค ์ถ”๊ฐ€""" - try: - db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0")) - db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER")) - db.commit() - - return { - "success": True, - "message": "๋ˆ„๋ฝ๋œ ์ปฌ๋Ÿผ๋“ค์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค", - "added_columns": ["files.parsed_count", "materials.row_number"] - } - except Exception as e: - db.rollback() - return {"success": False, "error": f"์ปฌ๋Ÿผ ์ถ”๊ฐ€ ์‹คํŒจ: {str(e)}"} - -def validate_file_extension(filename: str) -> bool: - return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS - -def generate_unique_filename(original_filename: str) -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - unique_id = str(uuid.uuid4())[:8] - stem = Path(original_filename).stem - suffix = Path(original_filename).suffix - return f"{stem}_{timestamp}_{unique_id}{suffix}" - -def parse_dataframe(df): - df = df.dropna(how='all') - df.columns = df.columns.str.strip().str.lower() - - column_mapping = { - 'description': ['description', 'item', 'material', 'ํ’ˆ๋ช…', '์ž์žฌ๋ช…'], - 'quantity': ['qty', 'quantity', 'ea', '์ˆ˜๋Ÿ‰'], - 'main_size': ['main_nom', 'nominal_diameter', 'nd', '์ฃผ๋ฐฐ๊ด€'], - 'red_size': ['red_nom', 'reduced_diameter', '์ถ•์†Œ๋ฐฐ๊ด€'], - 'length': ['length', 'len', '๊ธธ์ด'], - 'weight': ['weight', 'wt', '์ค‘๋Ÿ‰'], - 'dwg_name': ['dwg_name', 'drawing', '๋„๋ฉด๋ช…'], - 'line_num': ['line_num', 'line_number', '๋ผ์ธ๋ฒˆํ˜ธ'] - } - - mapped_columns = {} - for standard_col, possible_names in column_mapping.items(): - for possible_name in possible_names: - if possible_name in df.columns: - mapped_columns[standard_col] = possible_name - break - - materials = [] - for index, row in df.iterrows(): - description = str(row.get(mapped_columns.get('description', ''), '')) - quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) - - try: - quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0 - except: - quantity = 0 - - material_grade = "" - if "ASTM" in description.upper(): - astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper()) - if astm_match: - material_grade = astm_match.group(0).strip() - - main_size = str(row.get(mapped_columns.get('main_size', ''), '')) - red_size = str(row.get(mapped_columns.get('red_size', ''), '')) - - if main_size != 'nan' and red_size != 'nan' and red_size != '': - size_spec = f"{main_size} x {red_size}" - elif main_size != 'nan' and main_size != '': - size_spec = main_size - else: - size_spec = "" - - if description and description not in ['nan', 'None', '']: - materials.append({ - 'original_description': description, - 'quantity': quantity, - 'unit': "EA", - 'size_spec': size_spec, - 'material_grade': material_grade, - 'line_number': index + 1, - 'row_number': index + 1 - }) - - return materials - -def parse_file_data(file_path): - file_extension = Path(file_path).suffix.lower() - - try: - if file_extension == ".csv": - df = pd.read_csv(file_path, encoding='utf-8') - elif file_extension in [".xlsx", ".xls"]: - df = pd.read_excel(file_path, sheet_name=0) - else: - raise HTTPException(status_code=400, detail="์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹") - - return parse_dataframe(df) - except Exception as e: - raise HTTPException(status_code=400, detail=f"ํŒŒ์ผ ํŒŒ์‹ฑ ์‹คํŒจ: {str(e)}") - -@router.post("/upload") -async def upload_file( - file: UploadFile = File(...), - job_no: str = Form(...), - revision: str = Form("Rev.0"), - db: Session = Depends(get_db) -): - if not validate_file_extension(file.filename): - raise HTTPException( - status_code=400, - detail=f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค. ํ—ˆ์šฉ๋œ ํ™•์žฅ์ž: {', '.join(ALLOWED_EXTENSIONS)}" - ) - - if file.size and file.size > 10 * 1024 * 1024: - raise HTTPException(status_code=400, detail="ํŒŒ์ผ ํฌ๊ธฐ๋Š” 10MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") - - unique_filename = generate_unique_filename(file.filename) - file_path = UPLOAD_DIR / unique_filename - - try: - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - except Exception as e: - raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ: {str(e)}") - - try: - materials_data = parse_file_data(str(file_path)) - parsed_count = len(materials_data) - - # ํŒŒ์ผ ์ •๋ณด ์ €์žฅ - file_insert_query = text(""" - INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active) - VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active) - RETURNING id - """) - - file_result = db.execute(file_insert_query, { - "filename": unique_filename, - "original_filename": file.filename, - "file_path": str(file_path), - "job_no": job_no, - "revision": revision, - "description": f"BOM ํŒŒ์ผ - {parsed_count}๊ฐœ ์ž์žฌ", - "file_size": file.size, - "parsed_count": parsed_count, - "is_active": True - }) - - file_id = file_result.fetchone()[0] - - # ์ž์žฌ ๋ฐ์ดํ„ฐ ์ €์žฅ - materials_inserted = 0 - for material_data in materials_data: - material_insert_query = text(""" - INSERT INTO materials ( - file_id, original_description, quantity, unit, size_spec, - material_grade, line_number, row_number, classified_category, - classification_confidence, is_verified, created_at - ) - VALUES ( - :file_id, :original_description, :quantity, :unit, :size_spec, - :material_grade, :line_number, :row_number, :classified_category, - :classification_confidence, :is_verified, :created_at - ) - """) - - db.execute(material_insert_query, { - "file_id": file_id, - "original_description": material_data["original_description"], - "quantity": material_data["quantity"], - "unit": material_data["unit"], - "size_spec": material_data["size_spec"], - "material_grade": material_data["material_grade"], - "line_number": material_data["line_number"], - "row_number": material_data["row_number"], - "classified_category": None, - "classification_confidence": None, - "is_verified": False, - "created_at": datetime.now() - }) - materials_inserted += 1 - - db.commit() - - return { - "success": True, - "message": f"์™„์ „ํ•œ DB ์ €์žฅ ์„ฑ๊ณต! {materials_inserted}๊ฐœ ์ž์žฌ ์ €์žฅ๋จ", - "original_filename": file.filename, - "file_id": file_id, - "parsed_materials_count": parsed_count, - "saved_materials_count": materials_inserted, - "sample_materials": materials_data[:3] if materials_data else [] - } - - except Exception as e: - db.rollback() - if os.path.exists(file_path): - os.remove(file_path) - raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ฒ˜๋ฆฌ ์‹คํŒจ: {str(e)}") -@router.get("/materials") -async def get_materials( - job_no: Optional[str] = None, - file_id: Optional[str] = None, - skip: int = 0, - limit: int = 100, - db: Session = Depends(get_db) -): - """์ €์žฅ๋œ ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ""" - try: - query = """ - SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, - m.size_spec, m.material_grade, m.line_number, m.row_number, - m.created_at, - f.original_filename, f.job_no, - j.job_no, j.job_name - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - LEFT JOIN jobs j ON f.job_no = j.job_no - WHERE 1=1 - """ - - params = {} - - if job_no: - query += " AND f.job_no = :job_no" - params["job_no"] = job_no - - if file_id: - query += " AND m.file_id = :file_id" - params["file_id"] = file_id - - query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip" - params["limit"] = limit - params["skip"] = skip - - result = db.execute(text(query), params) - materials = result.fetchall() - - # ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ - count_query = """ - SELECT COUNT(*) as total - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - WHERE 1=1 - """ - count_params = {} - - if job_no: - count_query += " AND f.job_no = :job_no" - count_params["job_no"] = job_no - - if file_id: - count_query += " AND m.file_id = :file_id" - count_params["file_id"] = file_id - - count_result = db.execute(text(count_query), count_params) - total_count = count_result.fetchone()[0] - - return { - "success": True, - "total_count": total_count, - "returned_count": len(materials), - "skip": skip, - "limit": limit, - "materials": [ - { - "id": m.id, - "file_id": m.file_id, - "filename": m.original_filename, - "job_no": m.job_no, - "project_code": m.official_project_code, - "project_name": m.project_name, - "original_description": m.original_description, - "quantity": float(m.quantity) if m.quantity else 0, - "unit": m.unit, - "size_spec": m.size_spec, - "material_grade": m.material_grade, - "line_number": m.line_number, - "row_number": m.row_number, - "created_at": m.created_at - } - for m in materials - ] - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"์ž์žฌ ์กฐํšŒ ์‹คํŒจ: {str(e)}") - -@router.get("/materials/summary") -async def get_materials_summary( - job_no: Optional[str] = None, - file_id: Optional[str] = None, - db: Session = Depends(get_db) -): - """์ž์žฌ ์š”์•ฝ ํ†ต๊ณ„""" - try: - query = """ - SELECT - COUNT(*) as total_items, - COUNT(DISTINCT m.original_description) as unique_descriptions, - COUNT(DISTINCT m.size_spec) as unique_sizes, - COUNT(DISTINCT m.material_grade) as unique_materials, - SUM(m.quantity) as total_quantity, - AVG(m.quantity) as avg_quantity, - MIN(m.created_at) as earliest_upload, - MAX(m.created_at) as latest_upload - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - WHERE 1=1 - """ - - params = {} - - if job_no: - query += " AND f.job_no = :job_no" - params["job_no"] = job_no - - if file_id: - query += " AND m.file_id = :file_id" - params["file_id"] = file_id - - result = db.execute(text(query), params) - summary = result.fetchone() - - return { - "success": True, - "summary": { - "total_items": summary.total_items, - "unique_descriptions": summary.unique_descriptions, - "unique_sizes": summary.unique_sizes, - "unique_materials": summary.unique_materials, - "total_quantity": float(summary.total_quantity) if summary.total_quantity else 0, - "avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0, - "earliest_upload": summary.earliest_upload, - "latest_upload": summary.latest_upload - } - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"์š”์•ฝ ์กฐํšŒ ์‹คํŒจ: {str(e)}") -# Job ๊ฒ€์ฆ ํ•จ์ˆ˜ (ํŒŒ์ผ ๋์— ์ถ”๊ฐ€ํ•  ์˜ˆ์ •) -async def validate_job_exists(job_no: str, db: Session): - """Job ์กด์žฌ ์—ฌ๋ถ€ ๋ฐ ํ™œ์„ฑ ์ƒํƒœ ํ™•์ธ""" - try: - query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true") - job = db.execute(query, {"job_no": job_no}).fetchone() - - if not job: - return {"valid": False, "error": f"Job No. '{job_no}'๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"} - - if job.status == '์™„๋ฃŒ': - return {"valid": False, "error": f"์™„๋ฃŒ๋œ Job '{job.job_name}'์—๋Š” ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"} - - return { - "valid": True, - "job": { - "job_no": job.job_no, - "job_name": job.job_name, - "status": job.status - } - } - - except Exception as e: - return {"valid": False, "error": f"Job ๊ฒ€์ฆ ์‹คํŒจ: {str(e)}"} diff --git a/backend/app/routers/material_comparison.py b/backend/app/routers/material_comparison.py index 110ca0d..008808a 100644 --- a/backend/app/routers/material_comparison.py +++ b/backend/app/routers/material_comparison.py @@ -157,6 +157,26 @@ async def confirm_material_purchase( ] """ try: + # ์ž…๋ ฅ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ + if not job_no or not revision: + raise HTTPException(status_code=400, detail="Job ๋ฒˆํ˜ธ์™€ ๋ฆฌ๋น„์ „์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + + if not confirmations: + raise HTTPException(status_code=400, detail="ํ™•์ •ํ•  ์ž์žฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + # ๊ฐ ํ™•์ • ํ•ญ๋ชฉ ๊ฒ€์ฆ + for i, confirmation in enumerate(confirmations): + if not confirmation.get("material_hash"): + raise HTTPException(status_code=400, detail=f"{i+1}๋ฒˆ์งธ ํ•ญ๋ชฉ์˜ material_hash๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + confirmed_qty = confirmation.get("confirmed_quantity") + if confirmed_qty is None or confirmed_qty < 0: + raise HTTPException(status_code=400, detail=f"{i+1}๋ฒˆ์งธ ํ•ญ๋ชฉ์˜ ํ™•์ • ์ˆ˜๋Ÿ‰์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค") + + unit_price = confirmation.get("unit_price", 0) + if unit_price < 0: + raise HTTPException(status_code=400, detail=f"{i+1}๋ฒˆ์งธ ํ•ญ๋ชฉ์˜ ๋‹จ๊ฐ€๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค") + confirmed_items = [] for confirmation in confirmations: @@ -470,7 +490,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: """ํŒŒ์ผ์˜ ์ž์žฌ๋ฅผ ํ•ด์‹œ๋ณ„๋กœ ๊ทธ๋ฃนํ™”ํ•˜์—ฌ ์กฐํšŒ""" import hashlib - print(f"๐Ÿšจ๐Ÿšจ๐Ÿšจ get_materials_by_hash ํ˜ธ์ถœ๋จ! file_id={file_id} ๐Ÿšจ๐Ÿšจ๐Ÿšจ") + # ๋กœ๊ทธ ์ œ๊ฑฐ query = text(""" SELECT @@ -492,11 +512,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: result = db.execute(query, {"file_id": file_id}) materials = result.fetchall() - print(f"๐Ÿ” ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ ๊ฐœ์ˆ˜: {len(materials)}") - if len(materials) > 0: - print(f"๐Ÿ” ์ฒซ ๋ฒˆ์งธ ์ž๋ฃŒ ์ƒ˜ํ”Œ: {materials[0]}") - else: - print(f"โŒ ์ž๋ฃŒ๊ฐ€ ์—†์Œ! file_id={file_id}") + # ๋กœ๊ทธ ์ œ๊ฑฐ # ๐Ÿ”„ ๊ฐ™์€ ํŒŒ์ดํ”„๋“ค์„ Python์—์„œ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ทธ๋ฃนํ•‘ materials_dict = {} @@ -505,38 +521,41 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}" material_hash = hashlib.md5(hash_source.encode()).hexdigest() - print(f"๐Ÿ“ ๊ฐœ๋ณ„ ์ž์žฌ: {mat[1][:50]}... ({mat[2]}) - ์ˆ˜๋Ÿ‰: {mat[4]}, ๊ธธ์ด: {mat[7]}mm") + # ๊ฐœ๋ณ„ ์ž์žฌ ๋กœ๊ทธ ์ œ๊ฑฐ (๋„ˆ๋ฌด ๋งŽ์Œ) if material_hash in materials_dict: # ๐Ÿ”„ ๊ธฐ์กด ํ•ญ๋ชฉ์— ์ˆ˜๋Ÿ‰ ํ•ฉ๊ณ„ existing = materials_dict[material_hash] - existing["quantity"] += float(mat[4]) if mat[4] else 0.0 + # ํŒŒ์ดํ”„๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ quantity ํ•ฉ์‚ฐ (ํŒŒ์ดํ”„๋Š” ๊ฐœ๋ณ„ ๊ธธ์ด๊ฐ€ ๋‹ค๋ฅด๋ฏ€๋กœ ํ•ฉ์‚ฐํ•˜์ง€ ์•Š์Œ) + if mat[5] != 'PIPE': + existing["quantity"] += float(mat[4]) if mat[4] else 0.0 existing["line_number"] += f", {mat[8]}" if mat[8] else "" # ํŒŒ์ดํ”„์ธ ๊ฒฝ์šฐ ๊ธธ์ด ์ •๋ณด ํ•ฉ์‚ฐ if mat[5] == 'PIPE' and mat[7] is not None: if "pipe_details" in existing: - # ์ด๊ธธ์ด ํ•ฉ์‚ฐ: ๊ธฐ์กด ์ด๊ธธ์ด + (ํ˜„์žฌ ์ˆ˜๋Ÿ‰ ร— ํ˜„์žฌ ๊ธธ์ด) + # ์ด๊ธธ์ด ํ•ฉ์‚ฐ: ๊ธฐ์กด ์ด๊ธธ์ด + ํ˜„์žฌ ํŒŒ์ดํ”„์˜ ์‹ค์ œ ๊ธธ์ด (DB์— ์ €์žฅ๋œ ๊ฐœ๋ณ„ ๊ธธ์ด) current_total = existing["pipe_details"]["total_length_mm"] current_count = existing["pipe_details"]["pipe_count"] - new_length = float(mat[4]) * float(mat[7]) # ์ˆ˜๋Ÿ‰ ร— ๋‹จ์œ„๊ธธ์ด - existing["pipe_details"]["total_length_mm"] = current_total + new_length - existing["pipe_details"]["pipe_count"] = current_count + float(mat[4]) + # โœ… DB์—์„œ ๊ฐ€์ ธ์˜จ length_mm๋Š” ์ด๋ฏธ ๊ฐœ๋ณ„ ํŒŒ์ดํ”„์˜ ์‹ค์ œ ๊ธธ์ด์ด๋ฏ€๋กœ ์ˆ˜๋Ÿ‰์„ ๊ณฑํ•˜์ง€ ์•Š์Œ + individual_length = float(mat[7]) # ๊ฐœ๋ณ„ ํŒŒ์ดํ”„์˜ ์‹ค์ œ ๊ธธ์ด + existing["pipe_details"]["total_length_mm"] = current_total + individual_length + existing["pipe_details"]["pipe_count"] = current_count + 1 # ํŒŒ์ดํ”„ ๊ฐœ์ˆ˜๋Š” 1๊ฐœ์”ฉ ์ฆ๊ฐ€ # ํ‰๊ท  ๋‹จ์œ„ ๊ธธ์ด ์žฌ๊ณ„์‚ฐ total_length = existing["pipe_details"]["total_length_mm"] total_count = existing["pipe_details"]["pipe_count"] existing["pipe_details"]["length_mm"] = total_length / total_count - print(f"๐Ÿ”„ ํŒŒ์ดํ”„ ํ•ฉ์‚ฐ: {mat[1]} ({mat[2]}) - ์ด๊ธธ์ด: {total_length}mm, ์ด๊ฐœ์ˆ˜: {total_count}๊ฐœ, ํ‰๊ท : {total_length/total_count:.1f}mm") + # ํŒŒ์ดํ”„ ํ•ฉ์‚ฐ ๋กœ๊ทธ ์ œ๊ฑฐ (๋„ˆ๋ฌด ๋งŽ์Œ) else: # ์ฒซ ํŒŒ์ดํ”„ ์ •๋ณด ์„ค์ • - pipe_length = float(mat[4]) * float(mat[7]) + individual_length = float(mat[7]) # ๊ฐœ๋ณ„ ํŒŒ์ดํ”„์˜ ์‹ค์ œ ๊ธธ์ด existing["pipe_details"] = { - "length_mm": float(mat[7]), - "total_length_mm": pipe_length, - "pipe_count": float(mat[4]) + "length_mm": individual_length, + "total_length_mm": individual_length, # ์ฒซ ๋ฒˆ์งธ ํŒŒ์ดํ”„์ด๋ฏ€๋กœ ๊ฐœ๋ณ„ ๊ธธ์ด์™€ ๋™์ผ + "pipe_count": 1 # ์ฒซ ๋ฒˆ์งธ ํŒŒ์ดํ”„์ด๋ฏ€๋กœ 1๊ฐœ } else: # ๐Ÿ†• ์ƒˆ ํ•ญ๋ชฉ ์ƒ์„ฑ @@ -553,27 +572,22 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: # ํŒŒ์ดํ”„์ธ ๊ฒฝ์šฐ pipe_details ์ •๋ณด ์ถ”๊ฐ€ if mat[5] == 'PIPE' and mat[7] is not None: - pipe_length = float(mat[4]) * float(mat[7]) # ์ˆ˜๋Ÿ‰ ร— ๋‹จ์œ„๊ธธ์ด + individual_length = float(mat[7]) # ๊ฐœ๋ณ„ ํŒŒ์ดํ”„์˜ ์‹ค์ œ ๊ธธ์ด material_data["pipe_details"] = { - "length_mm": float(mat[7]), # ๋‹จ์œ„ ๊ธธ์ด - "total_length_mm": pipe_length, # ์ด ๊ธธ์ด - "pipe_count": float(mat[4]) # ํŒŒ์ดํ”„ ๊ฐœ์ˆ˜ + "length_mm": individual_length, # ๊ฐœ๋ณ„ ํŒŒ์ดํ”„ ๊ธธ์ด + "total_length_mm": individual_length, # ์ฒซ ๋ฒˆ์งธ ํŒŒ์ดํ”„์ด๋ฏ€๋กœ ๊ฐœ๋ณ„ ๊ธธ์ด์™€ ๋™์ผ + "pipe_count": 1 # ์ฒซ ๋ฒˆ์งธ ํŒŒ์ดํ”„์ด๋ฏ€๋กœ 1๊ฐœ } - print(f"๐Ÿ†• ํŒŒ์ดํ”„ ์‹ ๊ทœ: {mat[1]} ({mat[2]}) - ๋‹จ์œ„: {mat[7]}mm, ์ด๊ธธ์ด: {pipe_length}mm") + # ํŒŒ์ดํ”„๋Š” quantity๋ฅผ 1๋กœ ์„ค์ • (pipe_count์™€ ๋™์ผ) + material_data["quantity"] = 1 materials_dict[material_hash] = material_data - # ํŒŒ์ดํ”„ ๋ฐ์ดํ„ฐ๊ฐ€ ํฌํ•จ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + # ํŒŒ์ดํ”„ ๋ฐ์ดํ„ฐ ์š”์•ฝ๋งŒ ์ถœ๋ ฅ pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE') pipe_with_details = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE' and 'pipe_details' in data) - print(f"๐Ÿ” ๋ฐ˜ํ™˜ ๊ฒฐ๊ณผ: ์ด {len(materials_dict)}๊ฐœ ์ž์žฌ, ํŒŒ์ดํ”„ {pipe_count}๊ฐœ, pipe_details ์žˆ๋Š” ํŒŒ์ดํ”„ {pipe_with_details}๊ฐœ") - - # ์ฒซ ๋ฒˆ์งธ ํŒŒ์ดํ”„ ๋ฐ์ดํ„ฐ ์ƒ˜ํ”Œ ์ถœ๋ ฅ - for hash_key, data in materials_dict.items(): - if data.get('category') == 'PIPE': - print(f"๐Ÿ” ํŒŒ์ดํ”„ ์ƒ˜ํ”Œ: {data}") - break + print(f"โœ… ์ž์žฌ ์ฒ˜๋ฆฌ ์™„๋ฃŒ: ์ด {len(materials_dict)}๊ฐœ, ํŒŒ์ดํ”„ {pipe_count}๊ฐœ (๊ธธ์ด์ •๋ณด: {pipe_with_details}๊ฐœ)") return materials_dict diff --git a/backend/app/routers/purchase.py b/backend/app/routers/purchase.py index ef6b5fc..e271e60 100644 --- a/backend/app/routers/purchase.py +++ b/backend/app/routers/purchase.py @@ -5,11 +5,13 @@ - ๋ฆฌ๋น„์ „ ๋น„๊ต """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Optional +from pydantic import BaseModel import json +from datetime import datetime from ..database import get_db from ..services.purchase_calculator import ( @@ -21,6 +23,28 @@ from ..services.purchase_calculator import ( router = APIRouter(prefix="/purchase", tags=["purchase"]) +# Pydantic ๋ชจ๋ธ (์ตœ์ ํ™”๋œ ๊ตฌ์กฐ) +class PurchaseItemMinimal(BaseModel): + """๊ตฌ๋งค ํ™•์ •์šฉ ์ตœ์†Œ ํ•„์ˆ˜ ๋ฐ์ดํ„ฐ""" + item_code: str + category: str + specification: str + size: str = "" + material: str = "" + bom_quantity: float + calculated_qty: float + unit: str = "EA" + safety_factor: float = 1.0 + +class PurchaseConfirmRequest(BaseModel): + job_no: str + file_id: int + bom_name: Optional[str] = None # ์„ ํƒ์  ํ•„๋“œ๋กœ ๋ณ€๊ฒฝ + revision: str + purchase_items: List[PurchaseItemMinimal] # ์ตœ์ ํ™”๋œ ๊ตฌ์กฐ ์‚ฌ์šฉ + confirmed_at: str + confirmed_by: str + @router.get("/items/calculate") async def calculate_purchase_items( job_no: str = Query(..., description="Job ๋ฒˆํ˜ธ"), @@ -39,7 +63,7 @@ async def calculate_purchase_items( file_query = text(""" SELECT id FROM files WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE - ORDER BY created_at DESC + ORDER BY updated_at DESC LIMIT 1 """) file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone() @@ -62,6 +86,139 @@ async def calculate_purchase_items( except Exception as e: raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ํ’ˆ๋ชฉ ๊ณ„์‚ฐ ์‹คํŒจ: {str(e)}") +@router.post("/confirm") +async def confirm_purchase_quantities( + request: PurchaseConfirmRequest, + db: Session = Depends(get_db) +): + """ + ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • + - ๊ณ„์‚ฐ๋œ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰์„ ํ™•์ • ์ƒํƒœ๋กœ ์ €์žฅ + - ์ž์žฌ๋ณ„ ํ™•์ • ์ˆ˜๋Ÿ‰ ๋ฐ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + - ๋ฆฌ๋น„์ „ ๋น„๊ต๋ฅผ ์œ„ํ•œ ๊ธฐ์ค€ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + """ + try: + # 1. ๊ธฐ์กด ํ™•์ • ๋ฐ์ดํ„ฐ ํ™•์ธ ๋ฐ ์—…๋ฐ์ดํŠธ ๋˜๋Š” ์‚ฝ์ž… + existing_query = text(""" + SELECT id FROM purchase_confirmations + WHERE file_id = :file_id + """) + existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone() + + if existing_result: + # ๊ธฐ์กด ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ + confirmation_id = existing_result[0] + update_query = text(""" + UPDATE purchase_confirmations + SET job_no = :job_no, + bom_name = :bom_name, + revision = :revision, + confirmed_at = :confirmed_at, + confirmed_by = :confirmed_by, + is_active = TRUE, + updated_at = CURRENT_TIMESTAMP + WHERE id = :confirmation_id + """) + db.execute(update_query, { + "confirmation_id": confirmation_id, + "job_no": request.job_no, + "bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # ๊ธฐ๋ณธ๊ฐ’ ์ œ๊ณต + "revision": request.revision, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + }) + + # ๊ธฐ์กด ํ™•์ • ํ’ˆ๋ชฉ๋“ค ์‚ญ์ œ + delete_items_query = text(""" + DELETE FROM confirmed_purchase_items + WHERE confirmation_id = :confirmation_id + """) + db.execute(delete_items_query, {"confirmation_id": confirmation_id}) + else: + # ์ƒˆ๋กœ์šด ํ™•์ • ๋ฐ์ดํ„ฐ ์‚ฝ์ž… + confirm_query = text(""" + INSERT INTO purchase_confirmations ( + job_no, file_id, bom_name, revision, + confirmed_at, confirmed_by, is_active, created_at + ) VALUES ( + :job_no, :file_id, :bom_name, :revision, + :confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP + ) RETURNING id + """) + + confirm_result = db.execute(confirm_query, { + "job_no": request.job_no, + "file_id": request.file_id, + "bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # ๊ธฐ๋ณธ๊ฐ’ ์ œ๊ณต + "revision": request.revision, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + }) + + confirmation_id = confirm_result.fetchone()[0] + + # 3. ํ™•์ •๋œ ๊ตฌ๋งค ํ’ˆ๋ชฉ๋“ค ์ €์žฅ + saved_items = 0 + for item in request.purchase_items: + item_query = text(""" + INSERT INTO confirmed_purchase_items ( + confirmation_id, item_code, category, specification, + size, material, bom_quantity, calculated_qty, + unit, safety_factor, created_at + ) VALUES ( + :confirmation_id, :item_code, :category, :specification, + :size, :material, :bom_quantity, :calculated_qty, + :unit, :safety_factor, CURRENT_TIMESTAMP + ) + """) + + db.execute(item_query, { + "confirmation_id": confirmation_id, + "item_code": item.item_code or f"{item.category}-{saved_items+1}", + "category": item.category, + "specification": item.specification, + "size": item.size or "", + "material": item.material or "", + "bom_quantity": item.bom_quantity, + "calculated_qty": item.calculated_qty, + "unit": item.unit, + "safety_factor": item.safety_factor + }) + saved_items += 1 + + # 4. ํŒŒ์ผ ์ƒํƒœ๋ฅผ ํ™•์ •์œผ๋กœ ์—…๋ฐ์ดํŠธ + file_update_query = text(""" + UPDATE files + SET purchase_confirmed = TRUE, + confirmed_at = :confirmed_at, + confirmed_by = :confirmed_by, + updated_at = CURRENT_TIMESTAMP + WHERE id = :file_id + """) + + db.execute(file_update_query, { + "file_id": request.file_id, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + }) + + db.commit() + + return { + "success": True, + "message": "๊ตฌ๋งค ์ˆ˜๋Ÿ‰์ด ์„ฑ๊ณต์ ์œผ๋กœ ํ™•์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "confirmation_id": confirmation_id, + "confirmed_items": saved_items, + "job_no": request.job_no, + "revision": request.revision, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • ์‹คํŒจ: {str(e)}") + @router.post("/items/save") async def save_purchase_items( job_no: str, diff --git a/backend/app/services/activity_logger.py b/backend/app/services/activity_logger.py new file mode 100644 index 0000000..7951841 --- /dev/null +++ b/backend/app/services/activity_logger.py @@ -0,0 +1,362 @@ +""" +์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ์„œ๋น„์Šค +๋ชจ๋“  ์—…๋ฌด ํ™œ๋™์„ ์ถ”์ ํ•˜๊ณ  ๊ธฐ๋กํ•˜๋Š” ์„œ๋น„์Šค +""" + +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import Optional, Dict, Any +from fastapi import Request +import json +from datetime import datetime + +from ..utils.logger import get_logger + +logger = get_logger(__name__) + + +class ActivityLogger: + """์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ๊ด€๋ฆฌ ํด๋ž˜์Šค""" + + def __init__(self, db: Session): + self.db = db + + def log_activity( + self, + username: str, + activity_type: str, + activity_description: str, + target_id: Optional[int] = None, + target_type: Optional[str] = None, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> int: + """ + ์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก + + Args: + username: ์‚ฌ์šฉ์ž๋ช… (ํ•„์ˆ˜) + activity_type: ํ™œ๋™ ์œ ํ˜• (FILE_UPLOAD, PROJECT_CREATE ๋“ฑ) + activity_description: ํ™œ๋™ ์„ค๋ช… + target_id: ๋Œ€์ƒ ID (ํŒŒ์ผ, ํ”„๋กœ์ ํŠธ ๋“ฑ) + target_type: ๋Œ€์ƒ ์œ ํ˜• (FILE, PROJECT ๋“ฑ) + user_id: ์‚ฌ์šฉ์ž ID + ip_address: IP ์ฃผ์†Œ + user_agent: ๋ธŒ๋ผ์šฐ์ € ์ •๋ณด + metadata: ์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + + Returns: + int: ์ƒ์„ฑ๋œ ๋กœ๊ทธ ID + """ + try: + insert_query = text(""" + INSERT INTO user_activity_logs ( + user_id, username, activity_type, activity_description, + target_id, target_type, ip_address, user_agent, metadata + ) VALUES ( + :user_id, :username, :activity_type, :activity_description, + :target_id, :target_type, :ip_address, :user_agent, :metadata + ) RETURNING id + """) + + result = self.db.execute(insert_query, { + 'user_id': user_id, + 'username': username, + 'activity_type': activity_type, + 'activity_description': activity_description, + 'target_id': target_id, + 'target_type': target_type, + 'ip_address': ip_address, + 'user_agent': user_agent, + 'metadata': json.dumps(metadata) if metadata else None + }) + + log_id = result.fetchone()[0] + self.db.commit() + + logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}") + return log_id + + except Exception as e: + logger.error(f"Failed to log activity: {str(e)}") + self.db.rollback() + raise + + def log_file_upload( + self, + username: str, + file_id: int, + filename: str, + file_size: int, + job_no: str, + revision: str, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """ํŒŒ์ผ ์—…๋กœ๋“œ ํ™œ๋™ ๋กœ๊ทธ""" + metadata = { + 'filename': filename, + 'file_size': file_size, + 'job_no': job_no, + 'revision': revision, + 'upload_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='FILE_UPLOAD', + activity_description=f'BOM ํŒŒ์ผ ์—…๋กœ๋“œ: {filename} (Job: {job_no}, Rev: {revision})', + target_id=file_id, + target_type='FILE', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def log_project_create( + self, + username: str, + project_id: int, + project_name: str, + job_no: str, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ ํ™œ๋™ ๋กœ๊ทธ""" + metadata = { + 'project_name': project_name, + 'job_no': job_no, + 'create_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='PROJECT_CREATE', + activity_description=f'ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ: {project_name} ({job_no})', + target_id=project_id, + target_type='PROJECT', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def log_material_classify( + self, + username: str, + file_id: int, + classified_count: int, + job_no: str, + revision: str, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """์ž์žฌ ๋ถ„๋ฅ˜ ํ™œ๋™ ๋กœ๊ทธ""" + metadata = { + 'classified_count': classified_count, + 'job_no': job_no, + 'revision': revision, + 'classify_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='MATERIAL_CLASSIFY', + activity_description=f'์ž์žฌ ๋ถ„๋ฅ˜ ์™„๋ฃŒ: {classified_count}๊ฐœ ์ž์žฌ (Job: {job_no}, Rev: {revision})', + target_id=file_id, + target_type='FILE', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def log_purchase_confirm( + self, + username: str, + job_no: str, + revision: str, + confirmed_count: int, + total_amount: Optional[float] = None, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """๊ตฌ๋งค ํ™•์ • ํ™œ๋™ ๋กœ๊ทธ""" + metadata = { + 'job_no': job_no, + 'revision': revision, + 'confirmed_count': confirmed_count, + 'total_amount': total_amount, + 'confirm_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='PURCHASE_CONFIRM', + activity_description=f'๊ตฌ๋งค ํ™•์ •: {confirmed_count}๊ฐœ ํ’ˆ๋ชฉ (Job: {job_no}, Rev: {revision})', + target_id=None, # ๊ตฌ๋งค๋Š” ํŠน์ • ID๊ฐ€ ์—†์Œ + target_type='PURCHASE', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def get_user_activities( + self, + username: str, + activity_type: Optional[str] = None, + limit: int = 50, + offset: int = 0 + ) -> list: + """์‚ฌ์šฉ์ž ํ™œ๋™ ์ด๋ ฅ ์กฐํšŒ""" + try: + where_clause = "WHERE username = :username" + params = {'username': username} + + if activity_type: + where_clause += " AND activity_type = :activity_type" + params['activity_type'] = activity_type + + query = text(f""" + SELECT + id, activity_type, activity_description, + target_id, target_type, metadata, created_at + FROM user_activity_logs + {where_clause} + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """) + + params.update({'limit': limit, 'offset': offset}) + result = self.db.execute(query, params) + + activities = [] + for row in result.fetchall(): + activity = { + 'id': row[0], + 'activity_type': row[1], + 'activity_description': row[2], + 'target_id': row[3], + 'target_type': row[4], + 'metadata': json.loads(row[5]) if row[5] else {}, + 'created_at': row[6].isoformat() if row[6] else None + } + activities.append(activity) + + return activities + + except Exception as e: + logger.error(f"Failed to get user activities: {str(e)}") + return [] + + def get_recent_activities( + self, + days: int = 7, + limit: int = 100 + ) -> list: + """์ตœ๊ทผ ํ™œ๋™ ์กฐํšŒ (์ „์ฒด ์‚ฌ์šฉ์ž)""" + try: + query = text(""" + SELECT + username, activity_type, activity_description, + target_id, target_type, created_at + FROM user_activity_logs + WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days' + ORDER BY created_at DESC + LIMIT :limit + """ % days) + + result = self.db.execute(query, {'limit': limit}) + + activities = [] + for row in result.fetchall(): + activity = { + 'username': row[0], + 'activity_type': row[1], + 'activity_description': row[2], + 'target_id': row[3], + 'target_type': row[4], + 'created_at': row[5].isoformat() if row[5] else None + } + activities.append(activity) + + return activities + + except Exception as e: + logger.error(f"Failed to get recent activities: {str(e)}") + return [] + + +def get_client_info(request: Request) -> tuple: + """ + ์š”์ฒญ์—์„œ ํด๋ผ์ด์–ธํŠธ ์ •๋ณด ์ถ”์ถœ + + Args: + request: FastAPI Request ๊ฐ์ฒด + + Returns: + tuple: (ip_address, user_agent) + """ + # IP ์ฃผ์†Œ ์ถ”์ถœ (ํ”„๋ก์‹œ ๊ณ ๋ ค) + ip_address = ( + request.headers.get('x-forwarded-for', '').split(',')[0].strip() or + request.headers.get('x-real-ip', '') or + request.client.host if request.client else 'unknown' + ) + + # User-Agent ์ถ”์ถœ + user_agent = request.headers.get('user-agent', 'unknown') + + return ip_address, user_agent + + +def log_activity_from_request( + db: Session, + request: Request, + username: str, + activity_type: str, + activity_description: str, + target_id: Optional[int] = None, + target_type: Optional[str] = None, + user_id: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None +) -> int: + """ + ์š”์ฒญ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ํ™œ๋™ ๋กœ๊ทธ ๊ธฐ๋ก (ํŽธ์˜ ํ•จ์ˆ˜) + + Args: + db: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ธ์…˜ + request: FastAPI Request ๊ฐ์ฒด + username: ์‚ฌ์šฉ์ž๋ช… + activity_type: ํ™œ๋™ ์œ ํ˜• + activity_description: ํ™œ๋™ ์„ค๋ช… + target_id: ๋Œ€์ƒ ID + target_type: ๋Œ€์ƒ ์œ ํ˜• + user_id: ์‚ฌ์šฉ์ž ID + metadata: ์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + + Returns: + int: ์ƒ์„ฑ๋œ ๋กœ๊ทธ ID + """ + ip_address, user_agent = get_client_info(request) + + activity_logger = ActivityLogger(db) + return activity_logger.log_activity( + username=username, + activity_type=activity_type, + activity_description=activity_description, + target_id=target_id, + target_type=target_type, + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) diff --git a/backend/app/services/pipe_classifier.py b/backend/app/services/pipe_classifier.py index f2661a8..dfe456e 100644 --- a/backend/app/services/pipe_classifier.py +++ b/backend/app/services/pipe_classifier.py @@ -29,13 +29,13 @@ PIPE_MANUFACTURING = { # ========== PIPE ๋ ๊ฐ€๊ณต๋ณ„ ๋ถ„๋ฅ˜ ========== PIPE_END_PREP = { "BOTH_ENDS_BEVELED": { - "codes": ["BOE", "BOTH END", "BOTH BEVELED", "์–‘์ชฝ๊ฐœ์„ "], + "codes": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "์–‘์ชฝ๊ฐœ์„ "], "cutting_note": "์–‘์ชฝ ๊ฐœ์„ ", "machining_required": True, "confidence": 0.95 }, "ONE_END_BEVELED": { - "codes": ["BE", "BEV", "PBE", "PIPE BEVELED END"], + "codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"], "cutting_note": "ํ•œ์ชฝ ๊ฐœ์„ ", "machining_required": True, "confidence": 0.95 @@ -45,9 +45,85 @@ PIPE_END_PREP = { "cutting_note": "๋ฌด ๊ฐœ์„ ", "machining_required": False, "confidence": 0.95 + }, + "THREADED": { + "codes": ["TOE", "THE", "THREADED", "๋‚˜์‚ฌ", "์Šค๋ ˆ๋“œ"], + "cutting_note": "๋‚˜์‚ฌ ๊ฐ€๊ณต", + "machining_required": True, + "confidence": 0.90 } } +# ========== ๊ตฌ๋งค์šฉ ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ (๋๋‹จ ๊ฐ€๊ณต ์ œ์™ธ) ========== +def get_purchase_pipe_description(description: str) -> str: + """๊ตฌ๋งค์šฉ ํŒŒ์ดํ”„ ์„ค๋ช… - ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ œ๊ฑฐ""" + + # ๋ชจ๋“  ๋๋‹จ ๊ฐ€๊ณต ์ฝ”๋“œ๋“ค์„ ์ˆ˜์ง‘ + end_prep_codes = [] + for prep_data in PIPE_END_PREP.values(): + end_prep_codes.extend(prep_data["codes"]) + + # ์„ค๋ช…์—์„œ ๋๋‹จ ๊ฐ€๊ณต ์ฝ”๋“œ ์ œ๊ฑฐ + clean_description = description.upper() + + # ๋๋‹จ ๊ฐ€๊ณต ์ฝ”๋“œ๋“ค์„ ๊ธธ์ด ์ˆœ์œผ๋กœ ์ •๋ ฌ (๊ธด ๊ฒƒ๋ถ€ํ„ฐ ์ฒ˜๋ฆฌ) + end_prep_codes.sort(key=len, reverse=True) + + for code in end_prep_codes: + # ๋‹จ์–ด ๊ฒฝ๊ณ„๋ฅผ ๊ณ ๋ คํ•˜์—ฌ ์ œ๊ฑฐ (๋ถ€๋ถ„ ๋งค์นญ ๋ฐฉ์ง€) + pattern = r'\b' + re.escape(code) + r'\b' + clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE) + + # ๋๋‹จ ๊ฐ€๊ณต ๊ด€๋ จ ํŒจํ„ด๋“ค ์ถ”๊ฐ€ ์ œ๊ฑฐ + # BOE-POE, POE-TOE ๊ฐ™์€ ์กฐํ•ฉ ํŒจํ„ด๋“ค + end_prep_patterns = [ + r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE ๋“ฑ + r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO ๋“ฑ + r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE ๋“ฑ + r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO ๋“ฑ + ] + + for pattern in end_prep_patterns: + clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE) + + # ๋‚จ์€ ํ•˜์ดํ”ˆ๊ณผ ๊ณต๋ฐฑ ์ •๋ฆฌ + clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # ํ•˜์ดํ”ˆ ์ œ๊ฑฐ + clean_description = re.sub(r'\s+', ' ', clean_description).strip() # ์—ฐ์† ๊ณต๋ฐฑ ์ •๋ฆฌ + + return clean_description + +def extract_end_preparation_info(description: str) -> Dict: + """ํŒŒ์ดํ”„ ์„ค๋ช…์—์„œ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ถ”์ถœ""" + + desc_upper = description.upper() + + # ๋๋‹จ ๊ฐ€๊ณต ์ฝ”๋“œ ์ฐพ๊ธฐ + for prep_type, prep_data in PIPE_END_PREP.items(): + for code in prep_data["codes"]: + if code in desc_upper: + return { + "end_preparation_type": prep_type, + "end_preparation_code": code, + "machining_required": prep_data["machining_required"], + "cutting_note": prep_data["cutting_note"], + "confidence": prep_data["confidence"], + "matched_pattern": code, + "original_description": description, + "clean_description": get_purchase_pipe_description(description) + } + + # ๊ธฐ๋ณธ๊ฐ’: PBE (์–‘์ชฝ ๋ฌด๊ฐœ์„ ) + return { + "end_preparation_type": "NO_BEVEL", # PBE๋กœ ๋งคํ•‘๋  ์˜ˆ์ • + "end_preparation_code": "PBE", + "machining_required": False, + "cutting_note": "์–‘์ชฝ ๋ฌด๊ฐœ์„  (๊ธฐ๋ณธ๊ฐ’)", + "confidence": 0.5, + "matched_pattern": "DEFAULT", + "original_description": description, + "clean_description": get_purchase_pipe_description(description) + } + # ========== PIPE ์Šค์ผ€์ค„๋ณ„ ๋ถ„๋ฅ˜ ========== PIPE_SCHEDULE = { "patterns": [ @@ -62,6 +138,23 @@ PIPE_SCHEDULE = { ] } +def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str, + length: Optional[float] = None) -> Dict: + """๊ตฌ๋งค์šฉ ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ - ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ œ์™ธ""" + + # ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ์ œ๊ฑฐํ•œ ์„ค๋ช…์œผ๋กœ ๋ถ„๋ฅ˜ + clean_description = get_purchase_pipe_description(description) + + # ๊ธฐ๋ณธ ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ ์ˆ˜ํ–‰ + result = classify_pipe(dat_file, clean_description, main_nom, length) + + # ๊ตฌ๋งค์šฉ์ž„์„ ํ‘œ์‹œ + result["purchase_classification"] = True + result["original_description"] = description + result["clean_description"] = clean_description + + return result + def classify_pipe(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict: """ diff --git a/backend/app/services/revision_comparator.py b/backend/app/services/revision_comparator.py new file mode 100644 index 0000000..a89c010 --- /dev/null +++ b/backend/app/services/revision_comparator.py @@ -0,0 +1,289 @@ +""" +๋ฆฌ๋น„์ „ ๋น„๊ต ์„œ๋น„์Šค +- ๊ธฐ์กด ํ™•์ • ์ž์žฌ์™€ ์‹ ๊ทœ ์ž์žฌ ๋น„๊ต +- ๋ณ€๊ฒฝ๋œ ์ž์žฌ๋งŒ ๋ถ„๋ฅ˜ ์ฒ˜๋ฆฌ +- ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ์ตœ์ ํ™” +""" + +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Dict, Tuple, Optional +import hashlib +import logging + +logger = logging.getLogger(__name__) + +class RevisionComparator: + """๋ฆฌ๋น„์ „ ๋น„๊ต ๋ฐ ์ฐจ์ด ๋ถ„์„ ํด๋ž˜์Šค""" + + def __init__(self, db: Session): + self.db = db + + def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]: + """ + ์ด์ „ ํ™•์ •๋œ ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ + + Args: + job_no: ํ”„๋กœ์ ํŠธ ๋ฒˆํ˜ธ + current_revision: ํ˜„์žฌ ๋ฆฌ๋น„์ „ (์˜ˆ: Rev.1) + + Returns: + ํ™•์ •๋œ ์ž์žฌ ์ •๋ณด ๋”•์…”๋„ˆ๋ฆฌ ๋˜๋Š” None + """ + try: + # ํ˜„์žฌ ๋ฆฌ๋น„์ „ ๋ฒˆํ˜ธ ์ถ”์ถœ + current_rev_num = self._extract_revision_number(current_revision) + + # ์ด์ „ ๋ฆฌ๋น„์ „๋“ค ์ค‘ ํ™•์ •๋œ ๊ฒƒ ์ฐพ๊ธฐ (์—ญ์ˆœ์œผ๋กœ ๊ฒ€์ƒ‰) + for prev_rev_num in range(current_rev_num - 1, -1, -1): + prev_revision = f"Rev.{prev_rev_num}" + + # ํ•ด๋‹น ๋ฆฌ๋น„์ „์˜ ํ™•์ • ๋ฐ์ดํ„ฐ ์กฐํšŒ + query = text(""" + SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by, + COUNT(cpi.id) as confirmed_items_count + FROM purchase_confirmations pc + LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id + WHERE pc.job_no = :job_no + AND pc.revision = :revision + AND pc.is_active = TRUE + GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by + ORDER BY pc.confirmed_at DESC + LIMIT 1 + """) + + result = self.db.execute(query, { + "job_no": job_no, + "revision": prev_revision + }).fetchone() + + if result and result.confirmed_items_count > 0: + logger.info(f"์ด์ „ ํ™•์ • ์ž๋ฃŒ ๋ฐœ๊ฒฌ: {job_no} {prev_revision} ({result.confirmed_items_count}๊ฐœ ํ’ˆ๋ชฉ)") + + # ํ™•์ •๋œ ํ’ˆ๋ชฉ๋“ค ์ƒ์„ธ ์กฐํšŒ + items_query = text(""" + SELECT cpi.item_code, cpi.category, cpi.specification, + cpi.size, cpi.material, cpi.bom_quantity, + cpi.calculated_qty, cpi.unit, cpi.safety_factor + FROM confirmed_purchase_items cpi + WHERE cpi.confirmation_id = :confirmation_id + ORDER BY cpi.category, cpi.specification + """) + + items_result = self.db.execute(items_query, { + "confirmation_id": result.id + }).fetchall() + + return { + "confirmation_id": result.id, + "revision": result.revision, + "confirmed_at": result.confirmed_at, + "confirmed_by": result.confirmed_by, + "items": [dict(item) for item in items_result], + "items_count": len(items_result) + } + + logger.info(f"์ด์ „ ํ™•์ • ์ž๋ฃŒ ์—†์Œ: {job_no} (ํ˜„์žฌ: {current_revision})") + return None + + except Exception as e: + logger.error(f"์ด์ „ ํ™•์ • ์ž๋ฃŒ ์กฐํšŒ ์‹คํŒจ: {str(e)}") + return None + + def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict: + """ + ๊ธฐ์กด ํ™•์ • ์ž์žฌ์™€ ์‹ ๊ทœ ์ž์žฌ ๋น„๊ต + + Args: + previous_confirmed: ์ด์ „ ํ™•์ • ์ž์žฌ ์ •๋ณด + new_materials: ์‹ ๊ทœ ์—…๋กœ๋“œ๋œ ์ž์žฌ ๋ชฉ๋ก + + Returns: + ๋น„๊ต ๊ฒฐ๊ณผ ๋”•์…”๋„ˆ๋ฆฌ + """ + try: + # ์ด์ „ ํ™•์ • ์ž์žฌ๋ฅผ ํ•ด์‹œ๋งต์œผ๋กœ ๋ณ€ํ™˜ (๋น ๋ฅธ ๊ฒ€์ƒ‰์„ ์œ„ํ•ด) + confirmed_materials = {} + for item in previous_confirmed["items"]: + material_hash = self._generate_material_hash( + item["specification"], + item["size"], + item["material"] + ) + confirmed_materials[material_hash] = item + + # ์‹ ๊ทœ ์ž์žฌ ๋ถ„์„ + unchanged_materials = [] # ๋ณ€๊ฒฝ ์—†์Œ (๋ถ„๋ฅ˜ ๋ถˆํ•„์š”) + changed_materials = [] # ๋ณ€๊ฒฝ๋จ (์žฌ๋ถ„๋ฅ˜ ํ•„์š”) + new_materials_list = [] # ์‹ ๊ทœ ์ถ”๊ฐ€ (๋ถ„๋ฅ˜ ํ•„์š”) + + for new_material in new_materials: + # ์ž์žฌ ํ•ด์‹œ ์ƒ์„ฑ (description ๊ธฐ๋ฐ˜) + description = new_material.get("description", "") + size = self._extract_size_from_description(description) + material = self._extract_material_from_description(description) + + material_hash = self._generate_material_hash(description, size, material) + + if material_hash in confirmed_materials: + confirmed_item = confirmed_materials[material_hash] + + # ์ˆ˜๋Ÿ‰ ๋น„๊ต + new_qty = float(new_material.get("quantity", 0)) + confirmed_qty = float(confirmed_item["bom_quantity"]) + + if abs(new_qty - confirmed_qty) > 0.001: # ์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ + changed_materials.append({ + **new_material, + "change_type": "QUANTITY_CHANGED", + "previous_quantity": confirmed_qty, + "previous_item": confirmed_item + }) + else: + # ์ˆ˜๋Ÿ‰ ๋™์ผ - ๊ธฐ์กด ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ์žฌ์‚ฌ์šฉ + unchanged_materials.append({ + **new_material, + "reuse_classification": True, + "previous_item": confirmed_item + }) + else: + # ์‹ ๊ทœ ์ž์žฌ + new_materials_list.append({ + **new_material, + "change_type": "NEW_MATERIAL" + }) + + # ์‚ญ์ œ๋œ ์ž์žฌ ์ฐพ๊ธฐ (์ด์ „์—๋Š” ์žˆ์—ˆ์ง€๋งŒ ํ˜„์žฌ๋Š” ์—†๋Š” ๊ฒƒ) + new_material_hashes = set() + for material in new_materials: + description = material.get("description", "") + size = self._extract_size_from_description(description) + material_grade = self._extract_material_from_description(description) + hash_key = self._generate_material_hash(description, size, material_grade) + new_material_hashes.add(hash_key) + + removed_materials = [] + for hash_key, confirmed_item in confirmed_materials.items(): + if hash_key not in new_material_hashes: + removed_materials.append({ + "change_type": "REMOVED", + "previous_item": confirmed_item + }) + + comparison_result = { + "has_previous_confirmation": True, + "previous_revision": previous_confirmed["revision"], + "previous_confirmed_at": previous_confirmed["confirmed_at"], + "unchanged_count": len(unchanged_materials), + "changed_count": len(changed_materials), + "new_count": len(new_materials_list), + "removed_count": len(removed_materials), + "total_materials": len(new_materials), + "classification_needed": len(changed_materials) + len(new_materials_list), + "unchanged_materials": unchanged_materials, + "changed_materials": changed_materials, + "new_materials": new_materials_list, + "removed_materials": removed_materials + } + + logger.info(f"๋ฆฌ๋น„์ „ ๋น„๊ต ์™„๋ฃŒ: ๋ณ€๊ฒฝ์—†์Œ {len(unchanged_materials)}, " + f"๋ณ€๊ฒฝ๋จ {len(changed_materials)}, ์‹ ๊ทœ {len(new_materials_list)}, " + f"์‚ญ์ œ๋จ {len(removed_materials)}") + + return comparison_result + + except Exception as e: + logger.error(f"์ž์žฌ ๋น„๊ต ์‹คํŒจ: {str(e)}") + raise + + def _extract_revision_number(self, revision: str) -> int: + """๋ฆฌ๋น„์ „ ๋ฌธ์ž์—ด์—์„œ ์ˆซ์ž ์ถ”์ถœ (Rev.1 โ†’ 1)""" + try: + if revision.startswith("Rev."): + return int(revision.replace("Rev.", "")) + return 0 + except ValueError: + return 0 + + def _generate_material_hash(self, description: str, size: str, material: str) -> str: + """์ž์žฌ ๊ณ ์œ ์„ฑ ํŒ๋‹จ์„ ์œ„ํ•œ ํ•ด์‹œ ์ƒ์„ฑ""" + # RULES.md์˜ ์ฝ”๋”ฉ ์ปจ๋ฒค์…˜ ์ค€์ˆ˜ + hash_input = f"{description}|{size}|{material}".lower().strip() + return hashlib.md5(hash_input.encode()).hexdigest() + + def _extract_size_from_description(self, description: str) -> str: + """์ž์žฌ ์„ค๋ช…์—์„œ ์‚ฌ์ด์ฆˆ ์ •๋ณด ์ถ”์ถœ""" + # ๊ฐ„๋‹จํ•œ ์‚ฌ์ด์ฆˆ ํŒจํ„ด ์ถ”์ถœ (์‹ค์ œ๋กœ๋Š” ๋” ์ •๊ตํ•œ ๋กœ์ง ํ•„์š”) + import re + size_patterns = [ + r'(\d+(?:\.\d+)?)\s*(?:mm|MM|์ธ์น˜|inch|")', + r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)', + r'DN\s*(\d+)', + r'(\d+)\s*A' + ] + + for pattern in size_patterns: + match = re.search(pattern, description, re.IGNORECASE) + if match: + return match.group(0) + + return "" + + def _extract_material_from_description(self, description: str) -> str: + """์ž์žฌ ์„ค๋ช…์—์„œ ์žฌ์งˆ ์ •๋ณด ์ถ”์ถœ""" + # ์ผ๋ฐ˜์ ์ธ ์žฌ์งˆ ํŒจํ„ด + materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"] + + description_upper = description.upper() + for material in materials: + if material in description_upper: + return material + + return "" + +def get_revision_comparison(db: Session, job_no: str, current_revision: str, + new_materials: List[Dict]) -> Dict: + """ + ๋ฆฌ๋น„์ „ ๋น„๊ต ์ˆ˜ํ–‰ (ํŽธ์˜ ํ•จ์ˆ˜) + + Args: + db: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ธ์…˜ + job_no: ํ”„๋กœ์ ํŠธ ๋ฒˆํ˜ธ + current_revision: ํ˜„์žฌ ๋ฆฌ๋น„์ „ + new_materials: ์‹ ๊ทœ ์ž์žฌ ๋ชฉ๋ก + + Returns: + ๋น„๊ต ๊ฒฐ๊ณผ ๋˜๋Š” ์ „์ฒด ๋ถ„๋ฅ˜ ํ•„์š” ์ •๋ณด + """ + comparator = RevisionComparator(db) + + # ์ด์ „ ํ™•์ • ์ž๋ฃŒ ์กฐํšŒ + previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision) + + if previous_confirmed is None: + # ์ด์ „ ํ™•์ • ์ž๋ฃŒ๊ฐ€ ์—†์œผ๋ฉด ์ „์ฒด ๋ถ„๋ฅ˜ ํ•„์š” + return { + "has_previous_confirmation": False, + "classification_needed": len(new_materials), + "all_materials_need_classification": True, + "materials_to_classify": new_materials, + "message": "์ด์ „ ํ™•์ • ์ž๋ฃŒ๊ฐ€ ์—†์–ด ์ „์ฒด ์ž์žฌ๋ฅผ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค." + } + + # ์ด์ „ ํ™•์ • ์ž๋ฃŒ๊ฐ€ ์žˆ์œผ๋ฉด ๋น„๊ต ์ˆ˜ํ–‰ + return comparator.compare_materials(previous_confirmed, new_materials) + + + + + + + + + + + + + + + diff --git a/backend/debug_step_by_step.py b/backend/debug_step_by_step.py deleted file mode 100644 index e9392db..0000000 --- a/backend/debug_step_by_step.py +++ /dev/null @@ -1,41 +0,0 @@ -from app.services.integrated_classifier import LEVEL1_TYPE_KEYWORDS - -test = "NIPPLE, SMLS, SCH 80, ASTM A106 GR B PBE" -print(f"ํ…Œ์ŠคํŠธ: {test}") - -desc_upper = test.upper() -desc_parts = [part.strip() for part in desc_upper.split(',')] - -print(f"๋Œ€๋ฌธ์ž ๋ณ€ํ™˜: {desc_upper}") -print(f"์‰ผํ‘œ ๋ถ„๋ฆฌ: {desc_parts}") - -# ๋‹จ๊ณ„๋ณ„ ๋””๋ฒ„๊น… -detected_types = [] -for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items(): - type_found = False - for keyword in keywords: - # ์ „์ฒด ๋ฌธ์ž์—ด์—์„œ ์ฐพ๊ธฐ - if keyword in desc_upper: - print(f"โœ“ {material_type}: '{keyword}' ์ „์ฒด ๋ฌธ์ž์—ด์—์„œ ๋ฐœ๊ฒฌ") - detected_types.append((material_type, keyword)) - type_found = True - break - # ๊ฐ ๋ถ€๋ถ„์—์„œ๋„ ์ •ํ™•ํžˆ ๋งค์นญ๋˜๋Š”์ง€ ํ™•์ธ - for part in desc_parts: - if keyword == part or keyword in part: - print(f"โœ“ {material_type}: '{keyword}' ๋ถ€๋ถ„ '{part}'์—์„œ ๋ฐœ๊ฒฌ") - detected_types.append((material_type, keyword)) - type_found = True - break - if type_found: - break - -print(f"\n๊ฐ์ง€๋œ ํƒ€์ž…๋“ค: {detected_types}") -print(f"๊ฐ์ง€๋œ ํƒ€์ž… ๊ฐœ์ˆ˜: {len(detected_types)}") - -if len(detected_types) == 1: - print(f"๋‹จ์ผ ํƒ€์ž… ํ™•์ •: {detected_types[0][0]}") -elif len(detected_types) > 1: - print(f"๋ณต์ˆ˜ ํƒ€์ž… ๊ฐ์ง€: {detected_types}") -else: - print("Level 1 ํ‚ค์›Œ๋“œ ์—†์Œ - ์žฌ์งˆ ๊ธฐ๋ฐ˜ ๋ถ„๋ฅ˜๋กœ ์ด๋™") \ No newline at end of file diff --git a/backend/example_corrected_spool_usage.py b/backend/example_corrected_spool_usage.py deleted file mode 100644 index 40e6afb..0000000 --- a/backend/example_corrected_spool_usage.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -์ˆ˜์ •๋œ ์Šคํ’€ ์‹œ์Šคํ…œ ์‚ฌ์šฉ ์˜ˆ์‹œ -""" - -# ์‹œ๋‚˜๋ฆฌ์˜ค: A-1 ๋„๋ฉด์—์„œ ํŒŒ์ดํ”„ 3๊ฐœ ๋ฐœ๊ฒฌ -examples = [ - { - "dwg_name": "A-1", - "pipes": [ - {"description": "PIPE 1", "user_input_spool": "A"}, # A-1-A - {"description": "PIPE 2", "user_input_spool": "A"}, # A-1-A (๊ฐ™์€ ์Šคํ’€) - {"description": "PIPE 3", "user_input_spool": "B"} # A-1-B (๋‹ค๋ฅธ ์Šคํ’€) - ], - "area_assignment": "#01" # ๋ณ„๋„: A-1 ๋„๋ฉด์€ #01 ๊ตฌ์—ญ์— ์œ„์น˜ - } -] - -# ๊ฒฐ๊ณผ: -spool_identifiers = [ - "A-1-A", # ํŒŒ์ดํ”„ 1, 2๊ฐ€ ์†ํ•จ - "A-1-B" # ํŒŒ์ดํ”„ 3์ด ์†ํ•จ -] - -area_assignment = { - "#01": ["A-1"] # A-1 ๋„๋ฉด์€ #01 ๊ตฌ์—ญ์— ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์œ„์น˜ -} - -print("โœ… ์ˆ˜์ •๋œ ์Šคํ’€ ๊ตฌ์กฐ๊ฐ€ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!") -print(f"์Šคํ’€ ์‹๋ณ„์ž: {spool_identifiers}") -print(f"์—๋ฆฌ์–ด ํ• ๋‹น: {area_assignment}") diff --git a/backend/scripts/03_insert_dummy_data.py b/backend/scripts/03_insert_dummy_data.py deleted file mode 100644 index 48220e5..0000000 --- a/backend/scripts/03_insert_dummy_data.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -""" -๋”๋ฏธ ํ”„๋กœ์ ํŠธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ ์Šคํฌ๋ฆฝํŠธ -""" - -import sys -import os -from datetime import datetime, date -from sqlalchemy import create_engine, text - -# ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ Python path์— ์ถ”๊ฐ€ -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -def create_dummy_jobs(): - """๋”๋ฏธ Job ๋ฐ์ดํ„ฐ ์ƒ์„ฑ""" - - # ๊ฐ„๋‹จํ•œ SQLite ์—ฐ๊ฒฐ (์‹ค์ œ DB ์„ค์ •์— ๋งž๊ฒŒ ์ˆ˜์ •) - try: - # ์‹ค์ œ ํ”„๋กœ์ ํŠธ์˜ database.py ์„ค์ • ์‚ฌ์šฉ - from app.database import engine - print("โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ฑ๊ณต") - except ImportError: - # ์ง์ ‘ ์—ฐ๊ฒฐ (๊ฐœ๋ฐœ์šฉ) - DATABASE_URL = "sqlite:///./test.db" # ์‹ค์ œ DB URL๋กœ ๋ณ€๊ฒฝ - engine = create_engine(DATABASE_URL) - print("โš ๏ธ ์ง์ ‘ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ") - - # ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์ •์˜ - dummy_jobs = [ - { - 'job_no': 'J24-001', - 'job_name': '์šธ์‚ฐ SK์—๋„ˆ์ง€ ์ •์œ ์‹œ์„ค ์ฆ์„ค ๋ฐฐ๊ด€๊ณต์‚ฌ', - 'client_name': '์‚ผ์„ฑ์—”์ง€๋‹ˆ์–ด๋ง', - 'end_user': 'SK์—๋„ˆ์ง€', - 'epc_company': '์‚ผ์„ฑ์—”์ง€๋‹ˆ์–ด๋ง', - 'project_site': '์šธ์‚ฐ๊ด‘์—ญ์‹œ ์˜จ์‚ฐ๊ณต๋‹จ SK์—๋„ˆ์ง€ ์ •์œ ๊ณต์žฅ', - 'contract_date': '2024-03-15', - 'delivery_date': '2024-08-30', - 'delivery_terms': 'FOB ์šธ์‚ฐํ•ญ', - 'status': '์ง„ํ–‰์ค‘', - 'description': '์ •์œ ์‹œ์„ค ์ฆ์„ค์„ ์œ„ํ•œ ๋ฐฐ๊ด€ ์ž์žฌ ๊ณต๊ธ‰ ํ”„๋กœ์ ํŠธ. ๊ณ ์˜จ๊ณ ์•• ๋ฐฐ๊ด€ ๋ฐ ํŠน์ˆ˜ ๋ฐธ๋ธŒ ํฌํ•จ.', - 'created_by': 'admin' - }, - { - 'job_no': 'J24-002', - 'job_name': 'ํฌ์Šค์ฝ” ๊ด‘์–‘ ์ œ์ฒ ์†Œ ๋ฐฐ๊ด€ ์ •๋น„๊ณต์‚ฌ', - 'client_name': 'ํฌ์Šค์ฝ”', - 'end_user': 'ํฌ์Šค์ฝ”', - 'epc_company': None, - 'project_site': '์ „๋‚จ ๊ด‘์–‘์‹œ ํฌ์Šค์ฝ” ๊ด‘์–‘์ œ์ฒ ์†Œ', - 'contract_date': '2024-04-02', - 'delivery_date': '2024-07-15', - 'delivery_terms': 'DDP ๊ด‘์–‘์ œ์ฒ ์†Œ ํ˜„์žฅ', - 'status': '์ง„ํ–‰์ค‘', - 'description': '์ œ์ฒ ์†Œ ์ •๊ธฐ ์ •๋น„๋ฅผ ์œ„ํ•œ ๋ฐฐ๊ด€ ๋ถ€ํ’ˆ ๊ต์ฒด. ๋‚ด์—ด์„ฑ ํŠน์ˆ˜๊ฐ• ๋ฐฐ๊ด€ ํฌํ•จ.', - 'created_by': 'admin' - } - ] - - try: - with engine.connect() as conn: - # ๊ธฐ์กด ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์‚ญ์ œ (๊ฐœ๋ฐœ์šฉ) - print("๐Ÿงน ๊ธฐ์กด ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ...") - conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')")) - - # ์ƒˆ ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… - print("๐Ÿ“ ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์ค‘...") - - for job in dummy_jobs: - insert_query = text(""" - INSERT INTO jobs ( - job_no, job_name, client_name, end_user, epc_company, - project_site, contract_date, delivery_date, delivery_terms, - status, description, created_by, is_active - ) VALUES ( - :job_no, :job_name, :client_name, :end_user, :epc_company, - :project_site, :contract_date, :delivery_date, :delivery_terms, - :status, :description, :created_by, :is_active - ) - """) - - conn.execute(insert_query, {**job, 'is_active': True}) - print(f"โœ… {job['job_no']}: {job['job_name']}") - - # ์ปค๋ฐ‹ - conn.commit() - - # ๊ฒฐ๊ณผ ํ™•์ธ - result = conn.execute(text(""" - SELECT job_no, job_name, client_name, status - FROM jobs - WHERE job_no IN ('J24-001', 'J24-002') - """)) - jobs = result.fetchall() - - print(f"\n๐ŸŽ‰ ์ด {len(jobs)}๊ฐœ ๋”๋ฏธ Job ์ƒ์„ฑ ์™„๋ฃŒ!") - print("\n๐Ÿ“‹ ์ƒ์„ฑ๋œ ๋”๋ฏธ ๋ฐ์ดํ„ฐ:") - for job in jobs: - print(f" โ€ข {job[0]}: {job[1]} ({job[2]}) - {job[3]}") - - return True - - except Exception as e: - print(f"โŒ ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ ์‹คํŒจ: {e}") - return False - -if __name__ == "__main__": - create_dummy_jobs() diff --git a/backend/scripts/18_create_auth_tables.sql b/backend/scripts/18_create_auth_tables.sql index e14b541..54f5cb9 100644 --- a/backend/scripts/18_create_auth_tables.sql +++ b/backend/scripts/18_create_auth_tables.sql @@ -218,3 +218,19 @@ BEGIN RAISE NOTICE '๐Ÿ‘ค ๊ธฐ๋ณธ ๊ณ„์ •: admin/admin123, system/admin123'; RAISE NOTICE '๐Ÿ” ๊ถŒํ•œ ์‹œ์Šคํ…œ: 5๋‹จ๊ณ„ ์—ญํ•  + ๋ชจ๋“ˆ๋ณ„ ์„ธ๋ถ„ํ™”๋œ ๊ถŒํ•œ'; END $$; + + + + + + + + + + + + + + + + diff --git a/backend/scripts/19_add_user_tracking_fields.sql b/backend/scripts/19_add_user_tracking_fields.sql new file mode 100644 index 0000000..c6df4e3 --- /dev/null +++ b/backend/scripts/19_add_user_tracking_fields.sql @@ -0,0 +1,142 @@ +-- ์‚ฌ์šฉ์ž ์ถ”์  ๋ฐ ๋‹ด๋‹น์ž ๊ธฐ๋ก ํ•„๋“œ ์ถ”๊ฐ€ +-- ์ƒ์„ฑ์ผ: 2025.01 +-- ๋ชฉ์ : RULES ๊ฐ€์ด๋“œ๋ผ์ธ์— ๋”ฐ๋ฅธ ์‚ฌ์šฉ์ž ์ถ”์  ์‹œ์Šคํ…œ ๊ตฌ์ถ• + +-- ================================ +-- 1. ๊ธฐ์กด ํ…Œ์ด๋ธ”์— ๋‹ด๋‹น์ž ํ•„๋“œ ์ถ”๊ฐ€ +-- ================================ + +-- files ํ…Œ์ด๋ธ” ์ˆ˜์ • (uploaded_by๋Š” ์ด๋ฏธ ์กด์žฌ) +ALTER TABLE files +ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +-- jobs ํ…Œ์ด๋ธ” ์ˆ˜์ • +ALTER TABLE jobs +ADD COLUMN IF NOT EXISTS created_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS assigned_to VARCHAR(100); + +-- materials ํ…Œ์ด๋ธ” ์ˆ˜์ • +ALTER TABLE materials +ADD COLUMN IF NOT EXISTS classified_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS classified_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100); + +-- ================================ +-- 2. ์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- ================================ + +CREATE TABLE IF NOT EXISTS user_activity_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER, -- users ํ…Œ์ด๋ธ” ์ฐธ์กฐ (์™ธ๋ž˜ํ‚ค ์ œ์•ฝ ์—†์Œ - ์œ ์—ฐ์„ฑ) + username VARCHAR(100) NOT NULL, -- ์‚ฌ์šฉ์ž๋ช… (ํ•„์ˆ˜) + + -- ํ™œ๋™ ์ •๋ณด + activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' ๋“ฑ + activity_description TEXT, -- ์ƒ์„ธ ํ™œ๋™ ๋‚ด์šฉ + + -- ๋Œ€์ƒ ์ •๋ณด + target_id INTEGER, -- ๋Œ€์ƒ ID (ํŒŒ์ผ, ํ”„๋กœ์ ํŠธ ๋“ฑ) + target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL', 'PURCHASE' ๋“ฑ + + -- ์„ธ์…˜ ์ •๋ณด + ip_address VARCHAR(45), -- IP ์ฃผ์†Œ + user_agent TEXT, -- ๋ธŒ๋ผ์šฐ์ € ์ •๋ณด + + -- ์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (JSON) + metadata JSONB, -- ์ถ”๊ฐ€ ์ •๋ณด (ํŒŒ์ผ ํฌ๊ธฐ, ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ ๋“ฑ) + + -- ์‹œ๊ฐ„ ์ •๋ณด + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 3. ๊ตฌ๋งค ๊ด€๋ จ ํ…Œ์ด๋ธ” ์ˆ˜์ • +-- ================================ + +-- purchase_items ํ…Œ์ด๋ธ” ์ˆ˜์ • (์ด๋ฏธ created_by ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ ํ›„ ์ถ”๊ฐ€) +ALTER TABLE purchase_items +ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP; + +-- material_purchase_tracking ํ…Œ์ด๋ธ” ์ˆ˜์ • (์ด๋ฏธ confirmed_by ์กด์žฌ) +ALTER TABLE material_purchase_tracking +ADD COLUMN IF NOT EXISTS ordered_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS ordered_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100), +ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP; + +-- ================================ +-- 4. ์ธ๋ฑ์Šค ์ƒ์„ฑ (์„ฑ๋Šฅ ์ตœ์ ํ™”) +-- ================================ + +-- ์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ ์ธ๋ฑ์Šค +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_username ON user_activity_logs(username); +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity_logs(activity_type); +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id); + +-- ๋‹ด๋‹น์ž ํ•„๋“œ ์ธ๋ฑ์Šค +CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by); +CREATE INDEX IF NOT EXISTS idx_files_updated_by ON files(updated_by); +CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by); +CREATE INDEX IF NOT EXISTS idx_jobs_assigned_to ON jobs(assigned_to); +CREATE INDEX IF NOT EXISTS idx_materials_classified_by ON materials(classified_by); + +-- ================================ +-- 5. ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ (์ž๋™ updated_at ๊ฐฑ์‹ ) +-- ================================ + +-- files ํ…Œ์ด๋ธ” updated_at ์ž๋™ ๊ฐฑ์‹  +CREATE OR REPLACE FUNCTION update_files_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS trigger_files_updated_at ON files; +CREATE TRIGGER trigger_files_updated_at + BEFORE UPDATE ON files + FOR EACH ROW + EXECUTE FUNCTION update_files_updated_at(); + +-- jobs ํ…Œ์ด๋ธ” updated_at ์ž๋™ ๊ฐฑ์‹  +CREATE OR REPLACE FUNCTION update_jobs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS trigger_jobs_updated_at ON jobs; +CREATE TRIGGER trigger_jobs_updated_at + BEFORE UPDATE ON jobs + FOR EACH ROW + EXECUTE FUNCTION update_jobs_updated_at(); + +-- ================================ +-- 6. ๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ ์„ค์ • +-- ================================ + +-- ๊ธฐ์กด ๋ฐ์ดํ„ฐ์— ๊ธฐ๋ณธ ๋‹ด๋‹น์ž ์„ค์ • (์‹œ์Šคํ…œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์šฉ) +UPDATE files SET uploaded_by = 'system' WHERE uploaded_by IS NULL; +UPDATE jobs SET created_by = 'system' WHERE created_by IS NULL; + +-- ================================ +-- 7. ๊ถŒํ•œ ๋ฐ ๋ณด์•ˆ ์„ค์ • +-- ================================ + +-- ํ™œ๋™ ๋กœ๊ทธ ํ…Œ์ด๋ธ”์€ INSERT๋งŒ ํ—ˆ์šฉ (์ˆ˜์ •/์‚ญ์ œ ๋ฐฉ์ง€) +-- ์‹ค์ œ ์šด์˜์—์„œ๋Š” ๋ณ„๋„ ๊ถŒํ•œ ๊ด€๋ฆฌ ํ•„์š” + +COMMENT ON TABLE user_activity_logs IS '์‚ฌ์šฉ์ž ํ™œ๋™ ๋กœ๊ทธ - ๋ชจ๋“  ์—…๋ฌด ํ™œ๋™ ์ถ”์ '; +COMMENT ON COLUMN user_activity_logs.activity_type IS 'ํ™œ๋™ ์œ ํ˜•: FILE_UPLOAD, PROJECT_CREATE, PURCHASE_CONFIRM, MATERIAL_CLASSIFY ๋“ฑ'; +COMMENT ON COLUMN user_activity_logs.metadata IS '์ถ”๊ฐ€ ์ •๋ณด JSON: ํŒŒ์ผํฌ๊ธฐ, ์ฒ˜๋ฆฌ์‹œ๊ฐ„, ๋ณ€๊ฒฝ๋‚ด์šฉ ๋“ฑ'; + +-- ์™„๋ฃŒ ๋ฉ”์‹œ์ง€ +SELECT 'User tracking system tables created successfully!' as result; diff --git a/backend/scripts/20_add_pipe_end_preparation_table.sql b/backend/scripts/20_add_pipe_end_preparation_table.sql new file mode 100644 index 0000000..339938f --- /dev/null +++ b/backend/scripts/20_add_pipe_end_preparation_table.sql @@ -0,0 +1,49 @@ +-- ํŒŒ์ดํ”„ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- ๊ฐ ํŒŒ์ดํ”„๋ณ„๋กœ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด๋ฅผ ๋ณ„๋„ ์ €์žฅ + +CREATE TABLE IF NOT EXISTS pipe_end_preparations ( + id SERIAL PRIMARY KEY, + material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + + -- ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด + end_preparation_type VARCHAR(50) DEFAULT 'PBE', -- PBE(์–‘์ชฝ๋ฌด๊ฐœ์„ ), BBE(์–‘์ชฝ๊ฐœ์„ ), POE(ํ•œ์ชฝ๊ฐœ์„ ), PE(๋ฌด๊ฐœ์„ ) + end_preparation_code VARCHAR(20), -- ์›๋ณธ ์ฝ”๋“œ (BBE, POE, PBE ๋“ฑ) + machining_required BOOLEAN DEFAULT FALSE, -- ๊ฐ€๊ณต ํ•„์š” ์—ฌ๋ถ€ + cutting_note TEXT, -- ๊ฐ€๊ณต ๋ฉ”๋ชจ + + -- ์›๋ณธ ์ •๋ณด ๋ณด์กด + original_description TEXT NOT NULL, -- ๋๋‹จ ๊ฐ€๊ณต ํฌํ•จ๋œ ์›๋ณธ ์„ค๋ช… + clean_description TEXT NOT NULL, -- ๋๋‹จ ๊ฐ€๊ณต ์ œ์™ธํ•œ ๊ตฌ๋งค์šฉ ์„ค๋ช… + + -- ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + confidence FLOAT DEFAULT 0.0, -- ๋ถ„๋ฅ˜ ์‹ ๋ขฐ๋„ + matched_pattern VARCHAR(100), -- ๋งค์นญ๋œ ํŒจํ„ด + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_material_id ON pipe_end_preparations(material_id); +CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_file_id ON pipe_end_preparations(file_id); +CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_type ON pipe_end_preparations(end_preparation_type); + +-- ๊ธฐ๋ณธ ๋๋‹จ ๊ฐ€๊ณต ํƒ€์ž… ์ •์˜ +COMMENT ON COLUMN pipe_end_preparations.end_preparation_type IS 'PBE: ์–‘์ชฝ๋ฌด๊ฐœ์„ (๊ธฐ๋ณธ๊ฐ’), BBE: ์–‘์ชฝ๊ฐœ์„ , POE: ํ•œ์ชฝ๊ฐœ์„ , PE: ๋ฌด๊ฐœ์„ '; +COMMENT ON COLUMN pipe_end_preparations.machining_required IS '๊ฐ€๊ณต์ด ํ•„์š”ํ•œ์ง€ ์—ฌ๋ถ€ (๊ฐœ์„  ์ž‘์—… ๋“ฑ)'; +COMMENT ON COLUMN pipe_end_preparations.clean_description IS '๊ตฌ๋งค ์‹œ ์‚ฌ์šฉํ•  ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด๊ฐ€ ์ œ๊ฑฐ๋œ ์„ค๋ช…'; + +-- ํŠธ๋ฆฌ๊ฑฐ: updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_pipe_end_preparations_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_pipe_end_preparations_updated_at + BEFORE UPDATE ON pipe_end_preparations + FOR EACH ROW + EXECUTE FUNCTION update_pipe_end_preparations_updated_at(); diff --git a/backend/scripts/insert_dummy_jobs.py b/backend/scripts/insert_dummy_jobs.py deleted file mode 100644 index 72a8e4f..0000000 --- a/backend/scripts/insert_dummy_jobs.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -import sys -import os -from datetime import datetime, date - -# ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ Python path์— ์ถ”๊ฐ€ -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -try: - from app.database import engine - from sqlalchemy import text - print("โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ฑ๊ณต") -except ImportError as e: - print(f"โŒ ์ž„ํฌํŠธ ์‹คํŒจ: {e}") - sys.exit(1) - -def insert_dummy_data(): - dummy_jobs = [ - { - 'job_no': 'J24-001', - 'job_name': '์šธ์‚ฐ SK์—๋„ˆ์ง€ ์ •์œ ์‹œ์„ค ์ฆ์„ค ๋ฐฐ๊ด€๊ณต์‚ฌ', - 'client_name': '์‚ผ์„ฑ์—”์ง€๋‹ˆ์–ด๋ง', - 'end_user': 'SK์—๋„ˆ์ง€', - 'epc_company': '์‚ผ์„ฑ์—”์ง€๋‹ˆ์–ด๋ง', - 'project_site': '์šธ์‚ฐ๊ด‘์—ญ์‹œ ์˜จ์‚ฐ๊ณต๋‹จ', - 'contract_date': '2024-03-15', - 'delivery_date': '2024-08-30', - 'delivery_terms': 'FOB ์šธ์‚ฐํ•ญ', - 'description': '์ •์œ ์‹œ์„ค ์ฆ์„ค์„ ์œ„ํ•œ ๋ฐฐ๊ด€ ์ž์žฌ ๊ณต๊ธ‰', - 'created_by': 'admin' - }, - { - 'job_no': 'J24-002', - 'job_name': 'ํฌ์Šค์ฝ” ๊ด‘์–‘ ์ œ์ฒ ์†Œ ๋ฐฐ๊ด€ ์ •๋น„๊ณต์‚ฌ', - 'client_name': 'ํฌ์Šค์ฝ”', - 'end_user': 'ํฌ์Šค์ฝ”', - 'epc_company': None, - 'project_site': '์ „๋‚จ ๊ด‘์–‘์‹œ ํฌ์Šค์ฝ” ์ œ์ฒ ์†Œ', - 'contract_date': '2024-04-02', - 'delivery_date': '2024-07-15', - 'delivery_terms': 'DDP ๊ด‘์–‘์ œ์ฒ ์†Œ', - 'description': '์ œ์ฒ ์†Œ ์ •๊ธฐ ์ •๋น„์šฉ ๋ฐฐ๊ด€ ๋ถ€ํ’ˆ', - 'created_by': 'admin' - } - ] - - try: - with engine.connect() as conn: - # ๊ธฐ์กด ๋”๋ฏธ ๋ฐ์ดํ„ฐ ์‚ญ์ œ - conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')")) - - # ์ƒˆ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… - for job in dummy_jobs: - query = text(""" - INSERT INTO jobs ( - job_no, job_name, client_name, end_user, epc_company, - project_site, contract_date, delivery_date, delivery_terms, - description, created_by, is_active - ) VALUES ( - :job_no, :job_name, :client_name, :end_user, :epc_company, - :project_site, :contract_date, :delivery_date, :delivery_terms, - :description, :created_by, :is_active - ) - """) - - conn.execute(query, {**job, 'is_active': True}) - print(f"โœ… {job['job_no']}: {job['job_name']}") - - conn.commit() - print(f"\n๐ŸŽ‰ {len(dummy_jobs)}๊ฐœ ๋”๋ฏธ Job ์ƒ์„ฑ ์™„๋ฃŒ!") - - # ํ™•์ธ - result = conn.execute(text("SELECT job_no, job_name, client_name FROM jobs")) - for row in result: - print(f" โ€ข {row[0]}: {row[1]} ({row[2]})") - - except Exception as e: - print(f"โŒ ์˜ค๋ฅ˜: {e}") - -if __name__ == "__main__": - insert_dummy_data() diff --git a/backend/temp_main_update.py b/backend/temp_main_update.py deleted file mode 100644 index aa69547..0000000 --- a/backend/temp_main_update.py +++ /dev/null @@ -1,5 +0,0 @@ -# main.py์— ์ถ”๊ฐ€ํ•  import -from .api import spools - -# app.include_router ์ถ”๊ฐ€ -app.include_router(spools.router, prefix="/api/spools", tags=["์Šคํ’€ ๊ด€๋ฆฌ"]) diff --git a/backend/temp_new_upload.py b/backend/temp_new_upload.py deleted file mode 100644 index 966a99d..0000000 --- a/backend/temp_new_upload.py +++ /dev/null @@ -1,120 +0,0 @@ -@router.post("/upload") -async def upload_file( - file: UploadFile = File(...), - job_no: str = Form(...), - revision: str = Form("Rev.0"), - db: Session = Depends(get_db) -): - # 1. Job ๊ฒ€์ฆ (์ƒˆ๋กœ ์ถ”๊ฐ€!) - job_validation = await validate_job_exists(job_no, db) - if not job_validation["valid"]: - raise HTTPException( - status_code=400, - detail=f"Job ์˜ค๋ฅ˜: {job_validation['error']}" - ) - - job_info = job_validation["job"] - print(f"โœ… Job ๊ฒ€์ฆ ์™„๋ฃŒ: {job_info['job_no']} - {job_info['job_name']}") - - # 2. ํŒŒ์ผ ๊ฒ€์ฆ - if not validate_file_extension(file.filename): - raise HTTPException( - status_code=400, - detail=f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค. ํ—ˆ์šฉ๋œ ํ™•์žฅ์ž: {', '.join(ALLOWED_EXTENSIONS)}" - ) - - if file.size and file.size > 10 * 1024 * 1024: - raise HTTPException(status_code=400, detail="ํŒŒ์ผ ํฌ๊ธฐ๋Š” 10MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") - - # 3. ํŒŒ์ผ ์ €์žฅ - unique_filename = generate_unique_filename(file.filename) - file_path = UPLOAD_DIR / unique_filename - - try: - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - except Exception as e: - raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ: {str(e)}") - - # 4. ํŒŒ์ผ ํŒŒ์‹ฑ ๋ฐ ์ž์žฌ ์ถ”์ถœ - try: - materials_data = parse_file_data(str(file_path)) - parsed_count = len(materials_data) - - # ํŒŒ์ผ ์ •๋ณด ์ €์žฅ (job_no ์‚ฌ์šฉ!) - file_insert_query = text(""" - INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active) - VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active) - RETURNING id - """) - - file_result = db.execute(file_insert_query, { - "filename": unique_filename, - "original_filename": file.filename, - "file_path": str(file_path), - "job_no": job_no, # job_no ์‚ฌ์šฉ! - "revision": revision, - "description": f"BOM ํŒŒ์ผ - {parsed_count}๊ฐœ ์ž์žฌ ({job_info['job_name']})", - "file_size": file.size, - "parsed_count": parsed_count, - "is_active": True - }) - - file_id = file_result.fetchone()[0] - - # ์ž์žฌ ๋ฐ์ดํ„ฐ ์ €์žฅ - materials_inserted = 0 - for material_data in materials_data: - material_insert_query = text(""" - INSERT INTO materials ( - file_id, original_description, quantity, unit, size_spec, - material_grade, line_number, row_number, classified_category, - classification_confidence, is_verified, created_at - ) - VALUES ( - :file_id, :original_description, :quantity, :unit, :size_spec, - :material_grade, :line_number, :row_number, :classified_category, - :classification_confidence, :is_verified, :created_at - ) - """) - - db.execute(material_insert_query, { - "file_id": file_id, - "original_description": material_data["original_description"], - "quantity": material_data["quantity"], - "unit": material_data["unit"], - "size_spec": material_data["size_spec"], - "material_grade": material_data["material_grade"], - "line_number": material_data["line_number"], - "row_number": material_data["row_number"], - "classified_category": None, - "classification_confidence": None, - "is_verified": False, - "created_at": datetime.now() - }) - materials_inserted += 1 - - db.commit() - - return { - "success": True, - "message": f"Job '{job_info['job_name']}'์— BOM ํŒŒ์ผ ์—…๋กœ๋“œ ์™„๋ฃŒ!", - "job": { - "job_no": job_info["job_no"], - "job_name": job_info["job_name"], - "status": job_info["status"] - }, - "file": { - "id": file_id, - "original_filename": file.filename, - "parsed_count": parsed_count, - "saved_count": materials_inserted - }, - "sample_materials": materials_data[:3] if materials_data else [] - } - - except Exception as e: - db.rollback() - if os.path.exists(file_path): - os.remove(file_path) - raise HTTPException(status_code=500, detail=f"ํŒŒ์ผ ์ฒ˜๋ฆฌ ์‹คํŒจ: {str(e)}") diff --git a/backend/temp_upload_fix.py b/backend/temp_upload_fix.py deleted file mode 100644 index 5bbf33b..0000000 --- a/backend/temp_upload_fix.py +++ /dev/null @@ -1,13 +0,0 @@ -# upload ํ•จ์ˆ˜์— ์ถ”๊ฐ€ํ•  Job ๊ฒ€์ฆ ๋กœ์ง - -# Form ํŒŒ๋ผ๋ฏธํ„ฐ ๋ฐ›์€ ์งํ›„์— ์ถ”๊ฐ€: -# Job ๊ฒ€์ฆ -job_validation = await validate_job_exists(job_no, db) -if not job_validation["valid"]: - raise HTTPException( - status_code=400, - detail=f"Job ์˜ค๋ฅ˜: {job_validation['error']}" - ) - -job_info = job_validation["job"] -print(f"โœ… Job ๊ฒ€์ฆ ์™„๋ฃŒ: {job_info['job_no']} - {job_info['job_name']}") diff --git a/backend/test_bom.csv b/backend/test_bom.csv deleted file mode 100644 index 15b983f..0000000 --- a/backend/test_bom.csv +++ /dev/null @@ -1,5 +0,0 @@ -Description,Quantity,Unit,Size -"PIPE ASTM A106 GR.B",10,EA,4" -"ELBOW 90ยฐ ASTM A234",5,EA,4" -"VALVE GATE ASTM A216",2,EA,4" -"FLANGE WELD NECK",8,EA,4" diff --git a/backend/test_main_red_nom.py b/backend/test_main_red_nom.py deleted file mode 100644 index 126826c..0000000 --- a/backend/test_main_red_nom.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -""" -main_nom, red_nom ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ์Šคํฌ๋ฆฝํŠธ -""" - -import sys -import os -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from app.services.fitting_classifier import classify_fitting -from app.services.flange_classifier import classify_flange - -def test_main_red_nom(): - """main_nom๊ณผ red_nom ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ""" - - print("๐Ÿ”ง main_nom/red_nom ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ ์‹œ์ž‘!") - print("=" * 60) - - test_cases = [ - { - "name": "์ผ๋ฐ˜ TEE (๋™์ผ ์‚ฌ์ด์ฆˆ)", - "description": "TEE, SCH 40, ASTM A234 GR WPB", - "main_nom": "4\"", - "red_nom": None, - "expected": "EQUAL TEE" - }, - { - "name": "๋ฆฌ๋“€์‹ฑ TEE (๋‹ค๋ฅธ ์‚ฌ์ด์ฆˆ)", - "description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB", - "main_nom": "4\"", - "red_nom": "2\"", - "expected": "REDUCING TEE" - }, - { - "name": "๋™์‹ฌ ๋ฆฌ๋“€์„œ", - "description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB", - "main_nom": "6\"", - "red_nom": "4\"", - "expected": "CONCENTRIC REDUCER" - }, - { - "name": "๋ฆฌ๋“€์‹ฑ ํ”Œ๋žœ์ง€", - "description": "FLG REDUCING, 300LB, ASTM A105", - "main_nom": "6\"", - "red_nom": "4\"", - "expected": "REDUCING FLANGE" - } - ] - - for i, test in enumerate(test_cases, 1): - print(f"\n{i}. {test['name']}") - print(f" ์„ค๋ช…: {test['description']}") - print(f" MAIN_NOM: {test['main_nom']}") - print(f" RED_NOM: {test['red_nom']}") - - # ํ”ผํŒ… ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ - fitting_result = classify_fitting( - "", - test['description'], - test['main_nom'], - test['red_nom'] - ) - - print(f" ๐Ÿ”ง FITTING ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ:") - print(f" ์นดํ…Œ๊ณ ๋ฆฌ: {fitting_result.get('category')}") - print(f" ํƒ€์ž…: {fitting_result.get('fitting_type', {}).get('type')}") - print(f" ์„œ๋ธŒํƒ€์ž…: {fitting_result.get('fitting_type', {}).get('subtype')}") - print(f" ์‹ ๋ขฐ๋„: {fitting_result.get('overall_confidence', 0):.2f}") - - # ์‚ฌ์ด์ฆˆ ์ •๋ณด ํ™•์ธ - size_info = fitting_result.get('size_info', {}) - print(f" ๋ฉ”์ธ ์‚ฌ์ด์ฆˆ: {size_info.get('main_size')}") - print(f" ์ถ•์†Œ ์‚ฌ์ด์ฆˆ: {size_info.get('reduced_size')}") - print(f" ์‚ฌ์ด์ฆˆ ์„ค๋ช…: {size_info.get('size_description')}") - - # RED_NOM์ด ์žˆ๋Š” ๊ฒฝ์šฐ REDUCING ๋ถ„๋ฅ˜ ํ™•์ธ - if test['red_nom']: - fitting_type = fitting_result.get('fitting_type', {}) - if 'REDUCING' in fitting_type.get('subtype', '').upper(): - print(f" โœ… REDUCING ํƒ€์ž… ์ •์ƒ ์ธ์‹!") - else: - print(f" โŒ REDUCING ํƒ€์ž… ์ธ์‹ ์‹คํŒจ") - - print("-" * 50) - - print("\n๐ŸŽฏ ํ…Œ์ŠคํŠธ ์™„๋ฃŒ!") - -if __name__ == "__main__": - test_main_red_nom() \ No newline at end of file diff --git a/backend/test_mixed_bom.csv b/backend/test_mixed_bom.csv deleted file mode 100644 index ccab0b0..0000000 --- a/backend/test_mixed_bom.csv +++ /dev/null @@ -1,6 +0,0 @@ -Description,Quantity,Unit,Size -"PIPE ASTM A106 GR.B",10,EA,4" -"GATE VALVE ASTM A216",2,EA,4" -"FLANGE WELD NECK RF",8,EA,4" -"90 DEG ELBOW",5,EA,4" -"GASKET SPIRAL WOUND",4,EA,4" diff --git a/backend/test_sample.csv b/backend/test_sample.csv deleted file mode 100644 index 76278ff..0000000 --- a/backend/test_sample.csv +++ /dev/null @@ -1,6 +0,0 @@ -description,qty,main_nom,red_nom,length -"TEE EQUAL, SCH 40, ASTM A234 GR WPB",2,4",," -"TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",1,4",2"," -"RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",1,6",4"," -"90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS",4,3",," -"PIPE SMLS, SCH 40, ASTM A106 GR B",1,2",,6000 \ No newline at end of file diff --git a/database/init/20_purchase_confirmations.sql b/database/init/20_purchase_confirmations.sql new file mode 100644 index 0000000..1084721 --- /dev/null +++ b/database/init/20_purchase_confirmations.sql @@ -0,0 +1,72 @@ +-- ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • ๊ด€๋ จ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + +-- 1. ๊ตฌ๋งค ํ™•์ • ๋งˆ์Šคํ„ฐ ํ…Œ์ด๋ธ” +CREATE TABLE IF NOT EXISTS purchase_confirmations ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + file_id INTEGER REFERENCES files(id), + bom_name VARCHAR(255) NOT NULL, + revision VARCHAR(50) NOT NULL DEFAULT 'Rev.0', + confirmed_at TIMESTAMP NOT NULL, + confirmed_by VARCHAR(100) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. ํ™•์ •๋œ ๊ตฌ๋งค ํ’ˆ๋ชฉ ํ…Œ์ด๋ธ” +CREATE TABLE IF NOT EXISTS confirmed_purchase_items ( + id SERIAL PRIMARY KEY, + confirmation_id INTEGER REFERENCES purchase_confirmations(id) ON DELETE CASCADE, + item_code VARCHAR(100) NOT NULL, + category VARCHAR(50) NOT NULL, + specification TEXT, + size VARCHAR(100), + material VARCHAR(100), + bom_quantity DECIMAL(15,3) NOT NULL DEFAULT 0, + calculated_qty DECIMAL(15,3) NOT NULL DEFAULT 0, + unit VARCHAR(20) NOT NULL DEFAULT 'EA', + safety_factor DECIMAL(5,3) NOT NULL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. files ํ…Œ์ด๋ธ”์— ํ™•์ • ๊ด€๋ จ ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (์ด๋ฏธ ์žˆ์œผ๋ฉด ๋ฌด์‹œ) +ALTER TABLE files +ADD COLUMN IF NOT EXISTS purchase_confirmed BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS confirmed_by VARCHAR(100); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_purchase_confirmations_job_revision +ON purchase_confirmations(job_no, revision, is_active); + +CREATE INDEX IF NOT EXISTS idx_confirmed_purchase_items_confirmation +ON confirmed_purchase_items(confirmation_id); + +CREATE INDEX IF NOT EXISTS idx_confirmed_purchase_items_category +ON confirmed_purchase_items(category); + +CREATE INDEX IF NOT EXISTS idx_files_purchase_confirmed +ON files(purchase_confirmed); + +-- ์ฝ”๋ฉ˜ํŠธ ์ถ”๊ฐ€ +COMMENT ON TABLE purchase_confirmations IS '๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • ๋งˆ์Šคํ„ฐ ํ…Œ์ด๋ธ”'; +COMMENT ON TABLE confirmed_purchase_items IS 'ํ™•์ •๋œ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ธ ํ…Œ์ด๋ธ”'; +COMMENT ON COLUMN files.purchase_confirmed IS '๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • ์—ฌ๋ถ€'; +COMMENT ON COLUMN files.confirmed_at IS '๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ • ์‹œ๊ฐ„'; +COMMENT ON COLUMN files.confirmed_by IS '๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ํ™•์ •์ž'; + + + + + + + + + + + + + + + diff --git a/docker-backup/docker-compose.override.yml b/docker-backup/docker-compose.override.yml new file mode 100644 index 0000000..2915979 --- /dev/null +++ b/docker-backup/docker-compose.override.yml @@ -0,0 +1,33 @@ +# ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ ์˜ค๋ฒ„๋ผ์ด๋“œ (๊ธฐ๋ณธ๊ฐ’) +# docker-compose up ์‹œ ์ž๋™์œผ๋กœ ์ ์šฉ๋จ +# version: '3.8' # Docker Compose v2์—์„œ๋Š” version ํ•„๋“œ๊ฐ€ ์„ ํƒ์‚ฌํ•ญ + +services: + backend: + volumes: + # ๊ฐœ๋ฐœ ์‹œ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ + - ./backend:/app + environment: + - DEBUG=true + - RELOAD=true + - LOG_LEVEL=DEBUG + + frontend: + environment: + - VITE_API_URL=http://localhost:18000 + build: + args: + - VITE_API_URL=http://localhost:18000 + + # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ๋ชจ๋“  ํฌํŠธ๋ฅผ ์™ธ๋ถ€์— ๋…ธ์ถœ + postgres: + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + pgadmin: + ports: + - "5050:80" diff --git a/docker-backup/docker-compose.prod.yml b/docker-backup/docker-compose.prod.yml new file mode 100644 index 0000000..ff1d820 --- /dev/null +++ b/docker-backup/docker-compose.prod.yml @@ -0,0 +1,55 @@ +# ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์šฉ ์˜ค๋ฒ„๋ผ์ด๋“œ +version: '3.8' + +services: + backend: + environment: + - ENVIRONMENT=production + - DEBUG=false + - RELOAD=false + - LOG_LEVEL=INFO + # ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์ฝ”๋“œ ๋ณผ๋ฅจ ๋งˆ์šดํŠธ ์ œ๊ฑฐ + volumes: + - ./backend/uploads:/app/uploads + + frontend: + environment: + - VITE_API_URL=/api + build: + args: + - VITE_API_URL=/api + + # ํ”„๋กœ๋•์…˜์šฉ ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ + nginx: + image: nginx:alpine + container_name: tk-mp-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - frontend + - backend + networks: + - tk-mp-network + + # ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์™ธ๋ถ€ ํฌํŠธ ์ ‘๊ทผ ์ฐจ๋‹จ + postgres: + ports: [] + + redis: + ports: [] + + backend: + ports: [] + + frontend: + ports: [] + + # pgAdmin์€ ํ”„๋กœ๋•์…˜์—์„œ ๋น„ํ™œ์„ฑํ™” + pgadmin: + profiles: + - disabled \ No newline at end of file diff --git a/docker-backup/docker-compose.synology.yml b/docker-backup/docker-compose.synology.yml new file mode 100644 index 0000000..9be78f3 --- /dev/null +++ b/docker-backup/docker-compose.synology.yml @@ -0,0 +1,57 @@ +# ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ์šฉ ์˜ค๋ฒ„๋ผ์ด๋“œ +version: '3.8' + +services: + postgres: + container_name: tk-mp-postgres-synology + ports: + - "15432:5432" + volumes: + - tk_mp_postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + + redis: + container_name: tk-mp-redis-synology + ports: + - "16379:6379" + volumes: + - tk_mp_redis_data:/data + + backend: + container_name: tk-mp-backend-synology + ports: + - "10080:8000" + environment: + - ENVIRONMENT=synology + - DEBUG=false + - RELOAD=false + - LOG_LEVEL=INFO + - DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom} + - REDIS_URL=redis://redis:6379 + volumes: + - tk_mp_uploads:/app/uploads + + frontend: + container_name: tk-mp-frontend-synology + ports: + - "10173:3000" + environment: + - VITE_API_URL=http://localhost:10080 + build: + args: + - VITE_API_URL=http://localhost:10080 + + # ์‹œ๋†€๋กœ์ง€์—์„œ๋Š” pgAdmin ํฌํŠธ ๋ณ€๊ฒฝ + pgadmin: + container_name: tk-mp-pgadmin-synology + ports: + - "15050:80" + +# ์‹œ๋†€๋กœ์ง€์šฉ ๋ช…๋ช…๋œ ๋ณผ๋ฅจ +volumes: + tk_mp_postgres_data: + external: false + tk_mp_redis_data: + external: false + tk_mp_uploads: + external: false \ No newline at end of file diff --git a/docker-backup/docker-compose.yml b/docker-backup/docker-compose.yml new file mode 100644 index 0000000..64da84a --- /dev/null +++ b/docker-backup/docker-compose.yml @@ -0,0 +1,124 @@ +# TK-MP-Project Docker Compose ์„ค์ • +# ๊ธฐ๋ณธ ์„ค์ • (๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๊ธฐ์ค€) +# version: '3.8' # Docker Compose v2์—์„œ๋Š” version ํ•„๋“œ๊ฐ€ ์„ ํƒ์‚ฌํ•ญ + +services: + # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค + postgres: + image: postgres:15-alpine + container_name: tk-mp-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-tk_mp_bom} + POSTGRES_USER: ${POSTGRES_USER:-tkmp_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tkmp_password_2025} + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - tk-mp-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tkmp_user} -d ${POSTGRES_DB:-tk_mp_bom}"] + interval: 30s + timeout: 10s + retries: 3 + + # Redis (์บ์‹œ ๋ฐ ์„ธ์…˜ ๊ด€๋ฆฌ์šฉ) + redis: + image: redis:7-alpine + container_name: tk-mp-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + networks: + - tk-mp-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # ๋ฐฑ์—”๋“œ FastAPI ์„œ๋น„์Šค + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: tk-mp-backend + restart: unless-stopped + ports: + - "${BACKEND_PORT:-18000}:8000" + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom} + - REDIS_URL=redis://redis:6379 + - ENVIRONMENT=${ENVIRONMENT:-development} + - DEBUG=${DEBUG:-true} + - PYTHONPATH=/app + depends_on: + - postgres + - redis + networks: + - tk-mp-network + volumes: + - ./backend/uploads:/app/uploads + # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ (์˜ค๋ฒ„๋ผ์ด๋“œ์—์„œ ์„ค์ •) + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + # interval: 30s + # timeout: 10s + # retries: 3 + + # ํ”„๋ก ํŠธ์—”๋“œ React + Nginx ์„œ๋น„์Šค + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} + container_name: tk-mp-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-13000}:3000" + environment: + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} + depends_on: + - backend + networks: + - tk-mp-network + + # pgAdmin ์›น ๊ด€๋ฆฌ๋„๊ตฌ (๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์šฉ) + pgadmin: + image: dpage/pgadmin4:latest + container_name: tk-mp-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin2025} + PGADMIN_CONFIG_SERVER_MODE: 'False' + ports: + - "${PGADMIN_PORT:-5050}:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + - postgres + networks: + - tk-mp-network + profiles: + - dev + - test + +volumes: + postgres_data: + driver: local + pgadmin_data: + driver: local + redis_data: + driver: local + +networks: + tk-mp-network: + driver: bridge \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index d641d91..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: '3.8' - -# ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ ์˜ค๋ฒ„๋ผ์ด๋“œ -services: - frontend: - environment: - - VITE_API_URL=http://localhost:18000 - build: - args: - - VITE_API_URL=http://localhost:18000 - - backend: - volumes: - - ./backend:/app # ๊ฐœ๋ฐœ ์‹œ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ - environment: - - DEBUG=True - - RELOAD=True - - # ๊ฐœ๋ฐœ์šฉ ํฌํŠธ ๋งคํ•‘ - postgres: - ports: - - "5432:5432" - - redis: - ports: - - "6379:6379" \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..af35268 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,34 @@ +# ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ ์˜ค๋ฒ„๋ผ์ด๋“œ (๊ธฐ๋ณธ๊ฐ’) +# docker-compose up ์‹œ ์ž๋™์œผ๋กœ ์ ์šฉ๋จ +# version: '3.8' # Docker Compose v2์—์„œ๋Š” version ํ•„๋“œ๊ฐ€ ์„ ํƒ์‚ฌํ•ญ + +services: + backend: + volumes: + # ๊ฐœ๋ฐœ ์‹œ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ + - ./backend:/app + environment: + - DEBUG=true + - RELOAD=true + - LOG_LEVEL=DEBUG + + frontend: + environment: + - VITE_API_URL=http://localhost:18000 + build: + args: + - VITE_API_URL=http://localhost:18000 + + # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ๋ชจ๋“  ํฌํŠธ๋ฅผ ์™ธ๋ถ€์— ๋…ธ์ถœ + postgres: + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + pgadmin: + ports: + - "5050:80" + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 60b3557..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,46 +0,0 @@ -version: '3.8' - -# ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์šฉ ์˜ค๋ฒ„๋ผ์ด๋“œ -services: - frontend: - environment: - - VITE_API_URL=/api - build: - args: - - VITE_API_URL=/api - # ํฌํŠธ๋ฅผ ์™ธ๋ถ€์— ๋…ธ์ถœํ•˜์ง€ ์•Š์Œ (๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ ์‚ฌ์šฉ) - ports: [] - - backend: - environment: - - DEBUG=False - - RELOAD=False - # ํฌํŠธ๋ฅผ ์™ธ๋ถ€์— ๋…ธ์ถœํ•˜์ง€ ์•Š์Œ - ports: [] - - # ํ”„๋กœ๋•์…˜์šฉ ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ (์˜ˆ: Nginx) - nginx: - image: nginx:alpine - container_name: tk-mp-nginx - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf - - ./nginx/ssl:/etc/nginx/ssl # SSL ์ธ์ฆ์„œ - depends_on: - - frontend - - backend - networks: - - tk-mp-network - - # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ ‘๊ทผ ์ œํ•œ - postgres: - ports: [] # ์™ธ๋ถ€ ์ ‘๊ทผ ์ฐจ๋‹จ - - redis: - ports: [] # ์™ธ๋ถ€ ์ ‘๊ทผ ์ฐจ๋‹จ - - pgadmin: - ports: [] # ์™ธ๋ถ€ ์ ‘๊ทผ ์ฐจ๋‹จ (ํ•„์š”์‹œ SSH ํ„ฐ๋„๋ง) \ No newline at end of file diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml deleted file mode 100644 index d4b6921..0000000 --- a/docker-compose.synology.yml +++ /dev/null @@ -1,76 +0,0 @@ -version: '3.8' - -services: - # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค - tk-mp-postgres: - image: postgres:15-alpine - container_name: tk-mp-postgres - restart: unless-stopped - environment: - POSTGRES_DB: tk_mp_bom - POSTGRES_USER: tkmp_user - POSTGRES_PASSWORD: tkmp_password_2025 - POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" - ports: - - "15432:5432" - volumes: - - tk_mp_postgres_data:/var/lib/postgresql/data - - ./database/init:/docker-entrypoint-initdb.d - networks: - - tk-mp-network - - # Redis (์บ์‹œ ๋ฐ ์„ธ์…˜ ๊ด€๋ฆฌ์šฉ) - tk-mp-redis: - image: redis:7-alpine - container_name: tk-mp-redis - restart: unless-stopped - ports: - - "16379:6379" - volumes: - - tk_mp_redis_data:/data - networks: - - tk-mp-network - - # ๋ฐฑ์—”๋“œ FastAPI ์„œ๋น„์Šค - tk-mp-backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: tk-mp-backend - restart: unless-stopped - ports: - - "10080:10080" - environment: - - DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@tk-mp-postgres:5432/tk_mp_bom - - REDIS_URL=redis://tk-mp-redis:6379 - - PYTHONPATH=/app - depends_on: - - tk-mp-postgres - - tk-mp-redis - networks: - - tk-mp-network - volumes: - - tk_mp_uploads:/app/uploads - - # ํ”„๋ก ํŠธ์—”๋“œ Nginx ์„œ๋น„์Šค - tk-mp-frontend: - build: - context: ./frontend - dockerfile: Dockerfile - container_name: tk-mp-frontend - restart: unless-stopped - ports: - - "10173:10173" - depends_on: - - tk-mp-backend - networks: - - tk-mp-network - -volumes: - tk_mp_postgres_data: - tk_mp_redis_data: - tk_mp_uploads: - -networks: - tk-mp-network: - driver: bridge \ No newline at end of file diff --git a/docker-compose.unified.yml b/docker-compose.unified.yml new file mode 100644 index 0000000..c9004c9 --- /dev/null +++ b/docker-compose.unified.yml @@ -0,0 +1,160 @@ +# TK-MP-Project ํ†ตํ•ฉ Docker Compose ์„ค์ • +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ DEPLOY_ENV๋กœ ํ™˜๊ฒฝ ๊ตฌ๋ถ„: development(๊ธฐ๋ณธ), production, synology + +services: + # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค + postgres: + image: postgres:15-alpine + container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-postgres${CONTAINER_SUFFIX:-} + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-tk_mp_bom} + POSTGRES_USER: ${POSTGRES_USER:-tkmp_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tkmp_password_2025} + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + ports: + # ๊ฐœ๋ฐœ: 5432, ํ”„๋กœ๋•์…˜: ์—†์Œ, ์‹œ๋†€๋กœ์ง€: 15432 + - "${POSTGRES_EXTERNAL_PORT:-5432}:5432" + volumes: + - ${POSTGRES_DATA_VOLUME:-postgres_data}:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - tk-mp-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tkmp_user} -d ${POSTGRES_DB:-tk_mp_bom}"] + interval: 30s + timeout: 10s + retries: 3 + profiles: + - ${POSTGRES_PROFILE:-default} + + # Redis (์บ์‹œ ๋ฐ ์„ธ์…˜ ๊ด€๋ฆฌ์šฉ) + redis: + image: redis:7-alpine + container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-redis${CONTAINER_SUFFIX:-} + restart: unless-stopped + ports: + # ๊ฐœ๋ฐœ: 6379, ํ”„๋กœ๋•์…˜: ์—†์Œ, ์‹œ๋†€๋กœ์ง€: 16379 + - "${REDIS_EXTERNAL_PORT:-6379}:6379" + volumes: + - ${REDIS_DATA_VOLUME:-redis_data}:/data + networks: + - tk-mp-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + profiles: + - ${REDIS_PROFILE:-default} + + # ๋ฐฑ์—”๋“œ FastAPI ์„œ๋น„์Šค + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-backend${CONTAINER_SUFFIX:-} + restart: unless-stopped + ports: + # ๊ฐœ๋ฐœ: 18000, ํ”„๋กœ๋•์…˜: ์—†์Œ, ์‹œ๋†€๋กœ์ง€: 10080 + - "${BACKEND_EXTERNAL_PORT:-18000}:8000" + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom} + - REDIS_URL=redis://redis:6379 + - ENVIRONMENT=${DEPLOY_ENV:-development} + - DEBUG=${DEBUG:-true} + - RELOAD=${RELOAD:-true} + - LOG_LEVEL=${LOG_LEVEL:-DEBUG} + - PYTHONPATH=/app + depends_on: + - postgres + - redis + networks: + - tk-mp-network + volumes: + # ๊ฐœ๋ฐœ: ์ฝ”๋“œ ๋งˆ์šดํŠธ, ํ”„๋กœ๋•์…˜/์‹œ๋†€๋กœ์ง€: ์—…๋กœ๋“œ๋งŒ + - ${BACKEND_CODE_VOLUME:-./backend}:/app + - ${UPLOADS_VOLUME:-./backend/uploads}:/app/uploads + profiles: + - ${BACKEND_PROFILE:-default} + + # ํ”„๋ก ํŠธ์—”๋“œ React + Nginx ์„œ๋น„์Šค + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} + container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-frontend${CONTAINER_SUFFIX:-} + restart: unless-stopped + ports: + # ๊ฐœ๋ฐœ: 13000, ํ”„๋กœ๋•์…˜: ์—†์Œ, ์‹œ๋†€๋กœ์ง€: 10173 + - "${FRONTEND_EXTERNAL_PORT:-13000}:3000" + environment: + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} + depends_on: + - backend + networks: + - tk-mp-network + profiles: + - ${FRONTEND_PROFILE:-default} + + # Nginx ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ (ํ”„๋กœ๋•์…˜ ์ „์šฉ) + nginx: + image: nginx:alpine + container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-nginx${CONTAINER_SUFFIX:-} + restart: unless-stopped + ports: + - "${NGINX_HTTP_PORT:-80}:80" + - "${NGINX_HTTPS_PORT:-443}:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - frontend + - backend + networks: + - tk-mp-network + profiles: + - production + + # pgAdmin ์›น ๊ด€๋ฆฌ๋„๊ตฌ + pgadmin: + image: dpage/pgadmin4:latest + container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-pgadmin${CONTAINER_SUFFIX:-} + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin2025} + PGADMIN_CONFIG_SERVER_MODE: 'False' + ports: + # ๊ฐœ๋ฐœ: 5050, ์‹œ๋†€๋กœ์ง€: 15050, ํ”„๋กœ๋•์…˜: ๋น„ํ™œ์„ฑํ™” + - "${PGADMIN_EXTERNAL_PORT:-5050}:80" + volumes: + - ${PGADMIN_DATA_VOLUME:-pgadmin_data}:/var/lib/pgadmin + depends_on: + - postgres + networks: + - tk-mp-network + profiles: + - ${PGADMIN_PROFILE:-dev} + +volumes: + postgres_data: + driver: local + pgadmin_data: + driver: local + redis_data: + driver: local + # ์‹œ๋†€๋กœ์ง€์šฉ ๋ช…๋ช…๋œ ๋ณผ๋ฅจ + tk_mp_postgres_data: + external: false + tk_mp_redis_data: + external: false + tk_mp_uploads: + external: false + +networks: + tk-mp-network: + driver: bridge + diff --git a/docker-compose.yml b/docker-compose.yml index 840c6e6..8cd88f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,6 @@ -version: '3.8' +# TK-MP-Project Docker Compose ์„ค์ • +# ๊ธฐ๋ณธ ์„ค์ • (๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๊ธฐ์ค€) +# version: '3.8' # Docker Compose v2์—์„œ๋Š” version ํ•„๋“œ๊ฐ€ ์„ ํƒ์‚ฌํ•ญ services: # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค @@ -7,35 +9,22 @@ services: container_name: tk-mp-postgres restart: unless-stopped environment: - POSTGRES_DB: tk_mp_bom - POSTGRES_USER: tkmp_user - POSTGRES_PASSWORD: tkmp_password_2025 + POSTGRES_DB: ${POSTGRES_DB:-tk_mp_bom} + POSTGRES_USER: ${POSTGRES_USER:-tkmp_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tkmp_password_2025} POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./database/init:/docker-entrypoint-initdb.d networks: - tk-mp-network - - # pgAdmin ์›น ๊ด€๋ฆฌ๋„๊ตฌ - pgadmin: - image: dpage/pgadmin4:latest - container_name: tk-mp-pgadmin - restart: unless-stopped - environment: - PGADMIN_DEFAULT_EMAIL: admin@example.com - PGADMIN_DEFAULT_PASSWORD: admin2025 - PGADMIN_CONFIG_SERVER_MODE: 'False' - ports: - - "5050:80" - volumes: - - pgadmin_data:/var/lib/pgadmin - depends_on: - - postgres - networks: - - tk-mp-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tkmp_user} -d ${POSTGRES_DB:-tk_mp_bom}"] + interval: 30s + timeout: 10s + retries: 3 # Redis (์บ์‹œ ๋ฐ ์„ธ์…˜ ๊ด€๋ฆฌ์šฉ) redis: @@ -43,11 +32,16 @@ services: container_name: tk-mp-redis restart: unless-stopped ports: - - "6379:6379" + - "${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data networks: - tk-mp-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 # ๋ฐฑ์—”๋“œ FastAPI ์„œ๋น„์Šค backend: @@ -57,10 +51,13 @@ services: container_name: tk-mp-backend restart: unless-stopped ports: - - "18000:8000" + - "${BACKEND_PORT:-18000}:8000" environment: - - DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom + - DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom} - REDIS_URL=redis://redis:6379 + - ENVIRONMENT=${ENVIRONMENT:-development} + - DEBUG=${DEBUG:-true} + - PYTHONPATH=/app depends_on: - postgres - redis @@ -68,25 +65,52 @@ services: - tk-mp-network volumes: - ./backend/uploads:/app/uploads + # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ (์˜ค๋ฒ„๋ผ์ด๋“œ์—์„œ ์„ค์ •) + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + # interval: 30s + # timeout: 10s + # retries: 3 - # ํ”„๋ก ํŠธ์—”๋“œ Nginx ์„œ๋น„์Šค + # ํ”„๋ก ํŠธ์—”๋“œ React + Nginx ์„œ๋น„์Šค frontend: build: context: ./frontend dockerfile: Dockerfile args: - - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} container_name: tk-mp-frontend restart: unless-stopped ports: - - "13000:3000" + - "${FRONTEND_PORT:-13000}:3000" environment: - - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} depends_on: - backend networks: - tk-mp-network + # pgAdmin ์›น ๊ด€๋ฆฌ๋„๊ตฌ (๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์šฉ) + pgadmin: + image: dpage/pgadmin4:latest + container_name: tk-mp-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com} + PGLADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin2025} + PGADMIN_CONFIG_SERVER_MODE: 'False' + ports: + - "${PGADMIN_PORT:-5050}:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + - postgres + networks: + - tk-mp-network + profiles: + - dev + - test + volumes: postgres_data: driver: local @@ -97,4 +121,4 @@ volumes: networks: tk-mp-network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/docker-run.sh b/docker-run.sh new file mode 100755 index 0000000..0e45534 --- /dev/null +++ b/docker-run.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# TK-MP-Project Docker ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ +# ์‚ฌ์šฉ๋ฒ•: ./docker-run.sh [ํ™˜๊ฒฝ] [๋ช…๋ น] +# ํ™˜๊ฒฝ: dev (๊ธฐ๋ณธ), prod, synology +# ๋ช…๋ น: up, down, build, logs, ps + +set -e + +# ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • +ENV=${1:-dev} +CMD=${2:-up} + +# ํ™˜๊ฒฝ๋ณ„ ์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ +case $ENV in + "dev"|"development") + ENV_FILE="env.development" + COMPOSE_FILE="docker-compose.yml" + echo "๐Ÿ”ง ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค..." + ;; + "prod"|"production") + ENV_FILE="env.production" + COMPOSE_FILE="docker-compose.yml" + echo "๐Ÿš€ ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค..." + ;; + "synology"|"nas") + ENV_FILE="env.synology" + COMPOSE_FILE="docker-compose.yml" + echo "๐Ÿ  ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค..." + ;; + *) + echo "โŒ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ™˜๊ฒฝ์ž…๋‹ˆ๋‹ค: $ENV" + echo "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ™˜๊ฒฝ: dev, prod, synology" + exit 1 + ;; +esac + +# ํ™˜๊ฒฝ ํŒŒ์ผ ์กด์žฌ ํ™•์ธ +if [ ! -f "$ENV_FILE" ]; then + echo "โŒ ํ™˜๊ฒฝ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: $ENV_FILE" + exit 1 +fi + +# Docker Compose ํŒŒ์ผ ์กด์žฌ ํ™•์ธ +if [ ! -f "$COMPOSE_FILE" ]; then + echo "โŒ Docker Compose ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: $COMPOSE_FILE" + exit 1 +fi + +echo "๐Ÿ“‹ ํ™˜๊ฒฝ ํŒŒ์ผ: $ENV_FILE" +echo "๐Ÿณ Compose ํŒŒ์ผ: $COMPOSE_FILE" + +# Docker Compose ๋ช…๋ น ์‹คํ–‰ +case $CMD in + "up") + echo "๐Ÿš€ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d + echo "โœ… ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + echo "" + echo "๐Ÿ“Š ์„œ๋น„์Šค ์ƒํƒœ:" + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps + ;; + "down") + echo "๐Ÿ›‘ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ค‘์ง€ํ•ฉ๋‹ˆ๋‹ค..." + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" down + echo "โœ… ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์ค‘์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + ;; + "build") + echo "๐Ÿ”จ ์ด๋ฏธ์ง€๋ฅผ ๋นŒ๋“œํ•ฉ๋‹ˆ๋‹ค..." + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build + echo "โœ… ์ด๋ฏธ์ง€ ๋นŒ๋“œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + ;; + "rebuild") + echo "๐Ÿ”จ ์ด๋ฏธ์ง€๋ฅผ ์žฌ๋นŒ๋“œํ•ฉ๋‹ˆ๋‹ค..." + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build --no-cache + echo "โœ… ์ด๋ฏธ์ง€ ์žฌ๋นŒ๋“œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + ;; + "logs") + echo "๐Ÿ“‹ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค..." + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" logs -f + ;; + "ps"|"status") + echo "๐Ÿ“Š ์„œ๋น„์Šค ์ƒํƒœ:" + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps + ;; + "restart") + echo "๐Ÿ”„ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." + docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" restart + echo "โœ… ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์žฌ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + ;; + *) + echo "โŒ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋ช…๋ น์ž…๋‹ˆ๋‹ค: $CMD" + echo "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ช…๋ น: up, down, build, rebuild, logs, ps, restart" + exit 1 + ;; +esac + +echo "" +echo "๐ŸŽฏ ์‚ฌ์šฉ๋ฒ• ์˜ˆ์‹œ:" +echo " ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์‹œ์ž‘: ./docker-run.sh dev up" +echo " ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์‹œ์ž‘: ./docker-run.sh prod up" +echo " ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ์‹œ์ž‘: ./docker-run.sh synology up" +echo " ๋กœ๊ทธ ํ™•์ธ: ./docker-run.sh dev logs" +echo " ์ƒํƒœ ํ™•์ธ: ./docker-run.sh dev ps" diff --git a/env.development b/env.development new file mode 100644 index 0000000..e793a03 --- /dev/null +++ b/env.development @@ -0,0 +1,43 @@ +# ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์„ค์ • +DEPLOY_ENV=development +COMPOSE_PROJECT_NAME=tk-mp-dev + +# ์ปจํ…Œ์ด๋„ˆ ์„ค์ • +CONTAINER_SUFFIX= +DEBUG=true +RELOAD=true +LOG_LEVEL=DEBUG + +# ํฌํŠธ ์„ค์ • (๊ฐœ๋ฐœ ํ™˜๊ฒฝ - ๋ชจ๋“  ํฌํŠธ ์™ธ๋ถ€ ๋…ธ์ถœ) +POSTGRES_EXTERNAL_PORT=5432 +REDIS_EXTERNAL_PORT=6379 +BACKEND_EXTERNAL_PORT=18000 +FRONTEND_EXTERNAL_PORT=13000 +PGADMIN_EXTERNAL_PORT=5050 + +# ๋ณผ๋ฅจ ์„ค์ • +POSTGRES_DATA_VOLUME=postgres_data +REDIS_DATA_VOLUME=redis_data +PGADMIN_DATA_VOLUME=pgadmin_data +BACKEND_CODE_VOLUME=./backend +UPLOADS_VOLUME=./backend/uploads + +# ํ”„๋กœํŒŒ์ผ ์„ค์ • +POSTGRES_PROFILE=default +REDIS_PROFILE=default +BACKEND_PROFILE=default +FRONTEND_PROFILE=default +PGADMIN_PROFILE=dev + +# API URL +VITE_API_URL=http://localhost:18000 + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +POSTGRES_DB=tk_mp_bom +POSTGRES_USER=tkmp_user +POSTGRES_PASSWORD=tkmp_password_2025 + +# pgAdmin ์„ค์ • +PGADMIN_EMAIL=admin@example.com +PGADMIN_PASSWORD=admin2025 + diff --git a/env.example b/env.example new file mode 100644 index 0000000..0f95871 --- /dev/null +++ b/env.example @@ -0,0 +1,36 @@ +# TK-MP-Project ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์ • ์˜ˆ์‹œ +# ์‹ค์ œ ์‚ฌ์šฉ ์‹œ .env ํŒŒ์ผ๋กœ ๋ณต์‚ฌํ•˜์—ฌ ๊ฐ’์„ ์ˆ˜์ •ํ•˜์„ธ์š” + +# ํ™˜๊ฒฝ ์„ค์ • +ENVIRONMENT=development +DEBUG=true + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +POSTGRES_DB=tk_mp_bom +POSTGRES_USER=tkmp_user +POSTGRES_PASSWORD=tkmp_password_2025 +POSTGRES_PORT=5432 + +# Redis ์„ค์ • +REDIS_PORT=6379 + +# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ํฌํŠธ ์„ค์ • +BACKEND_PORT=18000 +FRONTEND_PORT=13000 + +# API URL ์„ค์ • +VITE_API_URL=http://localhost:18000 + +# pgAdmin ์„ค์ • +PGADMIN_EMAIL=admin@example.com +PGADMIN_PASSWORD=admin2025 +PGADMIN_PORT=5050 + +# JWT ์„ค์ • (ํ”„๋กœ๋•์…˜์—์„œ๋Š” ๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝ) +JWT_SECRET_KEY=your-super-secure-secret-key-here + +# ๋กœ๊น… ์„ค์ • +LOG_LEVEL=DEBUG + +# ๋ณด์•ˆ ์„ค์ • +CORS_ORIGINS=http://localhost:3000,http://localhost:13000,http://localhost:5173 diff --git a/env.production b/env.production new file mode 100644 index 0000000..7b93137 --- /dev/null +++ b/env.production @@ -0,0 +1,43 @@ +# ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์„ค์ • +DEPLOY_ENV=production +COMPOSE_PROJECT_NAME=tk-mp-prod + +# ์ปจํ…Œ์ด๋„ˆ ์„ค์ • +CONTAINER_SUFFIX=-prod +DEBUG=false +RELOAD=false +LOG_LEVEL=INFO + +# ํฌํŠธ ์„ค์ • (ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ - ๋‚ด๋ถ€ ์„œ๋น„์Šค๋Š” ํฌํŠธ ๋น„๋…ธ์ถœ) +POSTGRES_EXTERNAL_PORT= +REDIS_EXTERNAL_PORT= +BACKEND_EXTERNAL_PORT= +FRONTEND_EXTERNAL_PORT= +PGADMIN_EXTERNAL_PORT= + +# Nginx ํฌํŠธ (ํ”„๋กœ๋•์…˜์—์„œ๋งŒ ์‚ฌ์šฉ) +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 + +# ๋ณผ๋ฅจ ์„ค์ • +POSTGRES_DATA_VOLUME=postgres_data +REDIS_DATA_VOLUME=redis_data +PGADMIN_DATA_VOLUME=pgadmin_data +BACKEND_CODE_VOLUME= +UPLOADS_VOLUME=./backend/uploads + +# ํ”„๋กœํŒŒ์ผ ์„ค์ • (pgAdmin ๋น„ํ™œ์„ฑํ™”) +POSTGRES_PROFILE=default +REDIS_PROFILE=default +BACKEND_PROFILE=default +FRONTEND_PROFILE=default +PGADMIN_PROFILE=disabled + +# API URL (ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์ƒ๋Œ€ ๊ฒฝ๋กœ) +VITE_API_URL=/api + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +POSTGRES_DB=tk_mp_bom +POSTGRES_USER=tkmp_user +POSTGRES_PASSWORD=tkmp_password_2025 + diff --git a/env.synology b/env.synology new file mode 100644 index 0000000..1b1bc4b --- /dev/null +++ b/env.synology @@ -0,0 +1,43 @@ +# ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ ์„ค์ • +DEPLOY_ENV=synology +COMPOSE_PROJECT_NAME=tk-mp-synology + +# ์ปจํ…Œ์ด๋„ˆ ์„ค์ • +CONTAINER_SUFFIX=-synology +DEBUG=false +RELOAD=false +LOG_LEVEL=INFO + +# ํฌํŠธ ์„ค์ • (์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ - ํฌํŠธ ์ถฉ๋Œ ๋ฐฉ์ง€) +POSTGRES_EXTERNAL_PORT=15432 +REDIS_EXTERNAL_PORT=16379 +BACKEND_EXTERNAL_PORT=10080 +FRONTEND_EXTERNAL_PORT=10173 +PGADMIN_EXTERNAL_PORT=15050 + +# ๋ณผ๋ฅจ ์„ค์ • (์‹œ๋†€๋กœ์ง€์šฉ ๋ช…๋ช…๋œ ๋ณผ๋ฅจ) +POSTGRES_DATA_VOLUME=tk_mp_postgres_data +REDIS_DATA_VOLUME=tk_mp_redis_data +PGLADMIN_DATA_VOLUME=pgadmin_data +BACKEND_CODE_VOLUME= +UPLOADS_VOLUME=tk_mp_uploads + +# ํ”„๋กœํŒŒ์ผ ์„ค์ • +POSTGRES_PROFILE=default +REDIS_PROFILE=default +BACKEND_PROFILE=default +FRONTEND_PROFILE=default +PGADMIN_PROFILE=dev + +# API URL (์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ) +VITE_API_URL=http://localhost:10080 + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +POSTGRES_DB=tk_mp_bom +POSTGRES_USER=tkmp_user +POSTGRES_PASSWORD=tkmp_password_2025 + +# pgAdmin ์„ค์ • +PGADMIN_EMAIL=admin@example.com +PGLADMIN_PASSWORD=admin2025 + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 55fa86c..57086ec 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,6 +3,9 @@ server { server_name localhost; root /usr/share/nginx/html; index index.html index.htm; + + # ๐Ÿ”ง ์š”์ฒญ ํฌ๊ธฐ ์ œํ•œ ์ฆ๊ฐ€ (413 ์˜ค๋ฅ˜ ํ•ด๊ฒฐ) + client_max_body_size 100M; # SPA๋ฅผ ์œ„ํ•œ ์„ค์ • (React Router ๋“ฑ) location / { @@ -16,6 +19,10 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # ํ”„๋ก์‹œ ์š”์ฒญ ํฌ๊ธฐ ์ œํ•œ ์ฆ๊ฐ€ + proxy_request_buffering off; + client_max_body_size 100M; } # ์ •์  ํŒŒ์ผ ์บ์‹ฑ diff --git a/frontend/public/img/login-bg.jpeg b/frontend/public/img/login-bg.jpeg new file mode 100644 index 0000000..1681fd4 Binary files /dev/null and b/frontend/public/img/login-bg.jpeg differ diff --git a/frontend/public/img/logo.png b/frontend/public/img/logo.png new file mode 100644 index 0000000..5bbd962 Binary files /dev/null and b/frontend/public/img/logo.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4dab90c..61875d4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,13 +1,8 @@ import React, { useState, useEffect } from 'react'; import SimpleLogin from './SimpleLogin'; -import NavigationMenu from './components/NavigationMenu'; -import DashboardPage from './pages/DashboardPage'; -import ProjectsPage from './pages/ProjectsPage'; -import BOMStatusPage from './pages/BOMStatusPage'; -import SimpleMaterialsPage from './pages/SimpleMaterialsPage'; -import MaterialComparisonPage from './pages/MaterialComparisonPage'; -import RevisionPurchasePage from './pages/RevisionPurchasePage'; -import JobSelectionPage from './pages/JobSelectionPage'; +import BOMWorkspacePage from './pages/BOMWorkspacePage'; +import NewMaterialsPage from './pages/NewMaterialsPage'; +import SystemSettingsPage from './pages/SystemSettingsPage'; import './App.css'; function App() { @@ -16,6 +11,7 @@ function App() { const [user, setUser] = useState(null); const [currentPage, setCurrentPage] = useState('dashboard'); const [pageParams, setPageParams] = useState({}); + const [selectedProject, setSelectedProject] = useState(null); useEffect(() => { // ์ €์žฅ๋œ ํ† ํฐ ํ™•์ธ @@ -28,6 +24,24 @@ function App() { } setIsLoading(false); + + // ์ž์žฌ ๋ชฉ๋ก ํŽ˜์ด์ง€๋กœ ์ด๋™ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + const handleNavigateToMaterials = (event) => { + const { jobNo, revision, bomName, message, file_id } = event.detail; + navigateToPage('materials', { + jobNo: jobNo, + revision: revision, + bomName: bomName, + message: message, + file_id: file_id // file_id ์ถ”๊ฐ€ + }); + }; + + window.addEventListener('navigateToMaterials', handleNavigateToMaterials); + + return () => { + window.removeEventListener('navigateToMaterials', handleNavigateToMaterials); + }; }, []); // ๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ํ˜ธ์ถœ๋  ํ•จ์ˆ˜ @@ -54,152 +68,393 @@ function App() { setPageParams(params); }; + // ํ•ต์‹ฌ ๊ธฐ๋Šฅ๋งŒ ์ œ๊ณต + const getCoreFeatures = () => { + return [ + { + id: 'bom', + title: '๐Ÿ“‹ BOM ์—…๋กœ๋“œ & ๋ถ„๋ฅ˜', + description: '์—‘์…€ ํŒŒ์ผ ์—…๋กœ๋“œ โ†’ ์ž๋™ ๋ถ„๋ฅ˜ โ†’ ๊ฒ€ํ†  โ†’ ์ž์žฌ ํ™•์ธ โ†’ ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ', + color: '#4299e1' + } + ]; + }; + + // ๊ด€๋ฆฌ์ž ์ „์šฉ ๊ธฐ๋Šฅ + const getAdminFeatures = () => { + if (user?.role !== 'admin') return []; + + return [ + { + id: 'system-settings', + title: 'โš™๏ธ ์‹œ์Šคํ…œ ์„ค์ •', + description: '์‚ฌ์šฉ์ž ๊ณ„์ • ๊ด€๋ฆฌ', + color: '#dc2626' + } + ]; + }; + // ํŽ˜์ด์ง€ ๋ Œ๋”๋ง ํ•จ์ˆ˜ const renderCurrentPage = () => { + console.log('ํ˜„์žฌ ํŽ˜์ด์ง€:', currentPage, 'ํŽ˜์ด์ง€ ํŒŒ๋ผ๋ฏธํ„ฐ:', pageParams); switch (currentPage) { case 'dashboard': - return ; - case 'projects': - return ; + const coreFeatures = getCoreFeatures(); + const adminFeatures = getAdminFeatures(); + + return ( +
+ {/* ์ƒ๋‹จ ํ—ค๋” */} +
+
+

+ ๐Ÿญ TK-MP BOM ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +

+

+ {user?.full_name || user?.username}๋‹˜ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค +

+
+ +
+ + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
+ + {/* ํ”„๋กœ์ ํŠธ ์„ ํƒ */} +
+

+ ๐Ÿ“ ํ”„๋กœ์ ํŠธ ์„ ํƒ +

+ +
+ + {/* ํ•ต์‹ฌ ๊ธฐ๋Šฅ */} + {selectedProject && ( + <> +
+

+ ๐Ÿ“‹ BOM ๊ด€๋ฆฌ ์›Œํฌํ”Œ๋กœ์šฐ +

+
+ {coreFeatures.map((feature) => ( +
{ + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)'; + }} + > +

+ {feature.title} +

+

+ {feature.description} +

+ +
+ ))} +
+
+ + {/* ๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ (์žˆ๋Š” ๊ฒฝ์šฐ๋งŒ) */} + {adminFeatures.length > 0 && ( +
+

+ โš™๏ธ ์‹œ์Šคํ…œ ๊ด€๋ฆฌ +

+
+ {adminFeatures.map((feature) => ( +
{ + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)'; + }} + > +

+ {feature.title} +

+

+ {feature.description} +

+
+ + ๊ด€๋ฆฌ์ž ์ „์šฉ + +
+ +
+ ))} +
+
+ )} + + {/* ๊ฐ„๋‹จํ•œ ์‚ฌ์šฉ๋ฒ• ์•ˆ๋‚ด */} +
+

+ ๐Ÿ“– ๊ฐ„๋‹จํ•œ ์‚ฌ์šฉ๋ฒ• +

+
+
+ 1 + BOM ์—…๋กœ๋“œ +
+ โ†’ +
+ 2 + ์ž๋™ ๋ถ„๋ฅ˜ +
+ โ†’ +
+ 3 + ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ +
+
+
+ + )} {/* selectedProject ์กฐ๊ฑด๋ฌธ ๋‹ซ๊ธฐ */} +
+
+ ); + case 'bom': - return - navigateToPage('bom-status', { job_no: jobNo, job_name: jobName }) - } />; - case 'bom-status': - return ; + return ( + navigateToPage('dashboard')} + /> + ); + case 'materials': - return ; - case 'material-comparison': - return ; - case 'revision-purchase': - return ; - case 'quotes': - return
๐Ÿ’ฐ ๊ฒฌ์  ๊ด€๋ฆฌ ํŽ˜์ด์ง€ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •)
; - case 'procurement': - return
๐Ÿ›’ ๊ตฌ๋งค ๊ด€๋ฆฌ ํŽ˜์ด์ง€ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •)
; - case 'production': - return
๐Ÿญ ์ƒ์‚ฐ ๊ด€๋ฆฌ ํŽ˜์ด์ง€ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •)
; - case 'shipment': - return
๐Ÿšš ์ถœํ•˜ ๊ด€๋ฆฌ ํŽ˜์ด์ง€ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •)
; - case 'users': - return
๐Ÿ‘ฅ ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ ํŽ˜์ด์ง€ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •)
; - case 'system': - return
โš™๏ธ ์‹œ์Šคํ…œ ์„ค์ • ํŽ˜์ด์ง€ (๊ณง ๊ตฌํ˜„ ์˜ˆ์ •)
; + return ( + + ); + + case 'system-settings': + return ( + + ); + default: - return ; + return ( +
+

ํŽ˜์ด์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค

+ +
+ ); } }; + // ๋กœ๋”ฉ ์ค‘ if (isLoading) { return ( -
-
๋กœ๋”ฉ ์ค‘...
+
+
๐Ÿ”„
+
๋กœ๋”ฉ ์ค‘...
+
); } + // ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ if (!isAuthenticated) { return ; } + // ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ return ( -
- navigateToPage(page, {})} - /> - - {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  ์˜์—ญ */} -
- {/* ์ƒ๋‹จ ํ—ค๋” */} -
-
-

- {currentPage === 'dashboard' && '๋Œ€์‹œ๋ณด๋“œ'} - {currentPage === 'projects' && 'ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ'} - {currentPage === 'bom' && 'BOM ๊ด€๋ฆฌ'} - {currentPage === 'materials' && '์ž์žฌ ๊ด€๋ฆฌ'} - {currentPage === 'quotes' && '๊ฒฌ์  ๊ด€๋ฆฌ'} - {currentPage === 'procurement' && '๊ตฌ๋งค ๊ด€๋ฆฌ'} - {currentPage === 'production' && '์ƒ์‚ฐ ๊ด€๋ฆฌ'} - {currentPage === 'shipment' && '์ถœํ•˜ ๊ด€๋ฆฌ'} - {currentPage === 'users' && '์‚ฌ์šฉ์ž ๊ด€๋ฆฌ'} - {currentPage === 'system' && '์‹œ์Šคํ…œ ์„ค์ •'} -

-
- - -
- - {/* ํŽ˜์ด์ง€ ์ฝ˜ํ…์ธ  */} -
- {renderCurrentPage()} -
-
+
+ {renderCurrentPage()}
); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/SimpleDashboard.jsx b/frontend/src/SimpleDashboard.jsx index 15c3b4d..3045ead 100644 --- a/frontend/src/SimpleDashboard.jsx +++ b/frontend/src/SimpleDashboard.jsx @@ -218,3 +218,19 @@ const SimpleDashboard = () => { }; export default SimpleDashboard; + + + + + + + + + + + + + + + + diff --git a/frontend/src/SimpleLogin.jsx b/frontend/src/SimpleLogin.jsx index 3ccde25..64c6a9f 100644 --- a/frontend/src/SimpleLogin.jsx +++ b/frontend/src/SimpleLogin.jsx @@ -61,146 +61,150 @@ const SimpleLogin = ({ onLoginSuccess }) => { return (
-
-

- ๐Ÿš€ TK-MP System -

-

- ํ†ตํ•ฉ ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ -

-
+ ํ…Œํฌ๋‹ˆ์ปฌ์ฝ”๋ฆฌ์•„ ๋กœ๊ณ  +

+ (์ฃผ)ํ…Œํฌ๋‹ˆ์ปฌ์ฝ”๋ฆฌ์•„ +

+

+ ํ†ตํ•ฉ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +

-
- - -
- -
- - -
- - {error && ( -
- โš ๏ธ - {error} -
- )} - - {success && ( -
- โœ… {success} -
- )} + +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ โœ… {success} +
+ )} +
-

- ํ…Œ์ŠคํŠธ ๊ณ„์ •: admin / admin123 ๋˜๋Š” testuser / test123 +

+ ํ…Œ์ŠคํŠธ ๊ณ„์ •: admin / admin123

- - TK-MP Project Management System v2.0 + + TK-MP ํ†ตํ•ฉ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ v2.0
diff --git a/frontend/src/TestApp.jsx b/frontend/src/TestApp.jsx deleted file mode 100644 index e9d5daa..0000000 --- a/frontend/src/TestApp.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; - -const TestApp = () => { - return ( -
-
-

๐Ÿš€ TK-MP System

-

์‹œ์Šคํ…œ์ด ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ ์ค‘์ž…๋‹ˆ๋‹ค!

-
- -
-
-
- ); -}; - -export default TestApp; diff --git a/frontend/src/components/BOMFileTable.jsx b/frontend/src/_deprecated/BOMFileTable.jsx similarity index 75% rename from frontend/src/components/BOMFileTable.jsx rename to frontend/src/_deprecated/BOMFileTable.jsx index 9c880da..a06fa1e 100644 --- a/frontend/src/components/BOMFileTable.jsx +++ b/frontend/src/_deprecated/BOMFileTable.jsx @@ -6,7 +6,8 @@ const BOMFileTable = ({ groupFilesByBOM, handleViewMaterials, openRevisionDialog, - handleDelete + handleDelete, + onNavigate }) => { if (loading) { return ( @@ -97,6 +98,32 @@ const BOMFileTable = ({
+ + - {index === 0 && ( - - )} + + +

+ ๐Ÿ“Š BOM ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +

+ + {jobNo && jobName && ( +

+ {jobNo} - {jobName} +

+ )} +
+ + {/* ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ */} + + + {/* BOM ๋ชฉ๋ก */} +

+ ์—…๋กœ๋“œ๋œ BOM ๋ชฉ๋ก +

+ + {loading ? ( +
+ ๋กœ๋”ฉ ์ค‘... +
+ ) : ( +
+ + + + + + + + + + + + + {files.map((file) => ( + + + + + + + + + ))} + +
BOM ์ด๋ฆ„ํŒŒ์ผ๋ช…๋ฆฌ๋น„์ „์ž์žฌ ์ˆ˜์—…๋กœ๋“œ ์ผ์‹œ์ž‘์—…
+
+ {file.bom_name || file.original_filename} +
+
+ {file.description || ''} +
+
+ {file.original_filename} + + + {file.revision || 'Rev.0'} + + + {file.parsed_count || 0}๊ฐœ + + {new Date(file.created_at).toLocaleDateString()} + +
+ + + +
+
+ + {files.length === 0 && ( +
+ ์—…๋กœ๋“œ๋œ BOM ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. +
+ )} +
+ )} +
+
+ ); +}; + +export default BOMStatusPage; \ No newline at end of file diff --git a/frontend/src/_deprecated/BOMUploadPage.jsx b/frontend/src/_deprecated/BOMUploadPage.jsx new file mode 100644 index 0000000..5c74425 --- /dev/null +++ b/frontend/src/_deprecated/BOMUploadPage.jsx @@ -0,0 +1,416 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../api'; + +const BOMUploadPage = ({ projectInfo, onNavigate, onBack }) => { + const [selectedFile, setSelectedFile] = useState(null); + const [jobNo, setJobNo] = useState(projectInfo?.job_no || ''); + const [revision, setRevision] = useState('Rev.0'); + const [bomName, setBomName] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + + useEffect(() => { + if (projectInfo) { + setJobNo(projectInfo.job_no); + setBomName(projectInfo.project_name || ''); + } + }, [projectInfo]); + + const handleFileSelect = (event) => { + const file = event.target.files[0]; + if (file) { + setSelectedFile(file); + setUploadResult(null); + } + }; + + const handleUpload = async () => { + if (!selectedFile) { + alert('ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + if (!jobNo.trim()) { + alert('Job ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + setIsUploading(true); + setUploadResult(null); + + try { + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('job_no', jobNo.trim()); + formData.append('revision', revision); + if (bomName.trim()) { + formData.append('bom_name', bomName.trim()); + } + + const response = await api.post('/files/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + if (response.data.success) { + setUploadResult({ + success: true, + message: response.data.message, + fileId: response.data.file_id, + materialsCount: response.data.materials_count, + revision: response.data.revision, + uploadedBy: response.data.uploaded_by + }); + + // ํŒŒ์ผ ์„ ํƒ ์ดˆ๊ธฐํ™” + setSelectedFile(null); + const fileInput = document.getElementById('file-input'); + if (fileInput) fileInput.value = ''; + } else { + setUploadResult({ + success: false, + message: response.data.message || '์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.' + }); + } + } catch (error) { + console.error('์—…๋กœ๋“œ ์˜ค๋ฅ˜:', error); + setUploadResult({ + success: false, + message: error.response?.data?.detail || '์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.' + }); + } finally { + setIsUploading(false); + } + }; + + const handleViewMaterials = () => { + if (uploadResult && uploadResult.fileId) { + onNavigate('materials', { + file_id: uploadResult.fileId, + jobNo: jobNo, + bomName: bomName, + revision: uploadResult.revision, + filename: selectedFile?.name + }); + } + }; + + return ( +
+
+ {/* ํ—ค๋” */} +
+ {onBack && ( + + )} +
+

+ ๐Ÿ“ค BOM ํŒŒ์ผ ์—…๋กœ๋“œ +

+ {projectInfo && ( +
+ {projectInfo.project_name} ({projectInfo.job_no}) +
+ )} +
+
+ + {/* ์—…๋กœ๋“œ ํผ */} +
+
+ {/* Job ๋ฒˆํ˜ธ */} +
+ + setJobNo(e.target.value)} + placeholder="์˜ˆ: TK-2024-001" + disabled={!!projectInfo?.job_no} + style={{ + width: '100%', + padding: '12px 16px', + border: '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '16px', + backgroundColor: projectInfo?.job_no ? '#f9fafb' : 'white' + }} + /> +
+ + {/* ๋ฆฌ๋น„์ „ */} +
+ + setRevision(e.target.value)} + placeholder="์˜ˆ: Rev.0, Rev.1" + style={{ + width: '100%', + padding: '12px 16px', + border: '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '16px' + }} + /> +
+ + {/* BOM ์ด๋ฆ„ */} +
+ + setBomName(e.target.value)} + placeholder="BOM ํŒŒ์ผ ์„ค๋ช…" + style={{ + width: '100%', + padding: '12px 16px', + border: '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '16px' + }} + /> +
+ + {/* ํŒŒ์ผ ์„ ํƒ */} +
+ +
+ + +
+
+ + {/* ์—…๋กœ๋“œ ๋ฒ„ํŠผ */} + +
+
+ + {/* ์—…๋กœ๋“œ ๊ฒฐ๊ณผ */} + {uploadResult && ( +
+
+
+ {uploadResult.success ? 'โœ…' : 'โŒ'} +
+
+

+ {uploadResult.success ? '์—…๋กœ๋“œ ์„ฑ๊ณต!' : '์—…๋กœ๋“œ ์‹คํŒจ'} +

+

+ {uploadResult.message} +

+ + {uploadResult.success && ( +
+ +
+ {uploadResult.materialsCount}๊ฐœ ์ž์žฌ ๋ถ„๋ฅ˜๋จ โ€ข {uploadResult.uploadedBy} +
+
+ )} +
+
+
+ )} +
+
+ ); +}; + +export default BOMUploadPage; diff --git a/frontend/src/api.js b/frontend/src/api.js index 0509096..14f74fe 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -21,6 +21,20 @@ export const api = axios.create({ const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1์ดˆ +// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ: ํ† ํฐ ์ž๋™ ์ถ”๊ฐ€ +api.interceptors.request.use( + config => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + error => { + return Promise.reject(error); + } +); + // ์žฌ์‹œ๋„ ํ•จ์ˆ˜ const retryRequest = async (config, retries = MAX_RETRIES) => { try { @@ -35,7 +49,7 @@ const retryRequest = async (config, retries = MAX_RETRIES) => { } }; -// ๊ณตํ†ต ์—๋Ÿฌ ํ•ธ๋“ค๋ง (์˜ˆ์‹œ) +// ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ: ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฐ ์ž๋™ ๋กœ๊ทธ์•„์›ƒ api.interceptors.response.use( response => response, error => { @@ -47,7 +61,18 @@ api.interceptors.response.use( message: error.message }); - // ํ•„์š”์‹œ ์—๋Ÿฌ ๋กœ๊น…/์•Œ๋ฆผ ๋“ฑ ์ถ”๊ฐ€ + // 401/403 ์—๋Ÿฌ ์‹œ ์ž๋™ ๋กœ๊ทธ์•„์›ƒ + if (error.response?.status === 401 || error.response?.status === 403) { + const token = localStorage.getItem('access_token'); + if (token) { + console.log('ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ž๋™ ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.'); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_data'); + // ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ์œผ๋กœ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + window.location.reload(); + } + } + return Promise.reject(error); } ); @@ -65,9 +90,9 @@ export function uploadFile(formData, options = {}) { return retryRequest(config); } -// ์˜ˆ์‹œ: ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ +// ์˜ˆ์‹œ: ์ž์žฌ ๋ชฉ๋ก ์กฐํšŒ (์‹ ๋ฒ„์ „ API ์‚ฌ์šฉ) export function fetchMaterials(params) { - return api.get('/files/materials', { params }); + return api.get('/files/materials-v2', { params }); } // ์˜ˆ์‹œ: ์ž์žฌ ์š”์•ฝ ํ†ต๊ณ„ @@ -82,7 +107,7 @@ export function fetchFiles(params) { // ํŒŒ์ผ ์‚ญ์ œ export function deleteFile(fileId) { - return api.delete(`/files/${fileId}`); + return api.delete(`/files/delete/${fileId}`); } // ์˜ˆ์‹œ: Job ๋ชฉ๋ก ์กฐํšŒ diff --git a/frontend/src/components/BOMFileUpload.jsx b/frontend/src/components/BOMFileUpload.jsx index 80b8149..fdd97cf 100644 --- a/frontend/src/components/BOMFileUpload.jsx +++ b/frontend/src/components/BOMFileUpload.jsx @@ -116,3 +116,19 @@ const BOMFileUpload = ({ }; export default BOMFileUpload; + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/FileUpload.jsx b/frontend/src/components/FileUpload.jsx index c756298..60573e2 100644 --- a/frontend/src/components/FileUpload.jsx +++ b/frontend/src/components/FileUpload.jsx @@ -464,7 +464,18 @@ function FileUpload({ selectedProject, onUploadSuccess }) { +
+ + + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
+
+ {/* ๊ฐœ์ธ๋ณ„ ๋งž์ถค ๋ฐฐ๋„ˆ */} +
+
+
+ {user.role === 'admin' ? '๐Ÿ‘‘' : + user.role === 'manager' ? '๐Ÿ‘จโ€๐Ÿ’ผ' : + user.role === 'designer' ? '๐ŸŽจ' : + user.role === 'purchaser' ? '๐Ÿ›’' : '๐Ÿ‘ค'} +
+
+

+ ์•ˆ๋…•ํ•˜์„ธ์š”, {user.name || user.username}๋‹˜! ๐Ÿ‘‹ +

+

+ {dashboardData.subtitle} +

+
+
+
+ + {/* ํ•ต์‹ฌ ์ง€ํ‘œ ์นด๋“œ๋“ค */} +
+ {(dashboardData.metrics || []).map((metric, index) => ( +
{ + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; + }}> +
+
+
+ {metric.label} +
+
+ {metric.value} +
+
+
+ {metric.icon} +
+
+
+ ))} +
+ +
+ {/* ๋น ๋ฅธ ์ž‘์—… */} +
+

+ โšก ๋น ๋ฅธ ์ž‘์—… +

+
+ {(dashboardData.quickActions || []).map((action, index) => ( + + ))} +
+
+ + {/* ์ตœ๊ทผ ํ™œ๋™ */} +
+

+ ๐Ÿ“ˆ ์ตœ๊ทผ ํ™œ๋™ +

+
+ {recentActivities.map((activity, index) => ( +
+ + {activity.icon} + +
+
+ {activity.message} +
+
+ {activity.time} +
+
+
+ ))} +
+
+
+
+
+ + ); +}; + +export default PersonalizedDashboard; diff --git a/frontend/src/components/ProjectSelector.jsx b/frontend/src/components/ProjectSelector.jsx new file mode 100644 index 0000000..2441310 --- /dev/null +++ b/frontend/src/components/ProjectSelector.jsx @@ -0,0 +1,319 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../api'; + +const ProjectSelector = ({ onProjectSelect, selectedProject }) => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + + useEffect(() => { + loadProjects(); + }, []); + + const loadProjects = async () => { + try { + const response = await api.get('/jobs/'); + console.log('ํ”„๋กœ์ ํŠธ API ์‘๋‹ต:', response.data); + + // API ์‘๋‹ต ๊ตฌ์กฐ์— ๋งž๊ฒŒ ์ฒ˜๋ฆฌ + let projectsData = []; + if (response.data && response.data.success && Array.isArray(response.data.jobs)) { + // ์‹ค์ œ API ๋ฐ์ดํ„ฐ๋ฅผ ํ”„๋ก ํŠธ์—”๋“œ ํ˜•์‹์— ๋งž๊ฒŒ ๋ณ€ํ™˜ + projectsData = response.data.jobs.map(job => ({ + job_no: job.job_no, + project_name: job.project_name || job.job_name, + status: job.status === '์ง„ํ–‰์ค‘' ? 'active' : 'completed', + progress: job.status === '์ง„ํ–‰์ค‘' ? 75 : 100, // ์ž„์‹œ ์ง„ํ–‰๋ฅ  + client_name: job.client_name, + project_site: job.project_site, + delivery_date: job.delivery_date + })); + } + + // ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ๋ชฉ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + if (projectsData.length === 0) { + projectsData = [ + { job_no: 'TK-2024-001', project_name: '๋ƒ‰๋™๊ธฐ ์‹œ์Šคํ…œ', status: 'active', progress: 75 }, + { job_no: 'TK-2024-002', project_name: 'BOG ์ฒ˜๋ฆฌ ์‹œ์Šคํ…œ', status: 'active', progress: 45 }, + { job_no: 'TK-2024-003', project_name: '๋‹ค์ด์•„ํ”„๋žจ ํŽŒํ”„', status: 'active', progress: 90 }, + { job_no: 'TK-2024-004', project_name: '๋“œ๋ผ์ด์–ด ์‹œ์Šคํ…œ', status: 'completed', progress: 100 }, + { job_no: 'TK-2024-005', project_name: '์—ด๊ตํ™˜๊ธฐ ์‹œ์Šคํ…œ', status: 'active', progress: 30 } + ]; + } + + setProjects(projectsData); + } catch (error) { + console.error('ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก ๋กœ๋”ฉ ์‹คํŒจ:', error); + // ๋ชฉ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + const mockProjects = [ + { job_no: 'TK-2024-001', project_name: '๋ƒ‰๋™๊ธฐ ์‹œ์Šคํ…œ', status: 'active', progress: 75 }, + { job_no: 'TK-2024-002', project_name: 'BOG ์ฒ˜๋ฆฌ ์‹œ์Šคํ…œ', status: 'active', progress: 45 }, + { job_no: 'TK-2024-003', project_name: '๋‹ค์ด์•„ํ”„๋žจ ํŽŒํ”„', status: 'active', progress: 90 }, + { job_no: 'TK-2024-004', project_name: '๋“œ๋ผ์ด์–ด ์‹œ์Šคํ…œ', status: 'completed', progress: 100 }, + { job_no: 'TK-2024-005', project_name: '์—ด๊ตํ™˜๊ธฐ ์‹œ์Šคํ…œ', status: 'active', progress: 30 } + ]; + setProjects(mockProjects); + } finally { + setLoading(false); + } + }; + + const filteredProjects = projects.filter(project => + project.project_name.toLowerCase().includes(searchTerm.toLowerCase()) || + project.job_no.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const getStatusColor = (status) => { + const colors = { + 'active': '#48bb78', + 'completed': '#38b2ac', + 'on_hold': '#ed8936', + 'cancelled': '#e53e3e' + }; + return colors[status] || '#718096'; + }; + + const getStatusText = (status) => { + const texts = { + 'active': '์ง„ํ–‰์ค‘', + 'completed': '์™„๋ฃŒ', + 'on_hold': '๋ณด๋ฅ˜', + 'cancelled': '์ทจ์†Œ' + }; + return texts[status] || '์•Œ ์ˆ˜ ์—†์Œ'; + }; + + if (loading) { + return ( +
+ ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘... +
+ ); + } + + return ( +
+ {/* ์„ ํƒ๋œ ํ”„๋กœ์ ํŠธ ํ‘œ์‹œ ๋˜๋Š” ์„ ํƒ ๋ฒ„ํŠผ */} +
setShowDropdown(!showDropdown)} + style={{ + padding: '16px 20px', + background: selectedProject ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'white', + color: selectedProject ? 'white' : '#2d3748', + border: selectedProject ? 'none' : '2px dashed #cbd5e0', + borderRadius: '12px', + cursor: 'pointer', + transition: 'all 0.2s ease', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + boxShadow: selectedProject ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 4px rgba(0,0,0,0.1)' + }} + onMouseEnter={(e) => { + if (!selectedProject) { + e.target.style.borderColor = '#667eea'; + e.target.style.backgroundColor = '#f7fafc'; + } + }} + onMouseLeave={(e) => { + if (!selectedProject) { + e.target.style.borderColor = '#cbd5e0'; + e.target.style.backgroundColor = 'white'; + } + }} + > +
+ {selectedProject ? ( +
+
+ {selectedProject.project_name} +
+
+ {selectedProject.job_no} โ€ข {getStatusText(selectedProject.status)} +
+
+ ) : ( +
+
+ ๐ŸŽฏ ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜์„ธ์š” +
+
+ ์ž‘์—…ํ•  ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜๋ฉด ๊ด€๋ จ ์—…๋ฌด๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค +
+
+ )} +
+
+ {showDropdown ? '๐Ÿ”ผ' : '๐Ÿ”ฝ'} +
+
+ + {/* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด */} + {showDropdown && ( +
+ {/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */} +
+ setSearchTerm(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #cbd5e0', + borderRadius: '6px', + fontSize: '14px', + outline: 'none' + }} + onFocus={(e) => e.target.style.borderColor = '#667eea'} + onBlur={(e) => e.target.style.borderColor = '#cbd5e0'} + /> +
+ + {/* ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก */} +
+ {filteredProjects.length === 0 ? ( +
+ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค +
+ ) : ( + filteredProjects.map((project) => ( +
{ + onProjectSelect(project); + setShowDropdown(false); + setSearchTerm(''); + }} + style={{ + padding: '16px 20px', + cursor: 'pointer', + borderBottom: '1px solid #f7fafc', + transition: 'background-color 0.2s ease' + }} + onMouseEnter={(e) => { + e.target.style.backgroundColor = '#f7fafc'; + }} + onMouseLeave={(e) => { + e.target.style.backgroundColor = 'transparent'; + }} + > +
+
+
+ {project.project_name} +
+
+ {project.job_no} +
+ + {/* ์ง„ํ–‰๋ฅ  ๋ฐ” */} +
+
+
+
+
+ {project.progress || 0}% +
+
+
+ +
+ + {getStatusText(project.status)} + +
+
+
+ )) + )} +
+
+ )} + + {/* ๋“œ๋กญ๋‹ค์šด ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ */} + {showDropdown && ( +
setShowDropdown(false)} + /> + )} +
+ ); +}; + +export default ProjectSelector; diff --git a/frontend/src/components/RevisionUploadDialog.jsx b/frontend/src/components/RevisionUploadDialog.jsx index d4f2e56..82ba4d7 100644 --- a/frontend/src/components/RevisionUploadDialog.jsx +++ b/frontend/src/components/RevisionUploadDialog.jsx @@ -80,3 +80,19 @@ const RevisionUploadDialog = ({ }; export default RevisionUploadDialog; + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/SimpleFileUpload.jsx b/frontend/src/components/SimpleFileUpload.jsx index 0d5d150..54d5b48 100644 --- a/frontend/src/components/SimpleFileUpload.jsx +++ b/frontend/src/components/SimpleFileUpload.jsx @@ -299,3 +299,19 @@ const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => { }; export default SimpleFileUpload; + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/BOMStatusPage.jsx b/frontend/src/pages/BOMStatusPage.jsx index 9c1cc80..1ee9575 100644 --- a/frontend/src/pages/BOMStatusPage.jsx +++ b/frontend/src/pages/BOMStatusPage.jsx @@ -1,114 +1,70 @@ import React, { useState, useEffect } from 'react'; -import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api'; +import { api, fetchFiles, deleteFile as deleteFileApi } from '../api'; import BOMFileUpload from '../components/BOMFileUpload'; -import BOMFileTable from '../components/BOMFileTable'; -import RevisionUploadDialog from '../components/RevisionUploadDialog'; -const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => { +const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [uploading, setUploading] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [bomName, setBomName] = useState(''); - const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null }); - const [revisionFile, setRevisionFile] = useState(null); - const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null }); - // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ƒ‰์ƒ ํ•จ์ˆ˜ - const getCategoryColor = (category) => { - const colors = { - 'pipe': '#4299e1', - 'fitting': '#48bb78', - 'valve': '#ed8936', - 'flange': '#9f7aea', - 'bolt': '#38b2ac', - 'gasket': '#f56565', - 'instrument': '#d69e2e', - 'material': '#718096', - 'integrated': '#319795', - 'unknown': '#a0aec0' - }; - return colors[category?.toLowerCase()] || colors.unknown; - }; + useEffect(() => { + if (jobNo) { + fetchFilesList(); + } + }, [jobNo]); - // ํŒŒ์ผ ๋ชฉ๋ก ๋ถˆ๋Ÿฌ์˜ค๊ธฐ - const fetchFiles = async () => { - setLoading(true); - setError(''); + const fetchFilesList = async () => { try { - console.log('fetchFiles ํ˜ธ์ถœ - jobNo:', jobNo); - const response = await fetchFilesApi({ job_no: jobNo }); - console.log('API ์‘๋‹ต:', response); + setLoading(true); + const response = await api.get('/files/', { + params: { job_no: jobNo } + }); - if (response.data && response.data.data && Array.isArray(response.data.data)) { - setFiles(response.data.data); - } else if (response.data && Array.isArray(response.data)) { + // API๊ฐ€ ๋ฐฐ์—ด๋กœ ์ง์ ‘ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฒฝ์šฐ + if (Array.isArray(response.data)) { setFiles(response.data); - } else if (response.data && Array.isArray(response.data.files)) { - setFiles(response.data.files); + } else if (response.data && response.data.success) { + setFiles(response.data.files || []); } else { setFiles([]); } } catch (err) { - console.error('ํŒŒ์ผ ๋ชฉ๋ก ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์‹คํŒจ:', err); + console.error('ํŒŒ์ผ ๋ชฉ๋ก ๋กœ๋”ฉ ์‹คํŒจ:', err); setError('ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); } finally { setLoading(false); } }; - useEffect(() => { - if (jobNo) { - fetchFiles(); - } - }, [jobNo]); - // ํŒŒ์ผ ์—…๋กœ๋“œ const handleUpload = async () => { if (!selectedFile || !bomName.trim()) { - setError('ํŒŒ์ผ๊ณผ BOM ์ด๋ฆ„์„ ๋ชจ๋‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + alert('ํŒŒ์ผ๊ณผ BOM ์ด๋ฆ„์„ ๋ชจ๋‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); return; } - setUploading(true); - setError(''); - try { + setUploading(true); const formData = new FormData(); formData.append('file', selectedFile); - formData.append('job_no', jobNo); formData.append('bom_name', bomName.trim()); + formData.append('job_no', jobNo); - const uploadResult = await uploadFileApi(formData); - - // ์—…๋กœ๋“œ ์„ฑ๊ณต ํ›„ ํŒŒ์ผ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ - await fetchFiles(); - - // ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ ์ž๋™์œผ๋กœ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์‹คํ–‰ - if (uploadResult && uploadResult.file_id) { - // ์ž ์‹œ ํ›„ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํŽ˜์ด์ง€๋กœ ์ด๋™ - setTimeout(async () => { - try { - // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ API ํ˜ธ์ถœ - const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`); - const purchaseData = await response.json(); - - if (purchaseData.success) { - // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ๋ฅผ ๋ชจ๋‹ฌ๋กœ ํ‘œ์‹œํ•˜๊ฑฐ๋‚˜ ๋ณ„๋„ ํŽ˜์ด์ง€๋กœ ์ด๋™ - alert(`์—…๋กœ๋“œ ๋ฐ ๋ถ„๋ฅ˜ ์™„๋ฃŒ!\n๊ตฌ๋งค ์ˆ˜๋Ÿ‰์ด ๊ณ„์‚ฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\nํŒŒ์ดํ”„: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}๊ฐœ ํ•ญ๋ชฉ\n๊ธฐํƒ€ ์ž์žฌ: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}๊ฐœ ํ•ญ๋ชฉ`); - } - } catch (error) { - console.error('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์‹คํŒจ:', error); - } - }, 2000); // 2์ดˆ ํ›„ ์‹คํ–‰ (๋ถ„๋ฅ˜ ์™„๋ฃŒ ๋Œ€๊ธฐ) + const response = await api.post('/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + if (response.data && response.data.success) { + alert('ํŒŒ์ผ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!'); + setSelectedFile(null); + setBomName(''); + await fetchFilesList(); // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + } else { + throw new Error(response.data?.message || '์—…๋กœ๋“œ ์‹คํŒจ'); } - - // ํผ ์ดˆ๊ธฐํ™” - setSelectedFile(null); - setBomName(''); - document.getElementById('file-input').value = ''; - } catch (err) { console.error('ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ:', err); setError('ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); @@ -125,111 +81,26 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => { try { await deleteFileApi(fileId); - await fetchFiles(); // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + await fetchFilesList(); // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ } catch (err) { console.error('ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ:', err); setError('ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); } }; - // ์ž์žฌ ํ™•์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ - // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ (์ž์žฌ ๋ชฉ๋ก ํŽ˜์ด์ง€ ๊ฑฐ์น˜์ง€ ์•Š์Œ) - const handleViewMaterials = async (file) => { - try { - setLoading(true); - - // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ API ํ˜ธ์ถœ - console.log('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ API ํ˜ธ์ถœ:', { - job_no: file.job_no, - revision: file.revision || 'Rev.0', - file_id: file.id + // ์ž์žฌ ๊ด€๋ฆฌ ํŽ˜์ด์ง€๋กœ ๋ฐ”๋กœ ์ด๋™ (๋‹จ์ˆœํ™”) + const handleViewMaterials = (file) => { + if (onNavigate) { + onNavigate('materials', { + file_id: file.id, + jobNo: file.job_no, + bomName: file.bom_name || file.original_filename, + revision: file.revision, + filename: file.original_filename }); - - const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`); - - console.log('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์‘๋‹ต:', response.data); - const purchaseData = response.data; - - if (purchaseData.success && purchaseData.items) { - // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ๋ฅผ ๋ชจ๋‹ฌ๋กœ ํ‘œ์‹œ - setPurchaseModal({ - open: true, - data: purchaseData.items, - fileInfo: file - }); - } else { - alert('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } - } catch (error) { - console.error('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์˜ค๋ฅ˜:', error); - alert('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setLoading(false); } }; - // ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ๋‹ค์ด์–ผ๋กœ๊ทธ ์—ด๊ธฐ - const openRevisionDialog = (bomName, parentId) => { - setRevisionDialog({ open: true, bomName, parentId }); - }; - - // ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ - const handleRevisionUpload = async () => { - if (!revisionFile || !revisionDialog.bomName) { - setError('ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); - return; - } - - setUploading(true); - setError(''); - - try { - const formData = new FormData(); - formData.append('file', revisionFile); - formData.append('job_no', jobNo); - formData.append('bom_name', revisionDialog.bomName); - formData.append('parent_id', revisionDialog.parentId); - - await uploadFileApi(formData); - - // ์—…๋กœ๋“œ ์„ฑ๊ณต ํ›„ ํŒŒ์ผ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ - await fetchFiles(); - - // ๋‹ค์ด์–ผ๋กœ๊ทธ ๋‹ซ๊ธฐ - setRevisionDialog({ open: false, bomName: '', parentId: null }); - setRevisionFile(null); - - } catch (err) { - console.error('๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ์‹คํŒจ:', err); - setError('๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setUploading(false); - } - }; - - // BOM๋ณ„๋กœ ๊ทธ๋ฃนํ™” - const groupFilesByBOM = () => { - const grouped = {}; - files.forEach(file => { - const bomKey = file.bom_name || file.original_filename || file.filename; - if (!grouped[bomKey]) { - grouped[bomKey] = []; - } - grouped[bomKey].push(file); - }); - - // ๊ฐ ๊ทธ๋ฃน์„ ๋ฆฌ๋น„์ „ ์ˆœ์œผ๋กœ ์ •๋ ฌ - Object.keys(grouped).forEach(key => { - grouped[key].sort((a, b) => { - const revA = parseInt(a.revision?.replace('Rev.', '') || '0'); - const revB = parseInt(b.revision?.replace('Rev.', '') || '0'); - return revB - revA; // ์ตœ์‹  ๋ฆฌ๋น„์ „์ด ๋จผ์ € ์˜ค๋„๋ก - }); - }); - - return grouped; - }; - return (
{ {/* ํ—ค๋” */}

{ ์—…๋กœ๋“œ๋œ BOM ๋ชฉ๋ก

- {/* ํŒŒ์ผ ํ…Œ์ด๋ธ” ์ปดํฌ๋„ŒํŠธ */} - - - {/* ๋ฆฌ๋น„์ „ ์—…๋กœ๋“œ ๋‹ค์ด์–ผ๋กœ๊ทธ */} - - - {/* ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ ๋ชจ๋‹ฌ */} - {purchaseModal.open && ( + {loading ? ( +
+ ๋กœ๋”ฉ ์ค‘... +
+ ) : (
-
-
-

- ๐Ÿงฎ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ -

- -
- -
-
ํ”„๋กœ์ ํŠธ: {purchaseModal.fileInfo?.job_no}
-
BOM: {purchaseModal.fileInfo?.bom_name}
-
๋ฆฌ๋น„์ „: {purchaseModal.fileInfo?.revision || 'Rev.0'}
-
- -
- - - - - - - - - - - - - - - {purchaseModal.data?.map((item, index) => ( - - + + + + + + + + + + + {files.map((file) => ( + + + + + + + - - - - - - - - - ))} - -
์นดํ…Œ๊ณ ๋ฆฌ์‚ฌ์–‘์‚ฌ์ด์ฆˆ์žฌ์งˆBOM ์ˆ˜๋Ÿ‰๊ตฌ๋งค ์ˆ˜๋Ÿ‰๋‹จ์œ„๋น„๊ณ 
- +
BOM ์ด๋ฆ„ํŒŒ์ผ๋ช…๋ฆฌ๋น„์ „์ž์žฌ ์ˆ˜์—…๋กœ๋“œ ์ผ์‹œ์ž‘์—…
+
+ {file.bom_name || file.original_filename} +
+
+ {file.description || ''} +
+
+ {file.original_filename} + + + {file.revision || 'Rev.0'} + + + {file.parsed_count || 0}๊ฐœ + + {new Date(file.created_at).toLocaleDateString()} + +
+
- {item.specification} - - {/* PIPE๋Š” ์‚ฌ์–‘์— ๋ชจ๋“  ์ •๋ณด๊ฐ€ ํฌํ•จ๋˜๋ฏ€๋กœ ์‚ฌ์ด์ฆˆ ์ปฌ๋Ÿผ ๋น„์›€ */} - {item.category !== 'PIPE' && ( - - {item.size_spec || '-'} - - )} - {item.category === 'PIPE' && ( - - ์‚ฌ์–‘์— ํฌํ•จ - - )} - - {/* PIPE๋Š” ์‚ฌ์–‘์— ๋ชจ๋“  ์ •๋ณด๊ฐ€ ํฌํ•จ๋˜๋ฏ€๋กœ ์žฌ์งˆ ์ปฌ๋Ÿผ ๋น„์›€ */} - {item.category !== 'PIPE' && ( - - {item.material_spec || '-'} - - )} - {item.category === 'PIPE' && ( - - ์‚ฌ์–‘์— ํฌํ•จ - - )} - - {item.category === 'PIPE' ? - `${Math.round(item.bom_quantity)}mm` : - item.bom_quantity - } - - {item.category === 'PIPE' ? - `${item.pipes_count}๋ณธ (${Math.round(item.calculated_qty)}mm)` : - item.calculated_qty - } - - {item.unit} - - {item.category === 'PIPE' && ( -
-
์ ˆ๋‹จ์ˆ˜: {item.cutting_count}ํšŒ
-
์ ˆ๋‹จ์†์‹ค: {item.cutting_loss}mm
-
ํ™œ์šฉ๋ฅ : {Math.round(item.utilization_rate)}%
-
- )} - {item.category !== 'PIPE' && item.safety_factor && ( -
์—ฌ์œ ์œจ: {Math.round((item.safety_factor - 1) * 100)}%
- )} -
-
- -
+ ๐Ÿ“‹ ์ž์žฌ ๋ณด๊ธฐ + + + +
+ + + ))} + + + + {files.length === 0 && ( +
-
๐Ÿ“‹ ๊ณ„์‚ฐ ๊ทœ์น™ (์˜ฌ๋ฐ”๋ฅธ ๊ทœ์น™):
-
โ€ข PIPE: 6M ๋‹จ์œ„ ์˜ฌ๋ฆผ, ์ ˆ๋‹จ๋‹น 2mm ์†์‹ค
-
โ€ข FITTING: BOM ์ˆ˜๋Ÿ‰ ๊ทธ๋Œ€๋กœ
-
โ€ข VALVE: BOM ์ˆ˜๋Ÿ‰ ๊ทธ๋Œ€๋กœ
-
โ€ข BOLT: 5% ์—ฌ์œ ์œจ ํ›„ 4์˜ ๋ฐฐ์ˆ˜ ์˜ฌ๋ฆผ
-
โ€ข GASKET: 5์˜ ๋ฐฐ์ˆ˜ ์˜ฌ๋ฆผ
-
โ€ข INSTRUMENT: BOM ์ˆ˜๋Ÿ‰ ๊ทธ๋Œ€๋กœ
+ ์—…๋กœ๋“œ๋œ BOM ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.
-
+ )}
)}
diff --git a/frontend/src/pages/BOMWorkspacePage.jsx b/frontend/src/pages/BOMWorkspacePage.jsx new file mode 100644 index 0000000..330e95f --- /dev/null +++ b/frontend/src/pages/BOMWorkspacePage.jsx @@ -0,0 +1,720 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { api, fetchFiles, deleteFile as deleteFileApi } from '../api'; + +const BOMWorkspacePage = ({ project, onNavigate, onBack }) => { + // ์ƒํƒœ ๊ด€๋ฆฌ + const [files, setFiles] = useState([]); + const [selectedFile, setSelectedFile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [sidebarWidth, setSidebarWidth] = useState(300); + const [previewWidth, setPreviewWidth] = useState(400); + + // ์—…๋กœ๋“œ ๊ด€๋ จ ์ƒํƒœ + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + // ํŽธ์ง‘ ์ƒํƒœ + const [editingFile, setEditingFile] = useState(null); + const [editingField, setEditingField] = useState(null); + const [editValue, setEditValue] = useState(''); + + useEffect(() => { + console.log('๐Ÿ”„ ํ”„๋กœ์ ํŠธ ๋ณ€๊ฒฝ๋จ:', project); + const jobNo = project?.official_project_code || project?.job_no; + if (jobNo) { + console.log('โœ… ํ”„๋กœ์ ํŠธ ์ฝ”๋“œ ํ™•์ธ:', jobNo); + // ํ”„๋กœ์ ํŠธ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ๊ธฐ์กด ์„ ํƒ ์ดˆ๊ธฐํ™” + setSelectedFile(null); + setFiles([]); + loadFiles(); + } else { + console.warn('โš ๏ธ ํ”„๋กœ์ ํŠธ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋ฐ›์€ ํ”„๋กœ์ ํŠธ:', project); + setFiles([]); + setSelectedFile(null); + } + }, [project?.official_project_code, project?.job_no]); // ๋‘ ํ•„๋“œ ๋ชจ๋‘ ๊ฐ์‹œ + + const loadFiles = async () => { + const jobNo = project?.official_project_code || project?.job_no; + if (!jobNo) { + console.warn('ํ”„๋กœ์ ํŠธ ์ •๋ณด๊ฐ€ ์—†์–ด์„œ ํŒŒ์ผ์„ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค:', project); + return; + } + + try { + setLoading(true); + setError(''); // ์—๋Ÿฌ ์ดˆ๊ธฐํ™” + + console.log('๐Ÿ“‚ ํŒŒ์ผ ๋ชฉ๋ก ๋กœ๋”ฉ ์‹œ์ž‘:', jobNo); + + const response = await api.get('/files/', { + params: { job_no: jobNo } + }); + + console.log('๐Ÿ“‚ API ์‘๋‹ต:', response.data); + + const fileList = Array.isArray(response.data) ? response.data : response.data?.files || []; + console.log('๐Ÿ“‚ ํŒŒ์‹ฑ๋œ ํŒŒ์ผ ๋ชฉ๋ก:', fileList); + + setFiles(fileList); + + // ๊ธฐ์กด ์„ ํƒ๋œ ํŒŒ์ผ์ด ๋ชฉ๋ก์— ์žˆ๋Š”์ง€ ํ™•์ธ + if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) { + setSelectedFile(null); + } + + // ์ฒซ ๋ฒˆ์งธ ํŒŒ์ผ ์ž๋™ ์„ ํƒ (๊ธฐ์กด ์„ ํƒ์ด ์—†์„ ๋•Œ๋งŒ) + if (fileList.length > 0 && !selectedFile) { + console.log('๐Ÿ“‚ ์ฒซ ๋ฒˆ์งธ ํŒŒ์ผ ์ž๋™ ์„ ํƒ:', fileList[0].original_filename); + setSelectedFile(fileList[0]); + } + + console.log('๐Ÿ“‚ ํŒŒ์ผ ๋กœ๋”ฉ ์™„๋ฃŒ:', fileList.length, '๊ฐœ ํŒŒ์ผ'); + } catch (err) { + console.error('๐Ÿ“‚ ํŒŒ์ผ ๋กœ๋”ฉ ์‹คํŒจ:', err); + console.error('๐Ÿ“‚ ์—๋Ÿฌ ์ƒ์„ธ:', err.response?.data); + setError(`ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ${err.response?.data?.detail || err.message}`); + setFiles([]); // ์—๋Ÿฌ ์‹œ ๋นˆ ๋ฐฐ์—ด๋กœ ์ดˆ๊ธฐํ™” + } finally { + setLoading(false); + } + }; + + // ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ํ•ธ๋“ค๋Ÿฌ + const handleDragOver = (e) => { + e.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + setDragOver(false); + }; + + const handleDrop = async (e) => { + e.preventDefault(); + setDragOver(false); + + const droppedFiles = Array.from(e.dataTransfer.files); + console.log('๋“œ๋กญ๋œ ํŒŒ์ผ๋“ค:', droppedFiles.map(f => ({ name: f.name, type: f.type }))); + + const excelFiles = droppedFiles.filter(file => { + const fileName = file.name.toLowerCase(); + const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls'); + console.log(`ํŒŒ์ผ ${file.name}: Excel ์—ฌ๋ถ€ = ${isExcel}`); + return isExcel; + }); + + if (excelFiles.length > 0) { + console.log('์—…๋กœ๋“œํ•  Excel ํŒŒ์ผ๋“ค:', excelFiles.map(f => f.name)); + await uploadFiles(excelFiles); + } else { + console.log('Excel ํŒŒ์ผ์ด ์—†์Œ. ๋“œ๋กญ๋œ ํŒŒ์ผ๋“ค:', droppedFiles.map(f => f.name)); + alert(`Excel ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.\n์—…๋กœ๋“œํ•˜๋ ค๋Š” ํŒŒ์ผ: ${droppedFiles.map(f => f.name).join(', ')}`); + } + }; + + const handleFileSelect = (e) => { + const selectedFiles = Array.from(e.target.files); + console.log('์„ ํƒ๋œ ํŒŒ์ผ๋“ค:', selectedFiles.map(f => ({ name: f.name, type: f.type }))); + + const excelFiles = selectedFiles.filter(file => { + const fileName = file.name.toLowerCase(); + const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls'); + console.log(`ํŒŒ์ผ ${file.name}: Excel ์—ฌ๋ถ€ = ${isExcel}`); + return isExcel; + }); + + if (excelFiles.length > 0) { + console.log('์—…๋กœ๋“œํ•  Excel ํŒŒ์ผ๋“ค:', excelFiles.map(f => f.name)); + uploadFiles(excelFiles); + } else { + console.log('Excel ํŒŒ์ผ์ด ์—†์Œ. ์„ ํƒ๋œ ํŒŒ์ผ๋“ค:', selectedFiles.map(f => f.name)); + alert(`Excel ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.\n์„ ํƒํ•˜๋ ค๋Š” ํŒŒ์ผ: ${selectedFiles.map(f => f.name).join(', ')}`); + } + }; + + const uploadFiles = async (filesToUpload) => { + console.log('์—…๋กœ๋“œ ์‹œ์ž‘:', filesToUpload.map(f => ({ name: f.name, size: f.size, type: f.type }))); + setUploading(true); + + try { + for (const file of filesToUpload) { + console.log(`์—…๋กœ๋“œ ์ค‘: ${file.name} (${file.size} bytes, ${file.type})`); + + const jobNo = project?.official_project_code || project?.job_no; + const formData = new FormData(); + formData.append('file', file); + formData.append('job_no', jobNo); + formData.append('bom_name', file.name.replace(/\.[^/.]+$/, "")); // ํ™•์žฅ์ž ์ œ๊ฑฐ + + console.log('FormData ๋‚ด์šฉ:', { + fileName: file.name, + jobNo: jobNo, + bomName: file.name.replace(/\.[^/.]+$/, "") + }); + + const response = await api.post('/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + console.log(`์—…๋กœ๋“œ ์„ฑ๊ณต: ${file.name}`, response.data); + } + + await loadFiles(); // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + alert(`${filesToUpload.length}๊ฐœ ํŒŒ์ผ์ด ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + } catch (err) { + console.error('์—…๋กœ๋“œ ์‹คํŒจ:', err); + console.error('์—๋Ÿฌ ์ƒ์„ธ:', err.response?.data); + setError(`ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ${err.response?.data?.detail || err.message}`); + } finally { + setUploading(false); + } + }; + + // ์ธ๋ผ์ธ ํŽธ์ง‘ ํ•ธ๋“ค๋Ÿฌ + const startEdit = (file, field) => { + setEditingFile(file.id); + setEditingField(field); + setEditValue(file[field] || ''); + }; + + const saveEdit = async () => { + try { + await api.put(`/files/${editingFile}`, { + [editingField]: editValue + }); + + // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + setFiles(files.map(f => + f.id === editingFile + ? { ...f, [editingField]: editValue } + : f + )); + + if (selectedFile?.id === editingFile) { + setSelectedFile({ ...selectedFile, [editingField]: editValue }); + } + + cancelEdit(); + } catch (err) { + console.error('์ˆ˜์ • ์‹คํŒจ:', err); + alert('์ˆ˜์ •์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }; + + const cancelEdit = () => { + setEditingFile(null); + setEditingField(null); + setEditValue(''); + }; + + // ํŒŒ์ผ ์‚ญ์ œ + const handleDelete = async (fileId) => { + if (!window.confirm('์ •๋ง๋กœ ์ด ํŒŒ์ผ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await deleteFileApi(fileId); + setFiles(files.filter(f => f.id !== fileId)); + + if (selectedFile?.id === fileId) { + const remainingFiles = files.filter(f => f.id !== fileId); + setSelectedFile(remainingFiles.length > 0 ? remainingFiles[0] : null); + } + } catch (err) { + console.error('์‚ญ์ œ ์‹คํŒจ:', err); + setError('ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }; + + // ์ž์žฌ ๋ณด๊ธฐ + const viewMaterials = (file) => { + if (onNavigate) { + onNavigate('materials', { + file_id: file.id, + jobNo: file.job_no, + bomName: file.bom_name || file.original_filename, + revision: file.revision, + filename: file.original_filename + }); + } + }; + + return ( +
+ {/* ์‚ฌ์ด๋“œ๋ฐ” - ํ”„๋กœ์ ํŠธ ์ •๋ณด */} +
+ {/* ํ—ค๋” */} +
+ +

+ {project?.project_name} +

+

+ {project?.official_project_code || project?.job_no} +

+
+ + {/* ํ”„๋กœ์ ํŠธ ํ†ต๊ณ„ */} +
+
+ ํ”„๋กœ์ ํŠธ ํ˜„ํ™ฉ +
+
+
+
+ {files.length} +
+
BOM ํŒŒ์ผ
+
+
+
+ {files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)} +
+
์ด ์ž์žฌ
+
+
+
+ + {/* ์—…๋กœ๋“œ ์˜์—ญ */} +
fileInputRef.current?.click()} + > + + {uploading ? ( +
+ ๐Ÿ“ค ์—…๋กœ๋“œ ์ค‘... +
+ ) : ( +
+
๐Ÿ“
+
+ Excel ํŒŒ์ผ์„ ๋“œ๋ž˜๊ทธํ•˜๊ฑฐ๋‚˜
ํด๋ฆญํ•˜์—ฌ ์—…๋กœ๋“œ +
+
+ )} +
+
+ + {/* ๋ฉ”์ธ ํŒจ๋„ - ํŒŒ์ผ ๋ชฉ๋ก */} +
+ {/* ํˆด๋ฐ” */} +
+

+ BOM ํŒŒ์ผ ๋ชฉ๋ก ({files.length}) +

+
+ + +
+
+ + {/* ํŒŒ์ผ ๋ชฉ๋ก */} +
+ {loading ? ( +
+ ๋กœ๋”ฉ ์ค‘... +
+ ) : files.length === 0 ? ( +
+ ์—…๋กœ๋“œ๋œ BOM ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. +
+ ) : ( +
+ {files.map((file) => ( +
setSelectedFile(file)} + onMouseEnter={(e) => { + if (selectedFile?.id !== file.id) { + e.target.style.background = '#f8f9fa'; + } + }} + onMouseLeave={(e) => { + if (selectedFile?.id !== file.id) { + e.target.style.background = 'transparent'; + } + }} + > +
+
+ {/* BOM ์ด๋ฆ„ (์ธ๋ผ์ธ ํŽธ์ง‘) */} + {editingFile === file.id && editingField === 'bom_name' ? ( + setEditValue(e.target.value)} + onBlur={saveEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit(); + if (e.key === 'Escape') cancelEdit(); + }} + style={{ + border: '1px solid #4299e1', + borderRadius: '2px', + padding: '2px 4px', + fontSize: '14px', + fontWeight: '600' + }} + autoFocus + /> + ) : ( +
{ + e.stopPropagation(); + startEdit(file, 'bom_name'); + }} + > + {file.bom_name || file.original_filename} +
+ )} + +
+ {file.original_filename} โ€ข {file.parsed_count || 0}๊ฐœ ์ž์žฌ โ€ข {file.revision || 'Rev.0'} +
+
+ {new Date(file.created_at).toLocaleDateString('ko-KR')} +
+
+ +
+ + + +
+
+
+ ))} +
+ )} +
+
+ + {/* ์šฐ์ธก ํŒจ๋„ - ์ƒ์„ธ ์ •๋ณด */} + {selectedFile && ( +
+ {/* ์ƒ์„ธ ์ •๋ณด ํ—ค๋” */} +
+

+ ํŒŒ์ผ ์ƒ์„ธ ์ •๋ณด +

+
+ + {/* ์ƒ์„ธ ์ •๋ณด ๋‚ด์šฉ */} +
+
+ +
startEdit(selectedFile, 'bom_name')} + > + {selectedFile.bom_name || selectedFile.original_filename} +
+
+ +
+ +
+ {selectedFile.original_filename} +
+
+ +
+ +
+ {selectedFile.revision || 'Rev.0'} +
+
+ +
+ +
+ {selectedFile.parsed_count || 0}๊ฐœ +
+
+ +
+ +
+ {new Date(selectedFile.created_at).toLocaleString('ko-KR')} +
+
+ + {/* ์•ก์…˜ ๋ฒ„ํŠผ๋“ค */} +
+ + + + + +
+
+
+ )} + + {/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */} + {error && ( +
+ {error} + +
+ )} +
+ ); +}; + +export default BOMWorkspacePage; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 18013f7..0df5d58 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -262,3 +262,19 @@ const DashboardPage = ({ user }) => { }; export default DashboardPage; + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index e1ab9d3..237da30 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -217,3 +217,19 @@ border-color: #667eea; } } + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 373292f..715f96b 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -114,3 +114,19 @@ const LoginPage = () => { }; export default LoginPage; + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css new file mode 100644 index 0000000..3c8b437 --- /dev/null +++ b/frontend/src/pages/NewMaterialsPage.css @@ -0,0 +1,464 @@ +/* NewMaterialsPage - DevonThink ์Šคํƒ€์ผ */ + +* { + box-sizing: border-box; +} + +.materials-page { + background: #f8f9fa; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; +} + +/* ํ—ค๋” */ +.materials-header { + background: white; + border-bottom: 1px solid #e5e7eb; + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #6366f1; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.back-button:hover { + background: #5558e3; + transform: translateY(-1px); +} + +.materials-header h1 { + font-size: 20px; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.job-info { + color: #6b7280; + font-size: 14px; + font-weight: 400; +} + +.material-count { + color: #6b7280; + font-size: 14px; + background: #f3f4f6; + padding: 4px 12px; + border-radius: 12px; +} + +/* ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ */ +.category-filters { + background: white; + padding: 16px 24px; + display: flex; + gap: 8px; + align-items: center; + border-bottom: 1px solid #e5e7eb; + overflow-x: auto; +} + +.category-filters::-webkit-scrollbar { + height: 6px; +} + +.category-filters::-webkit-scrollbar-track { + background: #f3f4f6; + border-radius: 3px; +} + +.category-filters::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; +} + +.category-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: white; + border: 1px solid #e5e7eb; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + color: #4b5563; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.category-btn:hover { + background: #f9fafb; + border-color: #d1d5db; +} + +.category-btn.active { + background: #eef2ff; + border-color: #6366f1; + color: #4f46e5; +} + +.category-btn .count { + background: #f3f4f6; + padding: 2px 6px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + min-width: 20px; + text-align: center; +} + +.category-btn.active .count { + background: #6366f1; + color: white; +} + +/* ์•ก์…˜ ๋ฐ” */ +.action-bar { + background: white; + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e5e7eb; +} + +.selection-info { + font-size: 13px; + color: #6b7280; +} + +.action-buttons { + display: flex; + gap: 8px; +} + +.select-all-btn, +.export-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.select-all-btn { + background: white; + border: 1px solid #e5e7eb; + color: #374151; +} + +.select-all-btn:hover { + background: #f9fafb; + border-color: #d1d5db; +} + +.export-btn { + background: #10b981; + color: white; +} + +.export-btn:hover { + background: #059669; +} + +.export-btn:disabled { + background: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; +} + +/* ์ž์žฌ ํ…Œ์ด๋ธ” */ +.materials-grid { + background: white; + margin: 0; +} + +.detailed-grid-header { + display: grid; + grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px; + padding: 12px 24px; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ํ”Œ๋žœ์ง€ ์ „์šฉ ํ—ค๋” - 10๊ฐœ ์ปฌ๋Ÿผ */ +.detailed-grid-header.flange-header { + grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px; +} + +/* ํ”Œ๋žœ์ง€ ์ „์šฉ ํ–‰ - 10๊ฐœ ์ปฌ๋Ÿผ */ +.detailed-material-row.flange-row { + grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px; +} + +/* ํ”ผํŒ… ์ „์šฉ ํ—ค๋” - 10๊ฐœ ์ปฌ๋Ÿผ */ +.detailed-grid-header.fitting-header { + grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px; +} + +/* ํ”ผํŒ… ์ „์šฉ ํ–‰ - 10๊ฐœ ์ปฌ๋Ÿผ */ +.detailed-material-row.fitting-row { + grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px; +} + +/* ๋ฐธ๋ธŒ ์ „์šฉ ํ—ค๋” - 9๊ฐœ ์ปฌ๋Ÿผ (์Šค์ผ€์ค„ ์ œ๊ฑฐ, ํƒ€์ž… ๋„ˆ๋น„ ์ฆ๊ฐ€) */ +.detailed-grid-header.valve-header { + grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px; +} + +/* ๋ฐธ๋ธŒ ์ „์šฉ ํ–‰ - 9๊ฐœ ์ปฌ๋Ÿผ (์Šค์ผ€์ค„ ์ œ๊ฑฐ, ํƒ€์ž… ๋„ˆ๋น„ ์ฆ๊ฐ€) */ +.detailed-material-row.valve-row { + grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px; +} + +/* ๊ฐ€์Šค์ผ“ ์ „์šฉ ํ—ค๋” - 11๊ฐœ ์ปฌ๋Ÿผ (ํƒ€์ž… ์ข๊ฒŒ, ์ƒ์„ธ๋‚ด์—ญ ๋„“๊ฒŒ, ๋‘๊ป˜ ์ถ”๊ฐ€) */ +.detailed-grid-header.gasket-header { + grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px; +} + +/* ๊ฐ€์Šค์ผ“ ์ „์šฉ ํ–‰ - 11๊ฐœ ์ปฌ๋Ÿผ (ํƒ€์ž… ์ข๊ฒŒ, ์ƒ์„ธ๋‚ด์—ญ ๋„“๊ฒŒ, ๋‘๊ป˜ ์ถ”๊ฐ€) */ +.detailed-material-row.gasket-row { + grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px; +} + +/* UNKNOWN ์ „์šฉ ํ—ค๋” - 5๊ฐœ ์ปฌ๋Ÿผ */ +.detailed-grid-header.unknown-header { + grid-template-columns: 40px 100px 1fr 150px 100px; +} + +/* UNKNOWN ์ „์šฉ ํ–‰ - 5๊ฐœ ์ปฌ๋Ÿผ */ +.detailed-material-row.unknown-row { + grid-template-columns: 40px 100px 1fr 150px 100px; +} + +/* UNKNOWN ์„ค๋ช… ์…€ ์Šคํƒ€์ผ */ +.description-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.description-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailed-material-row { + display: grid; + grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px; + padding: 12px 24px; + border-bottom: 1px solid #f3f4f6; + align-items: center; + transition: background 0.15s; + font-size: 13px; +} + +.detailed-material-row:hover { + background: #fafbfc; +} + +.detailed-material-row.selected { + background: #f0f9ff; +} + +.material-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 12px; +} + +.material-cell input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +/* ํƒ€์ž… ๋ฐฐ์ง€ */ +.type-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.type-badge.pipe { + background: #10b981; + color: white; +} + +.type-badge.fitting { + background: #3b82f6; + color: white; +} + +.type-badge.valve { + background: #f59e0b; + color: white; +} + +.type-badge.flange { + background: #8b5cf6; + color: white; +} + +.type-badge.bolt { + background: #ef4444; + color: white; +} + +.type-badge.gasket { + background: #06b6d4; + color: white; +} + +.type-badge.unknown { + background: #6b7280; + color: white; +} + +.type-badge.instrument { + background: #78716c; + color: white; +} + +.type-badge.unknown { + background: #9ca3af; + color: white; +} + +/* ํ…์ŠคํŠธ ์Šคํƒ€์ผ */ +.subtype-text, +.size-text, +.material-grade { + color: #1f2937; + font-weight: 500; +} + +/* ์ž…๋ ฅ ํ•„๋“œ */ +.user-req-input { + width: 100%; + padding: 4px 8px; + border: 1px solid #e5e7eb; + border-radius: 4px; + font-size: 12px; + background: #fafbfc; + transition: all 0.2s; +} + +.user-req-input:focus { + outline: none; + border-color: #6366f1; + background: white; +} + +.user-req-input::placeholder { + color: #9ca3af; +} + +/* ์ˆ˜๋Ÿ‰ ์ •๋ณด */ +.quantity-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +/* ํ”Œ๋žœ์ง€ ์••๋ ฅ ์ •๋ณด */ +.pressure-info { + font-weight: 600; + color: #0066cc; +} + +.quantity-value { + font-weight: 600; + color: #1f2937; + font-size: 14px; +} + +.quantity-details { + font-size: 11px; + color: #9ca3af; +} + +/* ๋กœ๋”ฉ */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + background: white; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #f3f4f6; + border-top: 3px solid #6366f1; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.loading-container p { + margin-top: 16px; + color: #6b7280; + font-size: 14px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ์Šคํฌ๋กค๋ฐ” ์Šคํƒ€์ผ */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f3f4f6; +} + +::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #9ca3af; +} \ No newline at end of file diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx new file mode 100644 index 0000000..e13dd67 --- /dev/null +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -0,0 +1,971 @@ +import React, { useState, useEffect } from 'react'; +import { fetchMaterials } from '../api'; +import './NewMaterialsPage.css'; + +const NewMaterialsPage = ({ + onNavigate, + selectedProject, + fileId, + jobNo, + bomName, + revision, + filename +}) => { + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState('PIPE'); + const [selectedMaterials, setSelectedMaterials] = useState(new Set()); + const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple' + + // ์ž์žฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + useEffect(() => { + if (fileId) { + loadMaterials(fileId); + } + }, [fileId]); + + const loadMaterials = async (id) => { + try { + setLoading(true); + console.log('๐Ÿ” ์ž์žฌ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์ค‘...', { file_id: id }); + + const response = await fetchMaterials({ + file_id: parseInt(id), + limit: 10000 + }); + + if (response.data?.materials) { + const materialsData = response.data.materials; + console.log(`โœ… ${materialsData.length}๊ฐœ ์ž์žฌ ๋กœ๋“œ ์™„๋ฃŒ`); + + // ํŒŒ์ดํ”„ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ + const pipes = materialsData.filter(m => m.classified_category === 'PIPE'); + if (pipes.length > 0) { + console.log('๐Ÿ“Š ํŒŒ์ดํ”„ ๋ฐ์ดํ„ฐ ์ƒ˜ํ”Œ:', pipes[0]); + } + + setMaterials(materialsData); + } + } catch (error) { + console.error('โŒ ์ž์žฌ ๋กœ๋”ฉ ์‹คํŒจ:', error); + setMaterials([]); + } finally { + setLoading(false); + } + }; + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ž์žฌ ์ˆ˜ ๊ณ„์‚ฐ + const getCategoryCounts = () => { + const counts = {}; + materials.forEach(material => { + const category = material.classified_category || 'UNKNOWN'; + counts[category] = (counts[category] || 0) + 1; + }); + return counts; + }; + + // ํŒŒ์ดํ”„ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ•จ์ˆ˜ + const calculatePipePurchase = (material) => { + // ๋ฐฑ์—”๋“œ์—์„œ ์ด๋ฏธ ๊ทธ๋ฃนํ•‘๋œ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + const totalLength = material.pipe_details?.total_length_mm || 0; + const pipeCount = material.pipe_details?.pipe_count || material.quantity || 0; + + // ์ ˆ๋‹จ ์†์‹ค: ๊ฐ ๋‹จ๊ด€๋งˆ๋‹ค 2mm + const cuttingLoss = pipeCount * 2; + + // ์ด ํ•„์š” ๊ธธ์ด + const requiredLength = totalLength + cuttingLoss; + + // 6M(6000mm) ๋‹จ์œ„๋กœ ๊ตฌ๋งค ๋ณธ์ˆ˜ ๊ณ„์‚ฐ + const purchaseCount = Math.ceil(requiredLength / 6000); + + return { + pipeCount, // ๋‹จ๊ด€ ๊ฐœ์ˆ˜ + totalLength, // ์ด BOM ๊ธธ์ด + cuttingLoss, // ์ ˆ๋‹จ ์†์‹ค + requiredLength, // ํ•„์š” ๊ธธ์ด + purchaseCount // ๊ตฌ๋งค ๋ณธ์ˆ˜ + }; + }; + + // ์ž์žฌ ์ •๋ณด ํŒŒ์‹ฑ + const parseMaterialInfo = (material) => { + const category = material.classified_category; + + if (category === 'PIPE') { + const calc = calculatePipePurchase(material); + return { + type: 'PIPE', + subtype: material.pipe_details?.manufacturing_method || 'SMLS', + size: material.size_spec || '-', + schedule: material.pipe_details?.schedule || '-', + grade: material.material_grade || '-', + quantity: calc.purchaseCount, + unit: '๋ณธ', + details: calc + }; + } else if (category === 'FITTING') { + const fittingDetails = material.fitting_details || {}; + const fittingType = fittingDetails.fitting_type || ''; + const fittingSubtype = fittingDetails.fitting_subtype || ''; + const description = material.original_description || ''; + + // ํ”ผํŒ… ํƒ€์ž…๋ณ„ ์ƒ์„ธ ํ‘œ์‹œ + let displayType = ''; + + // CAP๊ณผ PLUG ๋จผ์ € ํ™•์ธ (fitting_type์ด ์—†์„ ์ˆ˜ ์žˆ์Œ) + if (description.toUpperCase().includes('CAP')) { + // CAP: ์—ฐ๊ฒฐ ๋ฐฉ์‹ ํ‘œ์‹œ (์˜ˆ: CAP, NPT(F), 3000LB, ASTM A105) + if (description.includes('NPT(F)')) { + displayType = 'CAP NPT(F)'; + } else if (description.includes('SW')) { + displayType = 'CAP SW'; + } else if (description.includes('BW')) { + displayType = 'CAP BW'; + } else { + displayType = 'CAP'; + } + } else if (description.toUpperCase().includes('PLUG')) { + // PLUG: ํƒ€์ž…๊ณผ ์—ฐ๊ฒฐ ๋ฐฉ์‹ ํ‘œ์‹œ (์˜ˆ: HEX.PLUG, NPT(M), 6000LB, ASTM A105) + if (description.toUpperCase().includes('HEX')) { + if (description.includes('NPT(M)')) { + displayType = 'HEX PLUG NPT(M)'; + } else { + displayType = 'HEX PLUG'; + } + } else if (description.includes('NPT(M)')) { + displayType = 'PLUG NPT(M)'; + } else if (description.includes('NPT')) { + displayType = 'PLUG NPT'; + } else { + displayType = 'PLUG'; + } + } else if (fittingType === 'NIPPLE') { + // ๋‹ˆํ”Œ: ๊ธธ์ด์™€ ๋๋‹จ ๊ฐ€๊ณต ์ •๋ณด + const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; + displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE'; + } else if (fittingType === 'ELBOW') { + // ์—˜๋ณด: ๊ฐ๋„์™€ ์—ฐ๊ฒฐ ๋ฐฉ์‹ + const angle = fittingSubtype === '90DEG' ? '90ยฐ' : fittingSubtype === '45DEG' ? '45ยฐ' : ''; + const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : ''; + displayType = `ELBOW ${angle} ${connection}`.trim(); + } else if (fittingType === 'TEE') { + // ํ‹ฐ: ํƒ€์ž…๊ณผ ์—ฐ๊ฒฐ ๋ฐฉ์‹ + const teeType = fittingSubtype === 'EQUAL' ? 'EQ' : fittingSubtype === 'REDUCING' ? 'RED' : ''; + const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : ''; + displayType = `TEE ${teeType} ${connection}`.trim(); + } else if (fittingType === 'REDUCER') { + // ๋ ˆ๋“€์„œ: ์ฝ˜์„ผํŠธ๋ฆญ/์—์„ผํŠธ๋ฆญ + const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : ''; + const sizes = fittingDetails.reduced_size ? `${material.size_spec}ร—${fittingDetails.reduced_size}` : material.size_spec; + displayType = `RED ${reducerType} ${sizes}`.trim(); + } else if (fittingType === 'SWAGE') { + // ์Šค์›จ์ด์ง€: ํƒ€์ž… ๋ช…์‹œ + const swageType = fittingSubtype || ''; + displayType = `SWAGE ${swageType}`.trim(); + } else if (!displayType) { + // ๊ธฐํƒ€ ํ”ผํŒ… ํƒ€์ž… + displayType = fittingType || 'FITTING'; + } + + // ์••๋ ฅ ๋“ฑ๊ธ‰๊ณผ ์Šค์ผ€์ค„ ์ถ”์ถœ + let pressure = '-'; + let schedule = '-'; + + // ์••๋ ฅ ๋“ฑ๊ธ‰ ์ฐพ๊ธฐ (3000LB, 6000LB ๋“ฑ) + const pressureMatch = description.match(/(\d+)LB/i); + if (pressureMatch) { + pressure = `${pressureMatch[1]}LB`; + } + + // ์Šค์ผ€์ค„ ์ฐพ๊ธฐ + if (description.includes('SCH')) { + const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); + if (schMatch) { + schedule = `SCH ${schMatch[1]}`; + } + } + + return { + type: 'FITTING', + subtype: displayType, + size: material.size_spec || '-', + pressure: pressure, + schedule: schedule, + grade: material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '๊ฐœ', + isFitting: true + }; + } else if (category === 'VALVE') { + const valveDetails = material.valve_details || {}; + const description = material.original_description || ''; + + // ๋ฐธ๋ธŒ ํƒ€์ž… ํŒŒ์‹ฑ (GATE, BALL, CHECK, GLOBE ๋“ฑ) + let valveType = valveDetails.valve_type || ''; + if (!valveType && description) { + if (description.includes('GATE')) valveType = 'GATE'; + else if (description.includes('BALL')) valveType = 'BALL'; + else if (description.includes('CHECK')) valveType = 'CHECK'; + else if (description.includes('GLOBE')) valveType = 'GLOBE'; + else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY'; + } + + // ์—ฐ๊ฒฐ ๋ฐฉ์‹ ํŒŒ์‹ฑ (FLG, SW, THRD ๋“ฑ) + let connectionType = ''; + if (description.includes('FLG')) { + connectionType = 'FLG'; + } else if (description.includes('SW X THRD')) { + connectionType = 'SWร—THRD'; + } else if (description.includes('SW')) { + connectionType = 'SW'; + } else if (description.includes('THRD')) { + connectionType = 'THRD'; + } else if (description.includes('BW')) { + connectionType = 'BW'; + } + + // ์••๋ ฅ ๋“ฑ๊ธ‰ ํŒŒ์‹ฑ + let pressure = '-'; + const pressureMatch = description.match(/(\d+)LB/i); + if (pressureMatch) { + pressure = `${pressureMatch[1]}LB`; + } + + // ์Šค์ผ€์ค„์€ ๋ฐธ๋ธŒ์—๋Š” ์ผ๋ฐ˜์ ์œผ๋กœ ์—†์Œ + let schedule = '-'; + + return { + type: 'VALVE', + valveType: valveType, + connectionType: connectionType, + size: material.size_spec || '-', + pressure: pressure, + schedule: schedule, + grade: material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '๊ฐœ', + isValve: true + }; + } else if (category === 'FLANGE') { + // ํ”Œ๋žœ์ง€ ํƒ€์ž… ๋ณ€ํ™˜ + const flangeTypeMap = { + 'WELD_NECK': 'WN', + 'SLIP_ON': 'SO', + 'BLIND': 'BL', + 'SOCKET_WELD': 'SW', + 'LAP_JOINT': 'LJ', + 'THREADED': 'TH', + 'ORIFICE': 'ORIFICE' // ์˜ค๋ฆฌํ”ผ์Šค๋Š” ํ’€๋„ค์ž„ ํ‘œ์‹œ + }; + const flangeType = material.flange_details?.flange_type; + const displayType = flangeTypeMap[flangeType] || flangeType || '-'; + + // ์›๋ณธ ์„ค๋ช…์—์„œ ์Šค์ผ€์ค„ ์ถ”์ถœ + let schedule = '-'; + const description = material.original_description || ''; + + // SCH 40, SCH 80 ๋“ฑ์˜ ํŒจํ„ด ์ฐพ๊ธฐ + if (description.toUpperCase().includes('SCH')) { + const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); + if (schMatch && schMatch[1]) { + schedule = `SCH ${schMatch[1]}`; + } + } + + return { + type: 'FLANGE', + subtype: displayType, + size: material.size_spec || '-', + pressure: material.flange_details?.pressure_rating || '-', + schedule: schedule, + grade: material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '๊ฐœ', + isFlange: true // ํ”Œ๋žœ์ง€ ๊ตฌ๋ถ„์šฉ ํ”Œ๋ž˜๊ทธ + }; + } else if (category === 'BOLT') { + const qty = Math.round(material.quantity || 0); + const safetyQty = Math.ceil(qty * 1.05); // 5% ์—ฌ์œ ์œจ + const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4์˜ ๋ฐฐ์ˆ˜ + return { + type: 'BOLT', + subtype: material.bolt_details?.bolt_type || '-', + size: material.size_spec || '-', + schedule: material.bolt_details?.length || '-', + grade: material.material_grade || '-', + quantity: purchaseQty, + unit: 'SETS' + }; + } else if (category === 'GASKET') { + const qty = Math.round(material.quantity || 0); + const purchaseQty = Math.ceil(qty / 5) * 5; // 5์˜ ๋ฐฐ์ˆ˜ + + // original_description์—์„œ ์žฌ์งˆ ์ •๋ณด ํŒŒ์‹ฑ + const description = material.original_description || ''; + let materialStructure = '-'; // H/F/I/O ๋ถ€๋ถ„ + let materialDetail = '-'; // SS304/GRAPHITE/CS/CS ๋ถ€๋ถ„ + + // H/F/I/O์™€ ์žฌ์งˆ ์ƒ์„ธ ์ •๋ณด ์ถ”์ถœ + const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/); + if (materialMatch) { + materialStructure = 'H/F/I/O'; + materialDetail = materialMatch[1].trim(); + // ๋‘๊ป˜ ์ •๋ณด ์ œ๊ฑฐ (๋ณ„๋„ ์ถ”์ถœ) + materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim(); + } + + // ์••๋ ฅ ์ •๋ณด ์ถ”์ถœ + let pressure = '-'; + const pressureMatch = description.match(/(\d+LB)/); + if (pressureMatch) { + pressure = pressureMatch[1]; + } + + // ๋‘๊ป˜ ์ •๋ณด ์ถ”์ถœ + let thickness = '-'; + const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i); + if (thicknessMatch) { + thickness = thicknessMatch[1] + 'mm'; + } + + return { + type: 'GASKET', + subtype: 'SWG', // ํ•ญ์ƒ SWG๋กœ ํ‘œ์‹œ + size: material.size_spec || '-', + pressure: pressure, + materialStructure: materialStructure, + materialDetail: materialDetail, + thickness: thickness, + quantity: purchaseQty, + unit: '๊ฐœ', + isGasket: true + }; + } else if (category === 'UNKNOWN') { + return { + type: 'UNKNOWN', + description: material.original_description || 'Unknown Item', + quantity: Math.round(material.quantity || 0), + unit: '๊ฐœ', + isUnknown: true + }; + } else { + return { + type: category || 'UNKNOWN', + subtype: '-', + size: material.size_spec || '-', + schedule: '-', + grade: material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '๊ฐœ' + }; + } + }; + + // ํ•„ํ„ฐ๋ง๋œ ์ž์žฌ ๋ชฉ๋ก + const filteredMaterials = materials.filter(material => { + return material.classified_category === selectedCategory; + }); + + // ์นดํ…Œ๊ณ ๋ฆฌ ์ƒ‰์ƒ (์ œ๊ฑฐ - CSS์—์„œ ์ฒ˜๋ฆฌ) + + // ์ „์ฒด ์„ ํƒ/ํ•ด์ œ + const toggleAllSelection = () => { + if (selectedMaterials.size === filteredMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(filteredMaterials.map(m => m.id))); + } + }; + + // ๊ฐœ๋ณ„ ์„ ํƒ + const toggleMaterialSelection = (id) => { + const newSelection = new Set(selectedMaterials); + if (newSelection.has(id)) { + newSelection.delete(id); + } else { + newSelection.add(id); + } + setSelectedMaterials(newSelection); + }; + + // ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ + const exportToExcel = () => { + const selectedData = materials.filter(m => selectedMaterials.has(m.id)); + console.log('๐Ÿ“Š ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ:', selectedData.length, '๊ฐœ ํ•ญ๋ชฉ'); + alert(`${selectedData.length}๊ฐœ ํ•ญ๋ชฉ์„ ์—‘์…€๋กœ ๋‚ด๋ณด๋ƒ…๋‹ˆ๋‹ค.`); + }; + + if (loading) { + return ( +
+
+

์ž์žฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ ); + } + + const categoryCounts = getCategoryCounts(); + + return ( +
+ {/* ํ—ค๋” */} +
+
+ +

์ž์žฌ ๋ชฉ๋ก

+ {jobNo && ( + + {jobNo} {revision && `(${revision})`} + + )} +
+
+ + ์ด {materials.length}๊ฐœ ์ž์žฌ + +
+
+ + {/* ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ */} +
+ {Object.entries(categoryCounts).map(([category, count]) => ( + + ))} +
+ + {/* ์•ก์…˜ ๋ฐ” */} +
+
+ {selectedMaterials.size}๊ฐœ ์ค‘ {filteredMaterials.length}๊ฐœ ์„ ํƒ +
+
+ + +
+
+ + {/* ์ž์žฌ ํ…Œ์ด๋ธ” */} +
+ {/* ํ”Œ๋žœ์ง€ ์ „์šฉ ํ—ค๋” */} + {selectedCategory === 'FLANGE' ? ( +
+
์„ ํƒ
+
์ข…๋ฅ˜
+
ํƒ€์ž…
+
ํฌ๊ธฐ
+
์••๋ ฅ(ํŒŒ์šด๋“œ)
+
์Šค์ผ€์ค„
+
์žฌ์งˆ
+
์ถ”๊ฐ€์š”๊ตฌ
+
์‚ฌ์šฉ์ž์š”๊ตฌ
+
์ˆ˜๋Ÿ‰
+
+ ) : selectedCategory === 'FITTING' ? ( +
+
์„ ํƒ
+
์ข…๋ฅ˜
+
ํƒ€์ž…/์ƒ์„ธ
+
ํฌ๊ธฐ
+
์••๋ ฅ
+
์Šค์ผ€์ค„
+
์žฌ์งˆ
+
์ถ”๊ฐ€์š”๊ตฌ
+
์‚ฌ์šฉ์ž์š”๊ตฌ
+
์ˆ˜๋Ÿ‰
+
+ ) : selectedCategory === 'GASKET' ? ( +
+
์„ ํƒ
+
์ข…๋ฅ˜
+
ํƒ€์ž…
+
ํฌ๊ธฐ
+
์••๋ ฅ
+
์žฌ์งˆ
+
์ƒ์„ธ๋‚ด์—ญ
+
๋‘๊ป˜
+
์ถ”๊ฐ€์š”๊ตฌ
+
์‚ฌ์šฉ์ž์š”๊ตฌ
+
์ˆ˜๋Ÿ‰
+
+ ) : selectedCategory === 'VALVE' ? ( +
+
์„ ํƒ
+
ํƒ€์ž…
+
์—ฐ๊ฒฐ๋ฐฉ์‹
+
ํฌ๊ธฐ
+
์••๋ ฅ
+
์žฌ์งˆ
+
์ถ”๊ฐ€์š”๊ตฌ
+
์‚ฌ์šฉ์ž์š”๊ตฌ
+
์ˆ˜๋Ÿ‰
+
+ ) : selectedCategory === 'UNKNOWN' ? ( +
+
์„ ํƒ
+
์ข…๋ฅ˜
+
์„ค๋ช…
+
์‚ฌ์šฉ์ž์š”๊ตฌ
+
์ˆ˜๋Ÿ‰
+
+ ) : ( +
+
์„ ํƒ
+
์ข…๋ฅ˜
+
ํƒ€์ž…
+
ํฌ๊ธฐ
+
์Šค์ผ€์ค„
+
์žฌ์งˆ
+
์ถ”๊ฐ€์š”๊ตฌ
+
์‚ฌ์šฉ์ž์š”๊ตฌ
+
์ˆ˜๋Ÿ‰
+
+ )} + + {filteredMaterials.map((material) => { + const info = parseMaterialInfo(material); + + // ํ”ผํŒ…์ธ ๊ฒฝ์šฐ 10๊ฐœ ์ปฌ๋Ÿผ + if (info.isFitting) { + return ( +
+ {/* ์„ ํƒ */} +
+ toggleMaterialSelection(material.id)} + /> +
+ + {/* ์ข…๋ฅ˜ */} +
+ + {info.type} + +
+ + {/* ํƒ€์ž…/์ƒ์„ธ */} +
+ {info.subtype} +
+ + {/* ํฌ๊ธฐ */} +
+ {info.size} +
+ + {/* ์••๋ ฅ */} +
+ {info.pressure} +
+ + {/* ์Šค์ผ€์ค„ */} +
+ {info.schedule} +
+ + {/* ์žฌ์งˆ */} +
+ {info.grade} +
+ + {/* ์ถ”๊ฐ€์š”๊ตฌ */} +
+ - +
+ + {/* ์‚ฌ์šฉ์ž์š”๊ตฌ */} +
+ +
+ + {/* ์ˆ˜๋Ÿ‰ */} +
+
+ + {info.quantity} {info.unit} + +
+
+
+ ); + } + + // ๋ฐธ๋ธŒ์ธ ๊ฒฝ์šฐ 10๊ฐœ ์ปฌ๋Ÿผ + if (info.isValve) { + return ( +
+ {/* ์„ ํƒ */} +
+ toggleMaterialSelection(material.id)} + /> +
+ + {/* ํƒ€์ž… */} +
+ {info.valveType} +
+ + {/* ์—ฐ๊ฒฐ๋ฐฉ์‹ */} +
+ {info.connectionType} +
+ + {/* ํฌ๊ธฐ */} +
+ {info.size} +
+ + {/* ์••๋ ฅ */} +
+ {info.pressure} +
+ + {/* ์žฌ์งˆ */} +
+ {info.grade} +
+ + {/* ์ถ”๊ฐ€์š”๊ตฌ */} +
+ - +
+ + {/* ์‚ฌ์šฉ์ž์š”๊ตฌ */} +
+ +
+ + {/* ์ˆ˜๋Ÿ‰ */} +
+
+ + {info.quantity} {info.unit} + +
+
+
+ ); + } + + // ํ”Œ๋žœ์ง€์ธ ๊ฒฝ์šฐ 10๊ฐœ ์ปฌ๋Ÿผ + if (info.isFlange) { + return ( +
+ {/* ์„ ํƒ */} +
+ toggleMaterialSelection(material.id)} + /> +
+ + {/* ์ข…๋ฅ˜ */} +
+ + {info.type} + +
+ + {/* ํƒ€์ž… */} +
+ {info.subtype} +
+ + {/* ํฌ๊ธฐ */} +
+ {info.size} +
+ + {/* ์••๋ ฅ(ํŒŒ์šด๋“œ) */} +
+ {info.pressure} +
+ + {/* ์Šค์ผ€์ค„ */} +
+ {info.schedule} +
+ + {/* ์žฌ์งˆ */} +
+ {info.grade} +
+ + {/* ์ถ”๊ฐ€์š”๊ตฌ */} +
+ - +
+ + {/* ์‚ฌ์šฉ์ž์š”๊ตฌ */} +
+ +
+ + {/* ์ˆ˜๋Ÿ‰ */} +
+
+ + {info.quantity} {info.unit} + +
+
+
+ ); + } + + // UNKNOWN์ธ ๊ฒฝ์šฐ 5๊ฐœ ์ปฌ๋Ÿผ + if (info.isUnknown) { + return ( +
+ {/* ์„ ํƒ */} +
+ toggleMaterialSelection(material.id)} + /> +
+ + {/* ์ข…๋ฅ˜ */} +
+ + {info.type} + +
+ + {/* ์„ค๋ช… */} +
+ + {info.description} + +
+ + {/* ์‚ฌ์šฉ์ž์š”๊ตฌ */} +
+ +
+ + {/* ์ˆ˜๋Ÿ‰ */} +
+
+ + {info.quantity} {info.unit} + +
+
+
+ ); + } + + // ๊ฐ€์Šค์ผ“์ธ ๊ฒฝ์šฐ 11๊ฐœ ์ปฌ๋Ÿผ + if (info.isGasket) { + return ( +
+ {/* ์„ ํƒ */} +
+ toggleMaterialSelection(material.id)} + /> +
+ + {/* ์ข…๋ฅ˜ */} +
+ + {info.type} + +
+ + {/* ํƒ€์ž… */} +
+ {info.subtype} +
+ + {/* ํฌ๊ธฐ */} +
+ {info.size} +
+ + {/* ์••๋ ฅ */} +
+ {info.pressure} +
+ + {/* ์žฌ์งˆ */} +
+ {info.materialStructure} +
+ + {/* ์ƒ์„ธ๋‚ด์—ญ */} +
+ {info.materialDetail} +
+ + {/* ๋‘๊ป˜ */} +
+ {info.thickness} +
+ + {/* ์ถ”๊ฐ€์š”๊ตฌ */} +
+ - +
+ + {/* ์‚ฌ์šฉ์ž์š”๊ตฌ */} +
+ +
+ + {/* ์ˆ˜๋Ÿ‰ */} +
+
+ + {info.quantity} {info.unit} + +
+
+
+ ); + } + + // ํ”Œ๋žœ์ง€๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ 9๊ฐœ ์ปฌ๋Ÿผ + return ( +
+ {/* ์„ ํƒ */} +
+ toggleMaterialSelection(material.id)} + /> +
+ + {/* ์ข…๋ฅ˜ */} +
+ + {info.type} + +
+ + {/* ํƒ€์ž… */} +
+ {info.subtype} +
+ + {/* ํฌ๊ธฐ */} +
+ {info.size} +
+ + {/* ์Šค์ผ€์ค„ */} +
+ {info.schedule} +
+ + {/* ์žฌ์งˆ */} +
+ {info.grade} +
+ + {/* ์ถ”๊ฐ€์š”๊ตฌ */} +
+ - +
+ + {/* ์‚ฌ์šฉ์ž์š”๊ตฌ */} +
+ +
+ + {/* ์ˆ˜๋Ÿ‰ */} +
+
+ + {info.quantity} {info.unit} + + {info.type === 'PIPE' && info.details && ( +
+ + ๋‹จ๊ด€ {info.details.pipeCount}๊ฐœ โ†’ {Math.round(info.details.totalLength)}mm + +
+ )} +
+
+
+ ); + })} +
+
+ ); +}; + +export default NewMaterialsPage; diff --git a/frontend/src/pages/ProjectWorkspacePage.jsx b/frontend/src/pages/ProjectWorkspacePage.jsx new file mode 100644 index 0000000..87c2d4d --- /dev/null +++ b/frontend/src/pages/ProjectWorkspacePage.jsx @@ -0,0 +1,358 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../api'; + +const ProjectWorkspacePage = ({ project, user, onNavigate, onBackToDashboard }) => { + const [projectStats, setProjectStats] = useState(null); + const [recentFiles, setRecentFiles] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (project) { + loadProjectData(); + } + }, [project]); + + const loadProjectData = async () => { + try { + // ์‹ค์ œ ํŒŒ์ผ ๋ฐ์ดํ„ฐ๋งŒ ๋กœ๋“œ + const filesResponse = await api.get(`/files?job_no=${project.job_no}&limit=5`); + + if (filesResponse.data && Array.isArray(filesResponse.data)) { + setRecentFiles(filesResponse.data); + + // ํŒŒ์ผ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ํ†ต๊ณ„ ๊ณ„์‚ฐ + const stats = { + totalFiles: filesResponse.data.length, + totalMaterials: filesResponse.data.reduce((sum, file) => sum + (file.parsed_count || 0), 0), + classifiedMaterials: 0, // API์—์„œ ๋ถ„๋ฅ˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์™€์•ผ ํ•จ + pendingVerification: 0, // API์—์„œ ๊ฒ€์ฆ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์™€์•ผ ํ•จ + }; + setProjectStats(stats); + } else { + setRecentFiles([]); + setProjectStats({ + totalFiles: 0, + totalMaterials: 0, + classifiedMaterials: 0, + pendingVerification: 0 + }); + } + } catch (error) { + console.error('ํ”„๋กœ์ ํŠธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์‹คํŒจ:', error); + setRecentFiles([]); + setProjectStats({ + totalFiles: 0, + totalMaterials: 0, + classifiedMaterials: 0, + pendingVerification: 0 + }); + } finally { + setLoading(false); + } + }; + + const getAvailableActions = () => { + const userRole = user?.role || 'user'; + + const allActions = { + // BOM ๊ด€๋ฆฌ (ํ†ตํ•ฉ) + 'bom-management': { + title: 'BOM ๊ด€๋ฆฌ', + description: 'BOM ํŒŒ์ผ ์—…๋กœ๋“œ, ๊ด€๋ฆฌ ๋ฐ ๋ฆฌ๋น„์ „ ์ถ”์ ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค', + icon: '๐Ÿ“‹', + color: '#667eea', + roles: ['designer', 'manager', 'admin'], + path: 'bom-status' + }, + // ์ž์žฌ ๊ด€๋ฆฌ + 'material-management': { + title: '์ž์žฌ ๊ด€๋ฆฌ', + description: '์ž์žฌ ๋ถ„๋ฅ˜, ๊ฒ€์ฆ ๋ฐ ๊ตฌ๋งค ๊ด€๋ฆฌ๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค', + icon: '๐Ÿ”ง', + color: '#48bb78', + roles: ['designer', 'purchaser', 'manager', 'admin'], + path: 'materials' + } + }; + + // ์‚ฌ์šฉ์ž ๊ถŒํ•œ์— ๋”ฐ๋ผ ํ•„ํ„ฐ๋ง + return Object.entries(allActions).filter(([key, action]) => + action.roles.includes(userRole) + ); + }; + + const handleActionClick = (actionPath) => { + switch (actionPath) { + case 'bom-management': + onNavigate('bom-status', { + job_no: project.job_no, + job_name: project.project_name + }); + break; + case 'material-management': + onNavigate('materials', { + job_no: project.job_no, + job_name: project.project_name + }); + break; + default: + alert(`${actionPath} ๊ธฐ๋Šฅ์€ ๊ณง ๊ตฌํ˜„๋  ์˜ˆ์ •์ž…๋‹ˆ๋‹ค.`); + } + }; + + if (loading) { + return ( +
+
ํ”„๋กœ์ ํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
+
+ ); + } + + const availableActions = getAvailableActions(); + + return ( +
+
+ {/* ํ—ค๋” */} +
+ +
+

+ {project.project_name} +

+
+ {project.job_no} โ€ข ์ง„ํ–‰๋ฅ : {project.progress || 0}% +
+
+
+ + {/* ํ”„๋กœ์ ํŠธ ํ†ต๊ณ„ */} +
+ {[ + { label: 'BOM ํŒŒ์ผ', value: projectStats.totalFiles, icon: '๐Ÿ“„', color: '#667eea' }, + { label: '์ „์ฒด ์ž์žฌ', value: projectStats.totalMaterials, icon: '๐Ÿ“ฆ', color: '#48bb78' }, + { label: '๋ถ„๋ฅ˜ ์™„๋ฃŒ', value: projectStats.classifiedMaterials, icon: 'โœ…', color: '#38b2ac' }, + { label: '๊ฒ€์ฆ ๋Œ€๊ธฐ', value: projectStats.pendingVerification, icon: 'โณ', color: '#ed8936' } + ].map((stat, index) => ( +
+
+
+
+ {stat.label} +
+
+ {stat.value} +
+
+
+ {stat.icon} +
+
+
+ ))} +
+ + {/* ์—…๋ฌด ๋ฉ”๋‰ด */} +
+

+ ๐Ÿš€ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—…๋ฌด +

+ +
+ {availableActions.map(([key, action]) => ( +
handleActionClick(key)} + style={{ + padding: '20px', + border: '1px solid #e2e8f0', + borderRadius: '12px', + cursor: 'pointer', + transition: 'all 0.2s ease', + background: 'white' + }} + onMouseEnter={(e) => { + e.target.style.borderColor = action.color; + e.target.style.boxShadow = `0 4px 12px ${action.color}20`; + e.target.style.transform = 'translateY(-2px)'; + }} + onMouseLeave={(e) => { + e.target.style.borderColor = '#e2e8f0'; + e.target.style.boxShadow = 'none'; + e.target.style.transform = 'translateY(0)'; + }} + > +
+
+ {action.icon} +
+
+

+ {action.title} +

+

+ {action.description} +

+
+
+
+ ))} +
+
+ + {/* ์ตœ๊ทผ ํ™œ๋™ (์˜ต์…˜) */} + {recentFiles.length > 0 && ( +
+

+ ๐Ÿ“ ์ตœ๊ทผ BOM ํŒŒ์ผ +

+ +
+ {recentFiles.map((file, index) => ( +
+
+
+ {file.original_filename || file.filename} +
+
+ {file.revision} โ€ข {file.uploaded_by || '์‹œ์Šคํ…œ'} โ€ข {file.parsed_count || 0}๊ฐœ ์ž์žฌ +
+
+ +
+ ))} +
+
+ )} +
+
+ ); +}; + +export default ProjectWorkspacePage; diff --git a/frontend/src/pages/ProjectsPage.jsx b/frontend/src/pages/ProjectsPage.jsx index 627e3e8..30b9275 100644 --- a/frontend/src/pages/ProjectsPage.jsx +++ b/frontend/src/pages/ProjectsPage.jsx @@ -386,3 +386,19 @@ const ProjectsPage = ({ user }) => { }; export default ProjectsPage; + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/PurchaseConfirmationPage.jsx b/frontend/src/pages/PurchaseConfirmationPage.jsx deleted file mode 100644 index f28bdc6..0000000 --- a/frontend/src/pages/PurchaseConfirmationPage.jsx +++ /dev/null @@ -1,446 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { - Box, - Card, - CardContent, - Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Button, - TextField, - Chip, - Alert, - IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Grid, - Divider -} from '@mui/material'; -import { - ArrowBack, - Edit, - Check, - Close, - ShoppingCart, - CompareArrows, - Warning -} from '@mui/icons-material'; -import { api } from '../api'; - -const PurchaseConfirmationPage = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [purchaseItems, setPurchaseItems] = useState([]); - const [revisionComparison, setRevisionComparison] = useState(null); - const [loading, setLoading] = useState(true); - const [editingItem, setEditingItem] = useState(null); - const [confirmDialog, setConfirmDialog] = useState(false); - - // URL์—์„œ job_no, revision ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ - const searchParams = new URLSearchParams(location.search); - const jobNo = searchParams.get('job_no'); - const revision = searchParams.get('revision'); - const filename = searchParams.get('filename'); - const previousRevision = searchParams.get('prev_revision'); - - useEffect(() => { - if (jobNo && revision) { - loadPurchaseItems(); - if (previousRevision) { - loadRevisionComparison(); - } - } - }, [jobNo, revision, previousRevision]); - - const loadPurchaseItems = async () => { - try { - setLoading(true); - const response = await api.get('/purchase/items/calculate', { - params: { job_no: jobNo, revision: revision } - }); - setPurchaseItems(response.data.items || []); - } catch (error) { - console.error('๊ตฌ๋งค ํ’ˆ๋ชฉ ๋กœ๋”ฉ ์‹คํŒจ:', error); - } finally { - setLoading(false); - } - }; - - const loadRevisionComparison = async () => { - try { - const response = await api.get('/purchase/revision-diff', { - params: { - job_no: jobNo, - current_revision: revision, - previous_revision: previousRevision - } - }); - setRevisionComparison(response.data.comparison); - } catch (error) { - console.error('๋ฆฌ๋น„์ „ ๋น„๊ต ์‹คํŒจ:', error); - } - }; - - const updateItemQuantity = async (itemId, field, value) => { - try { - await api.patch(`/purchase/items/${itemId}`, { - [field]: parseFloat(value) - }); - - // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ - setPurchaseItems(prev => - prev.map(item => - item.id === itemId - ? { ...item, [field]: parseFloat(value) } - : item - ) - ); - - setEditingItem(null); - } catch (error) { - console.error('์ˆ˜๋Ÿ‰ ์—…๋ฐ์ดํŠธ ์‹คํŒจ:', error); - } - }; - - const confirmPurchase = async () => { - try { - const response = await api.post('/purchase/orders/create', { - job_no: jobNo, - revision: revision, - items: purchaseItems.map(item => ({ - purchase_item_id: item.id, - ordered_quantity: item.calculated_qty, - required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30์ผ ํ›„ - })) - }); - - alert('๊ตฌ๋งค ์ฃผ๋ฌธ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!'); - navigate('/materials', { - state: { message: '๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ์™„๋ฃŒ' } - }); - } catch (error) { - console.error('๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', error); - alert('๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } - }; - - const getCategoryColor = (category) => { - const colors = { - 'PIPE': 'primary', - 'FITTING': 'secondary', - 'VALVE': 'success', - 'FLANGE': 'warning', - 'BOLT': 'info', - 'GASKET': 'error', - 'INSTRUMENT': 'purple' - }; - return colors[category] || 'default'; - }; - - const formatPipeInfo = (item) => { - if (item.category !== 'PIPE') return null; - - return ( - - - ์ ˆ๋‹จ์†์‹ค: {item.cutting_loss || 0}mm | - ๊ตฌ๋งค: {item.pipes_count || 0}๋ณธ | - ์—ฌ์œ ๋ถ„: {item.waste_length || 0}mm - - - ); - }; - - const formatBoltInfo = (item) => { - if (item.category !== 'BOLT') return null; - - // ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ์ •๋ณด (๋ฐฑ์—”๋“œ์—์„œ ์ œ๊ณต๋˜์–ด์•ผ ํ•จ) - const specialApplications = item.special_applications || {}; - const psvCount = specialApplications.PSV || 0; - const ltCount = specialApplications.LT || 0; - const ckCount = specialApplications.CK || 0; - const oriCount = specialApplications.ORI || 0; - - return ( - - - ๋ถ„์ˆ˜ ์‚ฌ์ด์ฆˆ: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} | - ํ‘œ๋ฉด์ฒ˜๋ฆฌ: {item.surface_treatment || '์—†์Œ'} - - - {/* ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ์ •๋ณด */} - - - ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ํ˜„ํ™ฉ: - - - - 0 ? "error.main" : "textSecondary"}> - PSV์šฉ: {psvCount}๊ฐœ - - - - 0 ? "warning.main" : "textSecondary"}> - ์ €์˜จ์šฉ: {ltCount}๊ฐœ - - - - 0 ? "info.main" : "textSecondary"}> - ์ฒดํฌ๋ฐธ๋ธŒ์šฉ: {ckCount}๊ฐœ - - - - 0 ? "secondary.main" : "textSecondary"}> - ์˜ค๋ฆฌํ”ผ์Šค์šฉ: {oriCount}๊ฐœ - - - - {(psvCount + ltCount + ckCount + oriCount) === 0 && ( - - ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ์—†์Œ (์ผ๋ฐ˜ ๋ณผํŠธ๋งŒ ํฌํ•จ) - - )} - - - ); - }; - - return ( - - {/* ํ—ค๋” */} - - navigate(-1)} sx={{ mr: 2 }}> - - - - - ๐Ÿ›’ ๊ตฌ๋งค ํ™•์ • - - - Job: {jobNo} | {filename} | {revision} - - - - - - {/* ๋ฆฌ๋น„์ „ ๋น„๊ต ์•Œ๋ฆผ */} - {revisionComparison && ( - } - > - - ๋ฆฌ๋น„์ „ ๋ณ€๊ฒฝ์‚ฌํ•ญ: {revisionComparison.summary} - - {revisionComparison.additional_items && ( - - ์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š”: {revisionComparison.additional_items}๊ฐœ ํ’ˆ๋ชฉ - - )} - - )} - - {/* ๊ตฌ๋งค ํ’ˆ๋ชฉ ํ…Œ์ด๋ธ” */} - {purchaseItems.map(item => ( - - - - - - {item.specification} - - {item.is_additional && ( - - )} - - - - {/* BOM ์ˆ˜๋Ÿ‰ */} - - - BOM ํ•„์š”๋Ÿ‰ - - - {item.bom_quantity} {item.unit} - - {formatPipeInfo(item)} - {formatBoltInfo(item)} - - - {/* ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ */} - - - ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ - - {editingItem === item.id ? ( - - - setPurchaseItems(prev => - prev.map(i => - i.id === item.id - ? { ...i, calculated_qty: parseFloat(e.target.value) || 0 } - : i - ) - ) - } - size="small" - type="number" - sx={{ width: 100 }} - /> - updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)} - > - - - setEditingItem(null)} - > - - - - ) : ( - - - {item.calculated_qty} {item.unit} - - setEditingItem(item.id)} - > - - - - )} - - - {/* ์ด๋ฏธ ๊ตฌ๋งคํ•œ ์ˆ˜๋Ÿ‰ */} - {previousRevision && ( - - - ๊ธฐ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ - - - {item.purchased_quantity || 0} {item.unit} - - - )} - - {/* ์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š”๋Ÿ‰ */} - {previousRevision && ( - - - ์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š” - - 0 ? "error" : "success"} - > - {Math.max(item.additional_needed || 0, 0)} {item.unit} - - - )} - - - {/* ์—ฌ์œ ์œจ ๋ฐ ์ตœ์†Œ ์ฃผ๋ฌธ ์ •๋ณด */} - - - - - ์—ฌ์œ ์œจ - - - {((item.safety_factor || 1) - 1) * 100}% - - - - - ์ตœ์†Œ ์ฃผ๋ฌธ - - - {item.min_order_qty || 0} {item.unit} - - - - - ์˜ˆ์ƒ ์—ฌ์œ ๋ถ„ - - - {(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit} - - - - - ํ™œ์šฉ๋ฅ  - - - {((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}% - - - - - - - ))} - - {/* ๊ตฌ๋งค ์ฃผ๋ฌธ ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ */} - setConfirmDialog(false)}> - ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ํ™•์ธ - - - ์ด {purchaseItems.length}๊ฐœ ํ’ˆ๋ชฉ์— ๋Œ€ํ•œ ๊ตฌ๋งค ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? - - - {revisionComparison && revisionComparison.has_changes && ( - - ๋ฆฌ๋น„์ „ ๋ณ€๊ฒฝ์œผ๋กœ ์ธํ•œ ์ถ”๊ฐ€ ๊ตฌ๋งค๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. - - )} - - - ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ํ›„์—๋Š” ์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ์ด ์ œํ•œ๋ฉ๋‹ˆ๋‹ค. - - - - - - - - - ); -}; - -export default PurchaseConfirmationPage; \ No newline at end of file diff --git a/frontend/src/pages/SimpleMaterialsPage.jsx b/frontend/src/pages/SimpleMaterialsPage.jsx deleted file mode 100644 index e9b5c5e..0000000 --- a/frontend/src/pages/SimpleMaterialsPage.jsx +++ /dev/null @@ -1,742 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../api'; - -const SimpleMaterialsPage = ({ fileId, jobNo: propJobNo, bomName: propBomName, revision: propRevision, filename: propFilename, onNavigate }) => { - const [materials, setMaterials] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [fileName, setFileName] = useState(''); - const [jobNo, setJobNo] = useState(''); - const [bomName, setBomName] = useState(''); - const [currentRevision, setCurrentRevision] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [filterCategory, setFilterCategory] = useState('all'); - const [filterConfidence, setFilterConfidence] = useState('all'); - const [showPurchaseCalculation, setShowPurchaseCalculation] = useState(false); - const [purchaseData, setPurchaseData] = useState(null); - const [calculatingPurchase, setCalculatingPurchase] = useState(false); - - useEffect(() => { - // Props๋กœ ๋ฐ›์€ ๊ฐ’๋“ค์„ ์ดˆ๊ธฐํ™” - if (propJobNo) setJobNo(propJobNo); - if (propBomName) setBomName(propBomName); - if (propRevision) setCurrentRevision(propRevision); - if (propFilename) setFileName(propFilename); - - if (fileId) { - loadMaterials(fileId); - } else { - setLoading(false); - setError('ํŒŒ์ผ ID๊ฐ€ ์ง€์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); - } - }, [fileId, propJobNo, propBomName, propRevision, propFilename]); - - const loadMaterials = async (id) => { - try { - setLoading(true); - const response = await api.get('/files/materials', { - params: { file_id: parseInt(id), limit: 10000 } - }); - - if (response.data && response.data.materials) { - setMaterials(response.data.materials); - - // ํŒŒ์ผ ์ •๋ณด ์„ค์ • - if (response.data.materials.length > 0) { - const firstMaterial = response.data.materials[0]; - setFileName(firstMaterial.filename || ''); - setJobNo(firstMaterial.project_code || ''); - setBomName(firstMaterial.filename || ''); - setCurrentRevision('Rev.0'); // API์—์„œ revision ์ •๋ณด๊ฐ€ ์—†์œผ๋ฏ€๋กœ ๊ธฐ๋ณธ๊ฐ’ - } - } else { - setMaterials([]); - } - } catch (err) { - console.error('์ž์žฌ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', err); - setError('์ž์žฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setLoading(false); - } - }; - - // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ•จ์ˆ˜ (๊ธฐ์กด BOM ๊ทœ์น™ ์ ์šฉ) - const calculatePurchaseQuantities = async () => { - if (!jobNo || !currentRevision) { - alert('ํ”„๋กœ์ ํŠธ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - return; - } - - setCalculatingPurchase(true); - try { - const response = await api.get(`/purchase/calculate`, { - params: { - job_no: jobNo, - revision: currentRevision, - file_id: fileId - } - }); - - if (response.data && response.data.success) { - setPurchaseData(response.data.purchase_items); - setShowPurchaseCalculation(true); - } else { - throw new Error('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์‹คํŒจ'); - } - } catch (error) { - console.error('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์˜ค๋ฅ˜:', error); - alert('๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setCalculatingPurchase(false); - } - }; - - // ํ•„ํ„ฐ๋ง๋œ ์ž์žฌ ๋ชฉ๋ก (๊ธฐ์กด BOM ๊ทœ์น™ ์ ์šฉ) - const filteredMaterials = materials.filter(material => { - const matchesSearch = !searchTerm || - material.original_description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.size_spec?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.material_grade?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesCategory = filterCategory === 'all' || - material.classified_category === filterCategory; - - // ์‹ ๋ขฐ๋„ ํ•„ํ„ฐ๋ง (๊ธฐ์กด BOM ๊ทœ์น™) - const matchesConfidence = filterConfidence === 'all' || - (filterConfidence === 'high' && material.classification_confidence >= 0.9) || - (filterConfidence === 'medium' && material.classification_confidence >= 0.7 && material.classification_confidence < 0.9) || - (filterConfidence === 'low' && material.classification_confidence < 0.7); - - return matchesSearch && matchesCategory && matchesConfidence; - }); - - // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํ†ต๊ณ„ - const categoryStats = materials.reduce((acc, material) => { - const category = material.classified_category || 'unknown'; - acc[category] = (acc[category] || 0) + 1; - return acc; - }, {}); - - const categories = Object.keys(categoryStats).sort(); - - // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ƒ‰์ƒ ํ•จ์ˆ˜ - const getCategoryColor = (category) => { - const colors = { - 'pipe': '#4299e1', - 'fitting': '#48bb78', - 'valve': '#ed8936', - 'flange': '#9f7aea', - 'bolt': '#38b2ac', - 'gasket': '#f56565', - 'instrument': '#d69e2e', - 'material': '#718096', - 'integrated': '#319795', - 'unknown': '#a0aec0' - }; - return colors[category?.toLowerCase()] || colors.unknown; - }; - - // ์‹ ๋ขฐ๋„ ๋ฐฐ์ง€ ํ•จ์ˆ˜ (๊ธฐ์กด BOM ๊ทœ์น™ ์ ์šฉ) - const getConfidenceBadge = (confidence) => { - if (!confidence) return '-'; - - const conf = parseFloat(confidence); - let color, text; - - if (conf >= 0.9) { - color = '#48bb78'; // ๋…น์ƒ‰ - text = '๋†’์Œ'; - } else if (conf >= 0.7) { - color = '#ed8936'; // ์ฃผํ™ฉ์ƒ‰ - text = '๋ณดํ†ต'; - } else { - color = '#f56565'; // ๋นจ๊ฐ„์ƒ‰ - text = '๋‚ฎ์Œ'; - } - - return ( -
- - {text} - - - {Math.round(conf * 100)}% - -
- ); - }; - - // ์ƒ์„ธ์ •๋ณด ํ‘œ์‹œ ํ•จ์ˆ˜ (๊ธฐ์กด BOM ๊ทœ์น™ ์ ์šฉ) - const getDetailInfo = (material) => { - const details = []; - - // PIPE ์ƒ์„ธ์ •๋ณด - if (material.pipe_details) { - const pipe = material.pipe_details; - if (pipe.schedule) details.push(`SCH ${pipe.schedule}`); - if (pipe.manufacturing_method) details.push(pipe.manufacturing_method); - if (pipe.end_preparation) details.push(pipe.end_preparation); - } - - // FITTING ์ƒ์„ธ์ •๋ณด - if (material.fitting_details) { - const fitting = material.fitting_details; - if (fitting.fitting_type) details.push(fitting.fitting_type); - if (fitting.connection_method && fitting.connection_method !== 'UNKNOWN') { - details.push(fitting.connection_method); - } - if (fitting.pressure_rating && fitting.pressure_rating !== 'UNKNOWN') { - details.push(fitting.pressure_rating); - } - } - - // VALVE ์ƒ์„ธ์ •๋ณด - if (material.valve_details) { - const valve = material.valve_details; - if (valve.valve_type) details.push(valve.valve_type); - if (valve.connection_type) details.push(valve.connection_type); - if (valve.pressure_rating) details.push(valve.pressure_rating); - } - - // BOLT ์ƒ์„ธ์ •๋ณด - if (material.bolt_details) { - const bolt = material.bolt_details; - if (bolt.fastener_type) details.push(bolt.fastener_type); - if (bolt.thread_specification) details.push(bolt.thread_specification); - if (bolt.length_mm) details.push(`L${bolt.length_mm}mm`); - } - - // FLANGE ์ƒ์„ธ์ •๋ณด - if (material.flange_details) { - const flange = material.flange_details; - if (flange.flange_type) details.push(flange.flange_type); - if (flange.pressure_rating) details.push(flange.pressure_rating); - if (flange.facing_type) details.push(flange.facing_type); - } - - return details.length > 0 ? ( -
- {details.slice(0, 2).map((detail, idx) => ( -
- {detail} -
- ))} - {details.length > 2 && ( - +{details.length - 2} - )} -
- ) : '-'; - }; - - if (loading) { - return ( -
-
- ๋กœ๋”ฉ ์ค‘... -
-
- ); - } - - if (error) { - return ( -
-
- {error} -
-
- ); - } - - return ( -
-
- {/* ํ—ค๋” */} -
- - -

- ๐Ÿ“ฆ ์ž์žฌ ๋ชฉ๋ก -

- -
-
-
ํ”„๋กœ์ ํŠธ: {jobNo}
-
BOM: {bomName}
-
๋ฆฌ๋น„์ „: {currentRevision}
-
์ด ์ž์žฌ ์ˆ˜: {materials.length}๊ฐœ
-
- - -
-
- - {/* ๊ฒ€์ƒ‰ ๋ฐ ํ•„ํ„ฐ */} -
-
-
- - setSearchTerm(e.target.value)} - placeholder="์ž์žฌ๋ช…, ๊ทœ๊ฒฉ, ์„ค๋ช…์œผ๋กœ ๊ฒ€์ƒ‰..." - style={{ - width: '100%', - padding: '12px', - border: '1px solid #e2e8f0', - borderRadius: '8px', - fontSize: '14px' - }} - /> -
- -
- - -
- -
- - -
-
-
- - {/* ํ†ต๊ณ„ ์นด๋“œ */} -
- {categories.slice(0, 6).map(category => ( -
-
- {categoryStats[category]} -
-
- {category} -
-
- ))} -
- - {/* ์ž์žฌ ํ…Œ์ด๋ธ” */} -
-
-

- ์ž์žฌ ๋ชฉ๋ก ({filteredMaterials.length}๊ฐœ) -

-
- -
- - - - - - - - - - - - - - - - {filteredMaterials.map((material, index) => ( - - - - - - - - - - - - ))} - -
No.์ž์žฌ๋ช…๊ทœ๊ฒฉ์ˆ˜๋Ÿ‰๋‹จ์œ„์นดํ…Œ๊ณ ๋ฆฌ์žฌ์งˆ์‹ ๋ขฐ๋„์ƒ์„ธ์ •๋ณด
- {material.line_number || index + 1} - - {material.original_description || '-'} - - {material.size_spec || material.main_nom || '-'} - - {material.quantity || '-'} - - {material.unit || '-'} - - - {material.classified_category || 'unknown'} - - - {material.material_grade || '-'} - - {getConfidenceBadge(material.classification_confidence)} - - {getDetailInfo(material)} -
-
- - {filteredMaterials.length === 0 && ( -
- ๊ฒ€์ƒ‰ ์กฐ๊ฑด์— ๋งž๋Š” ์ž์žฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. -
- )} -
- - {/* ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ ๋ชจ๋‹ฌ */} - {showPurchaseCalculation && purchaseData && ( -
-
-
-

- ๐Ÿงฎ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ -

- -
- -
- - - - - - - - - - - - - - - {purchaseData.map((item, index) => ( - - - - - - - - - - - ))} - -
์นดํ…Œ๊ณ ๋ฆฌ์‚ฌ์–‘์‚ฌ์ด์ฆˆ์žฌ์งˆBOM ์ˆ˜๋Ÿ‰๊ตฌ๋งค ์ˆ˜๋Ÿ‰๋‹จ์œ„๋น„๊ณ 
- - {item.category} - - - {item.specification} - - {/* PIPE๋Š” ์‚ฌ์–‘์— ๋ชจ๋“  ์ •๋ณด๊ฐ€ ํฌํ•จ๋˜๋ฏ€๋กœ ์‚ฌ์ด์ฆˆ ์ปฌ๋Ÿผ ๋น„์›€ */} - {item.category !== 'PIPE' && ( - - {item.size_spec || '-'} - - )} - {item.category === 'PIPE' && ( - - ์‚ฌ์–‘์— ํฌํ•จ - - )} - - {/* PIPE๋Š” ์‚ฌ์–‘์— ๋ชจ๋“  ์ •๋ณด๊ฐ€ ํฌํ•จ๋˜๋ฏ€๋กœ ์žฌ์งˆ ์ปฌ๋Ÿผ ๋น„์›€ */} - {item.category !== 'PIPE' && ( - - {item.material_spec || '-'} - - )} - {item.category === 'PIPE' && ( - - ์‚ฌ์–‘์— ํฌํ•จ - - )} - - {item.category === 'PIPE' ? - `${Math.round(item.bom_quantity)}mm` : - item.bom_quantity - } - - {item.category === 'PIPE' ? - `${item.pipes_count}๋ณธ (${Math.round(item.calculated_qty)}mm)` : - item.calculated_qty - } - - {item.unit} - - {item.category === 'PIPE' && ( -
-
์ ˆ๋‹จ์ˆ˜: {item.cutting_count}ํšŒ
-
์ ˆ๋‹จ์†์‹ค: {item.cutting_loss}mm
-
ํ™œ์šฉ๋ฅ : {Math.round(item.utilization_rate)}%
-
- )} - {item.category !== 'PIPE' && item.safety_factor && ( -
์—ฌ์œ ์œจ: {Math.round((item.safety_factor - 1) * 100)}%
- )} -
-
- -
-
๐Ÿ“‹ ๊ณ„์‚ฐ ๊ทœ์น™ (์˜ฌ๋ฐ”๋ฅธ ๊ทœ์น™):
-
โ€ข PIPE: 6M ๋‹จ์œ„ ์˜ฌ๋ฆผ, ์ ˆ๋‹จ๋‹น 2mm ์†์‹ค
-
โ€ข FITTING: BOM ์ˆ˜๋Ÿ‰ ๊ทธ๋Œ€๋กœ
-
โ€ข VALVE: BOM ์ˆ˜๋Ÿ‰ ๊ทธ๋Œ€๋กœ
-
โ€ข BOLT: 5% ์—ฌ์œ ์œจ ํ›„ 4์˜ ๋ฐฐ์ˆ˜ ์˜ฌ๋ฆผ
-
โ€ข GASKET: 5์˜ ๋ฐฐ์ˆ˜ ์˜ฌ๋ฆผ
-
โ€ข INSTRUMENT: BOM ์ˆ˜๋Ÿ‰ ๊ทธ๋Œ€๋กœ
-
-
-
- )} -
-
- ); -}; - -export default SimpleMaterialsPage; diff --git a/frontend/src/pages/SystemSettingsPage.jsx b/frontend/src/pages/SystemSettingsPage.jsx new file mode 100644 index 0000000..a9b09b7 --- /dev/null +++ b/frontend/src/pages/SystemSettingsPage.jsx @@ -0,0 +1,455 @@ +import React, { useState, useEffect } from 'react'; +import api from '../api'; + +const SystemSettingsPage = ({ onNavigate, user }) => { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [newUser, setNewUser] = useState({ + username: '', + email: '', + password: '', + full_name: '', + role: 'user' + }); + + useEffect(() => { + loadUsers(); + }, []); + + const loadUsers = async () => { + try { + setLoading(true); + const response = await api.get('/auth/users'); + if (response.data.success) { + setUsers(response.data.users); + } + } catch (err) { + console.error('์‚ฌ์šฉ์ž ๋ชฉ๋ก ๋กœ๋”ฉ ์‹คํŒจ:', err); + setError('์‚ฌ์šฉ์ž ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setLoading(false); + } + }; + + const handleCreateUser = async (e) => { + e.preventDefault(); + + if (!newUser.username || !newUser.email || !newUser.password) { + setError('๋ชจ๋“  ํ•„์ˆ˜ ํ•„๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + try { + setLoading(true); + const response = await api.post('/auth/register', newUser); + + if (response.data.success) { + alert('์‚ฌ์šฉ์ž๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + setNewUser({ + username: '', + email: '', + password: '', + full_name: '', + role: 'user' + }); + setShowCreateForm(false); + loadUsers(); + } + } catch (err) { + console.error('์‚ฌ์šฉ์ž ์ƒ์„ฑ ์‹คํŒจ:', err); + setError(err.response?.data?.detail || '์‚ฌ์šฉ์ž ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setLoading(false); + } + }; + + const handleDeleteUser = async (userId) => { + if (!confirm('์ •๋ง๋กœ ์ด ์‚ฌ์šฉ์ž๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + setLoading(true); + const response = await api.delete(`/auth/users/${userId}`); + + if (response.data.success) { + alert('์‚ฌ์šฉ์ž๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + loadUsers(); + } + } catch (err) { + console.error('์‚ฌ์šฉ์ž ์‚ญ์ œ ์‹คํŒจ:', err); + setError('์‚ฌ์šฉ์ž ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setLoading(false); + } + }; + + const getRoleDisplay = (role) => { + switch (role) { + case 'admin': return '๊ด€๋ฆฌ์ž'; + case 'manager': return '๋งค๋‹ˆ์ €'; + case 'user': return '์‚ฌ์šฉ์ž'; + default: return role; + } + }; + + const getRoleBadgeColor = (role) => { + switch (role) { + case 'admin': return '#dc2626'; + case 'manager': return '#ea580c'; + case 'user': return '#059669'; + default: return '#6b7280'; + } + }; + + // ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™•์ธ + if (user?.role !== 'admin') { + return ( +
+

์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค

+

+ ์‹œ์Šคํ…œ ์„ค์ •์€ ๊ด€๋ฆฌ์ž๋งŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +

+ +
+ ); + } + + return ( +
+ {/* ํ—ค๋” */} +
+
+

+ โš™๏ธ ์‹œ์Šคํ…œ ์„ค์ • +

+

+ ์‚ฌ์šฉ์ž ๊ณ„์ • ๊ด€๋ฆฌ ๋ฐ ์‹œ์Šคํ…œ ์„ค์ • +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ ์„น์…˜ */} +
+
+

+ ๐Ÿ‘ฅ ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ +

+ +
+ + {/* ์‚ฌ์šฉ์ž ์ƒ์„ฑ ํผ */} + {showCreateForm && ( +
+

+ ์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ +

+
+
+
+ + setNewUser({...newUser, username: e.target.value})} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '14px' + }} + required + /> +
+
+ + setNewUser({...newUser, email: e.target.value})} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '14px' + }} + required + /> +
+
+
+
+ + setNewUser({...newUser, password: e.target.value})} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '14px' + }} + required + /> +
+
+ + setNewUser({...newUser, full_name: e.target.value})} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '14px' + }} + /> +
+
+
+ + +
+
+ + +
+
+
+ )} + + {/* ์‚ฌ์šฉ์ž ๋ชฉ๋ก */} + {loading ? ( +
+
๋กœ๋”ฉ ์ค‘...
+
+ ) : ( +
+ + + + + + + + + + + + + {users.map((userItem) => ( + + + + + + + + + ))} + +
+ ์‚ฌ์šฉ์ž๋ช… + + ์ด๋ฉ”์ผ + + ์ „์ฒด ์ด๋ฆ„ + + ๊ถŒํ•œ + + ์ƒํƒœ + + ์ž‘์—… +
+ {userItem.username} + + {userItem.email} + + {userItem.full_name || '-'} + + + {getRoleDisplay(userItem.role)} + + + + {userItem.is_active ? 'ํ™œ์„ฑ' : '๋น„ํ™œ์„ฑ'} + + + {userItem.id !== user?.id && ( + + )} +
+
+ )} +
+
+ ); +}; + +export default SystemSettingsPage; diff --git a/frontend/src/pages/UserManagementPage.css b/frontend/src/pages/UserManagementPage.css index 8b407a7..1d0e145 100644 --- a/frontend/src/pages/UserManagementPage.css +++ b/frontend/src/pages/UserManagementPage.css @@ -428,3 +428,19 @@ width: 100%; } } + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/_backup/BOMManagementPage.jsx similarity index 98% rename from frontend/src/pages/BOMManagementPage.jsx rename to frontend/src/pages/_backup/BOMManagementPage.jsx index ae18713..ece4b62 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/_backup/BOMManagementPage.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import SimpleFileUpload from '../components/SimpleFileUpload'; import MaterialList from '../components/MaterialList'; -import { fetchMaterials, fetchFiles } from '../api'; +import { fetchMaterials, fetchFiles, fetchJobs } from '../api'; const BOMManagementPage = ({ user }) => { const [activeTab, setActiveTab] = useState('upload'); @@ -32,10 +32,10 @@ const BOMManagementPage = ({ user }) => { const loadProjects = async () => { try { - const response = await fetch('/api/jobs/'); - const data = await response.json(); - if (data.success) { - setProjects(data.jobs); + // โœ… API ํ•จ์ˆ˜ ์‚ฌ์šฉ (๊ถŒ์žฅ) + const response = await fetchJobs(); + if (response.data.success) { + setProjects(response.data.jobs); } } catch (error) { console.error('ํ”„๋กœ์ ํŠธ ๋กœ๋”ฉ ์‹คํŒจ:', error); diff --git a/frontend/src/pages/MaterialComparisonPage.jsx b/frontend/src/pages/_backup/MaterialComparisonPage.jsx similarity index 98% rename from frontend/src/pages/MaterialComparisonPage.jsx rename to frontend/src/pages/_backup/MaterialComparisonPage.jsx index 33b2e7f..af22933 100644 --- a/frontend/src/pages/MaterialComparisonPage.jsx +++ b/frontend/src/pages/_backup/MaterialComparisonPage.jsx @@ -33,7 +33,7 @@ import { } from '@mui/icons-material'; import MaterialComparisonResult from '../components/MaterialComparisonResult'; -import { compareMaterialRevisions, confirmMaterialPurchase, api } from '../api'; +import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api'; import { exportComparisonToExcel } from '../utils/excelExport'; const MaterialComparisonPage = () => { @@ -74,8 +74,11 @@ const MaterialComparisonPage = () => { // ๐Ÿšจ ํ…Œ์ŠคํŠธ: MaterialsPage API ์ง์ ‘ ํ˜ธ์ถœํ•ด์„œ ๊ธธ์ด ์ •๋ณด ํ™•์ธ try { - const testResult = await api.get('/files/materials', { - params: { job_no: jobNo, revision: currentRevision, limit: 10 } + // โœ… API ํ•จ์ˆ˜ ์‚ฌ์šฉ - ํ…Œ์ŠคํŠธ์šฉ ์ž์žฌ ์กฐํšŒ + const testResult = await fetchMaterials({ + job_no: jobNo, + revision: currentRevision, + limit: 10 }); const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE'); console.log('๐Ÿงช MaterialsPage API ํ…Œ์ŠคํŠธ (๊ธธ์ด ์žˆ๋Š”์ง€ ํ™•์ธ):', pipeData); diff --git a/frontend/src/pages/MaterialsManagementPage.jsx b/frontend/src/pages/_backup/MaterialsManagementPage.jsx similarity index 98% rename from frontend/src/pages/MaterialsManagementPage.jsx rename to frontend/src/pages/_backup/MaterialsManagementPage.jsx index b5833d0..6a55469 100644 --- a/frontend/src/pages/MaterialsManagementPage.jsx +++ b/frontend/src/pages/_backup/MaterialsManagementPage.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import MaterialList from '../components/MaterialList'; -import { fetchMaterials } from '../api'; +import { fetchMaterials, fetchJobs } from '../api'; const MaterialsManagementPage = ({ user }) => { const [materials, setMaterials] = useState([]); @@ -31,10 +31,10 @@ const MaterialsManagementPage = ({ user }) => { const loadProjects = async () => { try { - const response = await fetch('/api/jobs/'); - const data = await response.json(); - if (data.success) { - setProjects(data.jobs); + // โœ… API ํ•จ์ˆ˜ ์‚ฌ์šฉ (๊ถŒ์žฅ) + const response = await fetchJobs(); + if (response.data.success) { + setProjects(response.data.jobs); } } catch (error) { console.error('ํ”„๋กœ์ ํŠธ ๋กœ๋”ฉ ์‹คํŒจ:', error); diff --git a/frontend/src/pages/_backup/PurchaseConfirmationPage.jsx b/frontend/src/pages/_backup/PurchaseConfirmationPage.jsx new file mode 100644 index 0000000..d72e407 --- /dev/null +++ b/frontend/src/pages/_backup/PurchaseConfirmationPage.jsx @@ -0,0 +1,736 @@ +import React, { useState, useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { api } from '../api'; + +const PurchaseConfirmationPage = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [purchaseItems, setPurchaseItems] = useState([]); + const [revisionComparison, setRevisionComparison] = useState(null); + const [loading, setLoading] = useState(true); + const [editingItem, setEditingItem] = useState(null); + const [confirmDialog, setConfirmDialog] = useState(false); + + // URL์—์„œ job_no, revision ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const searchParams = new URLSearchParams(location.search); + const jobNo = searchParams.get('job_no'); + const revision = searchParams.get('revision'); + const filename = searchParams.get('filename'); + const previousRevision = searchParams.get('prev_revision'); + + useEffect(() => { + if (jobNo && revision) { + loadPurchaseItems(); + if (previousRevision) { + loadRevisionComparison(); + } + } + }, [jobNo, revision, previousRevision]); + + const loadPurchaseItems = async () => { + try { + setLoading(true); + const response = await api.get('/purchase/items/calculate', { + params: { job_no: jobNo, revision: revision } + }); + setPurchaseItems(response.data.items || []); + } catch (error) { + console.error('๊ตฌ๋งค ํ’ˆ๋ชฉ ๋กœ๋”ฉ ์‹คํŒจ:', error); + } finally { + setLoading(false); + } + }; + + const loadRevisionComparison = async () => { + try { + const response = await api.get('/purchase/revision-diff', { + params: { + job_no: jobNo, + current_revision: revision, + previous_revision: previousRevision + } + }); + setRevisionComparison(response.data.comparison); + } catch (error) { + console.error('๋ฆฌ๋น„์ „ ๋น„๊ต ์‹คํŒจ:', error); + } + }; + + const updateItemQuantity = async (itemId, field, value) => { + try { + await api.patch(`/purchase/items/${itemId}`, { + [field]: parseFloat(value) + }); + + // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + setPurchaseItems(prev => + prev.map(item => + item.id === itemId + ? { ...item, [field]: parseFloat(value) } + : item + ) + ); + + setEditingItem(null); + } catch (error) { + console.error('์ˆ˜๋Ÿ‰ ์—…๋ฐ์ดํŠธ ์‹คํŒจ:', error); + } + }; + + const confirmPurchase = async () => { + try { + // ์ž…๋ ฅ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ + if (!jobNo || !revision) { + alert('Job ๋ฒˆํ˜ธ์™€ ๋ฆฌ๋น„์ „ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + if (purchaseItems.length === 0) { + alert('๊ตฌ๋งคํ•  ํ’ˆ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ๊ฐ ํ’ˆ๋ชฉ์˜ ์ˆ˜๋Ÿ‰ ๊ฒ€์ฆ + const invalidItems = purchaseItems.filter(item => + !item.calculated_qty || item.calculated_qty <= 0 + ); + + if (invalidItems.length > 0) { + alert(`๋‹ค์Œ ํ’ˆ๋ชฉ๋“ค์˜ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค:\n${invalidItems.map(item => `- ${item.specification}`).join('\n')}`); + return; + } + + setConfirmDialog(false); + + const response = await api.post('/purchase/orders/create', { + job_no: jobNo, + revision: revision, + items: purchaseItems.map(item => ({ + purchase_item_id: item.id, + ordered_quantity: item.calculated_qty, + required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30์ผ ํ›„ + })) + }); + + const successMessage = `๊ตฌ๋งค ์ฃผ๋ฌธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!\n\n` + + `- Job: ${jobNo}\n` + + `- Revision: ${revision}\n` + + `- ํ’ˆ๋ชฉ ์ˆ˜: ${purchaseItems.length}๊ฐœ\n` + + `- ์ƒ์„ฑ ์‹œ๊ฐ„: ${new Date().toLocaleString('ko-KR')}`; + + alert(successMessage); + + // ์ž์žฌ ๋ชฉ๋ก ํŽ˜์ด์ง€๋กœ ์ด๋™ (์ƒํƒœ ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ… ์‚ฌ์šฉ) + // App.jsx์˜ ์ƒํƒœ ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ…์„ ์œ„ํ•ด window ์ด๋ฒคํŠธ ๋ฐœ์ƒ + window.dispatchEvent(new CustomEvent('navigateToMaterials', { + detail: { + jobNo: jobNo, + revision: revision, + bomName: `${jobNo} ${revision}`, + message: '๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ์™„๋ฃŒ' + } + })); + } catch (error) { + console.error('๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', error); + + let errorMessage = '๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + + if (error.response?.data?.detail) { + errorMessage += `\n\n์˜ค๋ฅ˜ ๋‚ด์šฉ: ${error.response.data.detail}`; + } else if (error.message) { + errorMessage += `\n\n์˜ค๋ฅ˜ ๋‚ด์šฉ: ${error.message}`; + } + + if (error.response?.status === 400) { + errorMessage += '\n\n์ž…๋ ฅ ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•ด์ฃผ์„ธ์š”.'; + } else if (error.response?.status === 404) { + errorMessage += '\n\nํ•ด๋‹น Job์ด๋‚˜ ๋ฆฌ๋น„์ „์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'; + } else if (error.response?.status >= 500) { + errorMessage += '\n\n์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'; + } + + alert(errorMessage); + } + }; + + const getCategoryColor = (category) => { + const colors = { + 'PIPE': '#1976d2', + 'FITTING': '#9c27b0', + 'VALVE': '#2e7d32', + 'FLANGE': '#ed6c02', + 'BOLT': '#0288d1', + 'GASKET': '#d32f2f', + 'INSTRUMENT': '#7b1fa2' + }; + return colors[category] || '#757575'; + }; + + const formatPipeInfo = (item) => { + if (item.category !== 'PIPE') return null; + + return ( +
+ ์ ˆ๋‹จ์†์‹ค: {item.cutting_loss || 0}mm | + ๊ตฌ๋งค: {item.pipes_count || 0}๋ณธ | + ์—ฌ์œ ๋ถ„: {item.waste_length || 0}mm +
+ ); + }; + + const formatBoltInfo = (item) => { + if (item.category !== 'BOLT') return null; + + // ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ์ •๋ณด (๋ฐฑ์—”๋“œ์—์„œ ์ œ๊ณต๋˜์–ด์•ผ ํ•จ) + const specialApplications = item.special_applications || {}; + const psvCount = specialApplications.PSV || 0; + const ltCount = specialApplications.LT || 0; + const ckCount = specialApplications.CK || 0; + const oriCount = specialApplications.ORI || 0; + + return ( +
+
+ ๋ถ„์ˆ˜ ์‚ฌ์ด์ฆˆ: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} | + ํ‘œ๋ฉด์ฒ˜๋ฆฌ: {item.surface_treatment || '์—†์Œ'} +
+ + {/* ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ์ •๋ณด */} +
+
+ ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ํ˜„ํ™ฉ: +
+
+
0 ? '#d32f2f' : '#666' }}> + PSV์šฉ: {psvCount}๊ฐœ +
+
0 ? '#ed6c02' : '#666' }}> + ์ €์˜จ์šฉ: {ltCount}๊ฐœ +
+
0 ? '#0288d1' : '#666' }}> + ์ฒดํฌ๋ฐธ๋ธŒ์šฉ: {ckCount}๊ฐœ +
+
0 ? '#9c27b0' : '#666' }}> + ์˜ค๋ฆฌํ”ผ์Šค์šฉ: {oriCount}๊ฐœ +
+
+ {(psvCount + ltCount + ckCount + oriCount) === 0 && ( +
+ ํŠน์ˆ˜ ์šฉ๋„ ๋ณผํŠธ ์—†์Œ (์ผ๋ฐ˜ ๋ณผํŠธ๋งŒ ํฌํ•จ) +
+ )} +
+
+ ); + }; + + const exportToExcel = () => { + if (purchaseItems.length === 0) { + alert('๋‚ด๋ณด๋‚ผ ๊ตฌ๋งค ํ’ˆ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์ƒ์„ธํ•œ ๊ตฌ๋งค ํ™•์ • ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + const data = purchaseItems.map((item, index) => { + const baseData = { + '์ˆœ๋ฒˆ': index + 1, + 'ํ’ˆ๋ชฉ์ฝ”๋“œ': item.item_code || '', + '์นดํ…Œ๊ณ ๋ฆฌ': item.category || '', + '์‚ฌ์–‘': item.specification || '', + '์žฌ์งˆ': item.material_spec || '', + '์‚ฌ์ด์ฆˆ': item.size_spec || '', + '๋‹จ์œ„': item.unit || '', + 'BOM์ˆ˜๋Ÿ‰': item.bom_quantity || 0, + '๊ตฌ๋งค์ˆ˜๋Ÿ‰': item.calculated_qty || 0, + '์—ฌ์œ ์œจ': ((item.safety_factor || 1) - 1) * 100 + '%', + '์ตœ์†Œ์ฃผ๋ฌธ': item.min_order_qty || 0, + '์˜ˆ์ƒ์—ฌ์œ ๋ถ„': ((item.calculated_qty || 0) - (item.bom_quantity || 0)).toFixed(1), + 'ํ™œ์šฉ๋ฅ ': (((item.bom_quantity || 0) / (item.calculated_qty || 1)) * 100).toFixed(1) + '%' + }; + + // ํŒŒ์ดํ”„ ํŠน์ˆ˜ ์ •๋ณด ์ถ”๊ฐ€ + if (item.category === 'PIPE') { + baseData['์ ˆ๋‹จ์†์‹ค'] = item.cutting_loss || 0; + baseData['๊ตฌ๋งค๋ณธ์ˆ˜'] = item.pipes_count || 0; + baseData['์—ฌ์œ ๊ธธ์ด'] = item.waste_length || 0; + } + + // ๋ณผํŠธ ํŠน์ˆ˜ ์ •๋ณด ์ถ”๊ฐ€ + if (item.category === 'BOLT') { + const specialApps = item.special_applications || {}; + baseData['PSV์šฉ'] = specialApps.PSV || 0; + baseData['์ €์˜จ์šฉ'] = specialApps.LT || 0; + baseData['์ฒดํฌ๋ฐธ๋ธŒ์šฉ'] = specialApps.CK || 0; + baseData['์˜ค๋ฆฌํ”ผ์Šค์šฉ'] = specialApps.ORI || 0; + baseData['๋ถ„์ˆ˜์‚ฌ์ด์ฆˆ'] = item.size_fraction || ''; + baseData['ํ‘œ๋ฉด์ฒ˜๋ฆฌ'] = item.surface_treatment || ''; + } + + // ๋ฆฌ๋น„์ „ ๋น„๊ต ์ •๋ณด ์ถ”๊ฐ€ (์žˆ๋Š” ๊ฒฝ์šฐ) + if (previousRevision) { + baseData['๊ธฐ๊ตฌ๋งค์ˆ˜๋Ÿ‰'] = item.purchased_quantity || 0; + baseData['์ถ”๊ฐ€๊ตฌ๋งคํ•„์š”'] = Math.max(item.additional_needed || 0, 0); + } + + return baseData; + }); + + // ํ—ค๋” ์ •๋ณด ์ถ”๊ฐ€ + const headerInfo = [ + `๊ตฌ๋งค ํ™•์ •์„œ`, + `Job No: ${jobNo}`, + `Revision: ${revision}`, + `ํŒŒ์ผ๋ช…: ${filename || ''}`, + `์ƒ์„ฑ์ผ: ${new Date().toLocaleString('ko-KR')}`, + `์ด ํ’ˆ๋ชฉ์ˆ˜: ${purchaseItems.length}๊ฐœ`, + '' + ]; + + // ์š”์•ฝ ์ •๋ณด ๊ณ„์‚ฐ + const totalBomQty = purchaseItems.reduce((sum, item) => sum + (item.bom_quantity || 0), 0); + const totalPurchaseQty = purchaseItems.reduce((sum, item) => sum + (item.calculated_qty || 0), 0); + const categoryCount = purchaseItems.reduce((acc, item) => { + acc[item.category] = (acc[item.category] || 0) + 1; + return acc; + }, {}); + + const summaryInfo = [ + '=== ์š”์•ฝ ์ •๋ณด ===', + `์ „์ฒด BOM ์ˆ˜๋Ÿ‰: ${totalBomQty.toFixed(1)}`, + `์ „์ฒด ๊ตฌ๋งค ์ˆ˜๋Ÿ‰: ${totalPurchaseQty.toFixed(1)}`, + `์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํ’ˆ๋ชฉ์ˆ˜: ${Object.entries(categoryCount).map(([cat, count]) => `${cat}(${count})`).join(', ')}`, + '' + ]; + + // CSV ํ˜•ํƒœ๋กœ ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + const csvContent = [ + ...headerInfo, + ...summaryInfo, + '=== ์ƒ์„ธ ํ’ˆ๋ชฉ ๋ชฉ๋ก ===', + Object.keys(data[0]).join(','), + ...data.map(row => Object.values(row).map(val => + typeof val === 'string' && val.includes(',') ? `"${val}"` : val + ).join(',')) + ].join('\n'); + + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + + const timestamp = new Date().toISOString().split('T')[0]; + const fileName = `๊ตฌ๋งคํ™•์ •์„œ_${jobNo}_${revision}_${timestamp}.csv`; + link.setAttribute('download', fileName); + + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ + alert(`๊ตฌ๋งค ํ™•์ •์„œ๊ฐ€ ๋‹ค์šด๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\nํŒŒ์ผ๋ช…: ${fileName}`); + }; + + if (loading) { + return ( +
+
๋กœ๋”ฉ ์ค‘...
+
+ ); + } + + return ( +
+ {/* ํ—ค๋” */} +
+ +
+

+ ๐Ÿ›’ ๊ตฌ๋งค ํ™•์ • +

+

+ Job: {jobNo} | {filename} | {revision} +

+
+
+ + +
+
+ + {/* ๋ฆฌ๋น„์ „ ๋น„๊ต ์•Œ๋ฆผ */} + {revisionComparison && ( +
+ ๐Ÿ”„ +
+
+ ๋ฆฌ๋น„์ „ ๋ณ€๊ฒฝ์‚ฌํ•ญ: {revisionComparison.summary} +
+ {revisionComparison.additional_items && ( +
+ ์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š”: {revisionComparison.additional_items}๊ฐœ ํ’ˆ๋ชฉ +
+ )} +
+
+ )} + + {/* ๊ตฌ๋งค ํ’ˆ๋ชฉ ๋ชฉ๋ก */} + {purchaseItems.length === 0 ? ( +
+
๐Ÿ“ฆ
+
+ ๊ตฌ๋งคํ•  ํ’ˆ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค. +
+
+ ) : ( +
+ {purchaseItems.map(item => ( +
+
+ + {item.category} + +

+ {item.specification} +

+ {item.is_additional && ( + + ์ถ”๊ฐ€ ๊ตฌ๋งค + + )} +
+ +
+ {/* BOM ์ˆ˜๋Ÿ‰ */} +
+
+ BOM ํ•„์š”๋Ÿ‰ +
+
+ {item.bom_quantity} {item.unit} +
+ {formatPipeInfo(item)} + {formatBoltInfo(item)} +
+ + {/* ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ */} +
+
+ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ +
+ {editingItem === item.id ? ( +
+ + setPurchaseItems(prev => + prev.map(i => + i.id === item.id + ? { ...i, calculated_qty: parseFloat(e.target.value) || 0 } + : i + ) + ) + } + style={{ + width: '100px', + padding: '4px 8px', + border: '1px solid #ddd', + borderRadius: '4px' + }} + /> + + +
+ ) : ( +
+
+ {item.calculated_qty} {item.unit} +
+ +
+ )} +
+ + {/* ์ด๋ฏธ ๊ตฌ๋งคํ•œ ์ˆ˜๋Ÿ‰ */} + {previousRevision && ( +
+
+ ๊ธฐ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ +
+
+ {item.purchased_quantity || 0} {item.unit} +
+
+ )} + + {/* ์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š”๋Ÿ‰ */} + {previousRevision && ( +
+
+ ์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š” +
+
0 ? '#d32f2f' : '#2e7d32' + }}> + {Math.max(item.additional_needed || 0, 0)} {item.unit} +
+
+ )} +
+ + {/* ์—ฌ์œ ์œจ ๋ฐ ์ตœ์†Œ ์ฃผ๋ฌธ ์ •๋ณด */} +
+
+
+
์—ฌ์œ ์œจ
+
+ {((item.safety_factor || 1) - 1) * 100}% +
+
+
+
์ตœ์†Œ ์ฃผ๋ฌธ
+
+ {item.min_order_qty || 0} {item.unit} +
+
+
+
์˜ˆ์ƒ ์—ฌ์œ ๋ถ„
+
+ {(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit} +
+
+
+
ํ™œ์šฉ๋ฅ 
+
+ {((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}% +
+
+
+
+
+ ))} +
+ )} + + {/* ๊ตฌ๋งค ์ฃผ๋ฌธ ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ */} + {confirmDialog && ( +
+
+

๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ํ™•์ธ

+
+ ์ด {purchaseItems.length}๊ฐœ ํ’ˆ๋ชฉ์— ๋Œ€ํ•œ ๊ตฌ๋งค ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? +
+ + {revisionComparison && revisionComparison.has_changes && ( +
+ โš ๏ธ ๋ฆฌ๋น„์ „ ๋ณ€๊ฒฝ์œผ๋กœ ์ธํ•œ ์ถ”๊ฐ€ ๊ตฌ๋งค๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. +
+ )} + +
+ ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ํ›„์—๋Š” ์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ์ด ์ œํ•œ๋ฉ๋‹ˆ๋‹ค. +
+ +
+ + +
+
+
+ )} +
+ ); +}; + +export default PurchaseConfirmationPage; \ No newline at end of file diff --git a/frontend/src/pages/RevisionPurchasePage.jsx b/frontend/src/pages/_backup/RevisionPurchasePage.jsx similarity index 100% rename from frontend/src/pages/RevisionPurchasePage.jsx rename to frontend/src/pages/_backup/RevisionPurchasePage.jsx diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index 9912964..b1127ba 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -133,6 +133,25 @@ const formatMaterialForExcel = (material, includeComparison = false) => { .trim(); } + // ๋‹ˆํ”Œ์˜ ๊ฒฝ์šฐ ๊ธธ์ด ์ •๋ณด ๋ช…์‹œ์  ์ถ”๊ฐ€ + if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) { + // fitting_details์—์„œ ๊ธธ์ด ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + if (material.fitting_details && material.fitting_details.length_mm) { + const lengthMm = Math.round(material.fitting_details.length_mm); + // ์ด๋ฏธ ๊ธธ์ด ์ •๋ณด๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + if (!cleanDescription.match(/\d+\s*mm/i)) { + cleanDescription += ` ${lengthMm}mm`; + } + } + // ๋˜๋Š” ๊ธฐ์กด ์„ค๋ช…์—์„œ ๊ธธ์ด ์ •๋ณด ์ถ”์ถœ + else { + const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i); + if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) { + cleanDescription += ` ${lengthMatch[1]}mm`; + } + } + } + // ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ const purchaseInfo = calculatePurchaseQuantity(material); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 950a70f..bf677be 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { - port: 3000, + port: 13000, host: true, open: true }, diff --git a/scripts/docker-run.sh b/scripts/docker-run.sh new file mode 100755 index 0000000..d41bb55 --- /dev/null +++ b/scripts/docker-run.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +# TK-MP-Project Docker ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ +# ํ™˜๊ฒฝ๋ณ„๋กœ ์ ์ ˆํ•œ Docker Compose ์„ค์ •์„ ์‚ฌ์šฉํ•˜์—ฌ ์‹คํ–‰ + +set -e + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ๋„์›€๋ง ํ•จ์ˆ˜ +show_help() { + echo -e "${BLUE}TK-MP-Project Docker ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ${NC}" + echo "" + echo "์‚ฌ์šฉ๋ฒ•: $0 [ํ™˜๊ฒฝ] [๋ช…๋ น]" + echo "" + echo "ํ™˜๊ฒฝ:" + echo " dev ๊ฐœ๋ฐœ ํ™˜๊ฒฝ (๊ธฐ๋ณธ๊ฐ’)" + echo " prod ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ" + echo " synology ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ" + echo "" + echo "๋ช…๋ น:" + echo " up ์„œ๋น„์Šค ์‹œ์ž‘ (๊ธฐ๋ณธ๊ฐ’)" + echo " down ์„œ๋น„์Šค ์ค‘์ง€" + echo " restart ์„œ๋น„์Šค ์žฌ์‹œ์ž‘" + echo " logs ๋กœ๊ทธ ํ™•์ธ" + echo " build ์ด๋ฏธ์ง€ ๋‹ค์‹œ ๋นŒ๋“œ" + echo " status ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ" + echo "" + echo "์˜ˆ์‹œ:" + echo " $0 # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์œผ๋กœ ์‹œ์ž‘" + echo " $0 dev up # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์œผ๋กœ ์‹œ์ž‘" + echo " $0 prod up # ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์œผ๋กœ ์‹œ์ž‘" + echo " $0 synology up # ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ์œผ๋กœ ์‹œ์ž‘" + echo " $0 dev logs # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๋กœ๊ทธ ํ™•์ธ" + echo " $0 dev down # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์ค‘์ง€" +} + +# ํ™˜๊ฒฝ ์„ค์ • +ENVIRONMENT=${1:-dev} +COMMAND=${2:-up} + +# ํ™˜๊ฒฝ๋ณ„ Docker Compose ํŒŒ์ผ ์„ค์ • +case $ENVIRONMENT in + dev|development) + COMPOSE_FILES="-f docker-compose.yml -f docker-compose.override.yml" + ENV_NAME="๊ฐœ๋ฐœ" + ;; + prod|production) + COMPOSE_FILES="-f docker-compose.yml -f docker-compose.prod.yml" + ENV_NAME="ํ”„๋กœ๋•์…˜" + ;; + synology|nas) + COMPOSE_FILES="-f docker-compose.yml -f docker-compose.synology.yml" + ENV_NAME="์‹œ๋†€๋กœ์ง€" + ;; + help|-h|--help) + show_help + exit 0 + ;; + *) + echo -e "${RED}โŒ ์•Œ ์ˆ˜ ์—†๋Š” ํ™˜๊ฒฝ: $ENVIRONMENT${NC}" + echo "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ™˜๊ฒฝ: dev, prod, synology" + exit 1 + ;; +esac + +# .env ํŒŒ์ผ ํ™•์ธ +if [ ! -f .env ]; then + echo -e "${YELLOW}โš ๏ธ .env ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. env.example์„ ๋ณต์‚ฌํ•˜์—ฌ .env ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.${NC}" + cp env.example .env + echo -e "${GREEN}โœ… .env ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํ•„์š”์‹œ ์„ค์ •์„ ์ˆ˜์ •ํ•˜์„ธ์š”.${NC}" +fi + +# ๋ช…๋ น ์‹คํ–‰ +echo -e "${BLUE}๐Ÿณ TK-MP-Project ${ENV_NAME} ํ™˜๊ฒฝ ${COMMAND} ์‹คํ–‰${NC}" +echo "Docker Compose ํŒŒ์ผ: $COMPOSE_FILES" +echo "" + +case $COMMAND in + up|start) + echo -e "${GREEN}๐Ÿš€ ์„œ๋น„์Šค๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค...${NC}" + docker-compose $COMPOSE_FILES up -d + echo "" + echo -e "${GREEN}โœ… ์„œ๋น„์Šค๊ฐ€ ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค!${NC}" + echo "" + echo "์ ‘์† ์ฃผ์†Œ:" + case $ENVIRONMENT in + dev|development) + echo " - ํ”„๋ก ํŠธ์—”๋“œ: http://localhost:13000" + echo " - ๋ฐฑ์—”๋“œ API: http://localhost:18000" + echo " - API ๋ฌธ์„œ: http://localhost:18000/docs" + echo " - pgAdmin: http://localhost:5050" + ;; + prod|production) + echo " - ์›น์‚ฌ์ดํŠธ: http://localhost" + echo " - HTTPS: https://localhost (SSL ์„ค์ • ์‹œ)" + ;; + synology|nas) + echo " - ํ”„๋ก ํŠธ์—”๋“œ: http://localhost:10173" + echo " - ๋ฐฑ์—”๋“œ API: http://localhost:10080" + echo " - API ๋ฌธ์„œ: http://localhost:10080/docs" + echo " - pgAdmin: http://localhost:15050" + ;; + esac + ;; + down|stop) + echo -e "${YELLOW}๐Ÿ›‘ ์„œ๋น„์Šค๋ฅผ ์ค‘์ง€ํ•ฉ๋‹ˆ๋‹ค...${NC}" + docker-compose $COMPOSE_FILES down + echo -e "${GREEN}โœ… ์„œ๋น„์Šค๊ฐ€ ์ค‘์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${NC}" + ;; + restart) + echo -e "${YELLOW}๐Ÿ”„ ์„œ๋น„์Šค๋ฅผ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค...${NC}" + docker-compose $COMPOSE_FILES restart + echo -e "${GREEN}โœ… ์„œ๋น„์Šค๊ฐ€ ์žฌ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${NC}" + ;; + logs) + echo -e "${BLUE}๐Ÿ“‹ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค...${NC}" + docker-compose $COMPOSE_FILES logs -f + ;; + build) + echo -e "${BLUE}๐Ÿ”จ ์ด๋ฏธ์ง€๋ฅผ ๋‹ค์‹œ ๋นŒ๋“œํ•ฉ๋‹ˆ๋‹ค...${NC}" + docker-compose $COMPOSE_FILES build --no-cache + echo -e "${GREEN}โœ… ๋นŒ๋“œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${NC}" + ;; + status|ps) + echo -e "${BLUE}๐Ÿ“Š ์„œ๋น„์Šค ์ƒํƒœ๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค...${NC}" + docker-compose $COMPOSE_FILES ps + ;; + *) + echo -e "${RED}โŒ ์•Œ ์ˆ˜ ์—†๋Š” ๋ช…๋ น: $COMMAND${NC}" + echo "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ช…๋ น: up, down, restart, logs, build, status" + exit 1 + ;; +esac diff --git a/test_bolt_data.csv b/test_bolt_data.csv deleted file mode 100644 index 406d3b5..0000000 --- a/test_bolt_data.csv +++ /dev/null @@ -1,4 +0,0 @@ -description,qty,main_nom -"0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",76,3/4" -"HEX BOLT M16 X 100MM, ASTM A193 B7",10,M16 -"STUD BOLT 1/2"" X 120MM, ASTM A193 GR B7",25,1/2" \ No newline at end of file diff --git a/test_bolt_display.csv b/test_bolt_display.csv deleted file mode 100644 index 0a277af..0000000 --- a/test_bolt_display.csv +++ /dev/null @@ -1,5 +0,0 @@ -DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,LENGTH,QUANTITY,UNIT -BOLT_HEX,0.625 HEX BOLT 100.0000 LG PSV ASTM A193/A194 GR B7/2H ELEC.GALV,0.625,,100,10,EA -BOLT_STUD,0.5 STUD BOLT 75.0000 LG LT ASTM A320 GR L7,0.5,,75,8,EA -BOLT_HEX,0.75 HEX BOLT 120.0000 LG CK ASTM A193 GR B8,0.75,,120,6,EA -BOLT_HEX,0.625 HEX BOLT 100.0000 LG ASTM A193/A194 GR B7/2H ELEC.GALV,0.625,,100,12,EA \ No newline at end of file diff --git a/test_bolt_upload.csv b/test_bolt_upload.csv deleted file mode 100644 index e952091..0000000 --- a/test_bolt_upload.csv +++ /dev/null @@ -1,6 +0,0 @@ -DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,QUANTITY,UNIT,DRAWING_NAME,AREA_CODE,LINE_NO -BLT_150_TK,"STUD BOLT, 0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",0.5,70.0000,8.0,EA,P&ID-001,#01,LINE-001-A -BLT_300_TK,"FLANGE BOLT, 3/4, 80.0000 LG, 300LB, ASTM A193/A194 GR B7/2H",3/4,80.0000,12.0,EA,P&ID-002,#02,LINE-002-B -BOLT_HEX_M16,"HEX BOLT, M16 X 60MM, GRADE 8.8, ZINC PLATED",M16,60.0000,10.0,EA,P&ID-003,#03,LINE-003-C -STUD_M20,"STUD BOLT, M20 X 100MM, ASTM A193 B7, 600LB",M20,100.0000,6.0,EA,P&ID-004,#04,LINE-004-D -NUT_HEX_M16,"HEX NUT, M16, ASTM A194 2H",M16,,16.0,EA,P&ID-003,#03,LINE-003-C \ No newline at end of file diff --git a/test_flange_bolt.csv b/test_flange_bolt.csv deleted file mode 100644 index e419231..0000000 --- a/test_flange_bolt.csv +++ /dev/null @@ -1,6 +0,0 @@ -DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,LENGTH,QUANTITY,UNIT -FLANGE_BOLT,FLANGE BOLT 6" 300LB M16 X 80MM ASTM A193 B7 ELECTRO GALVANIZED,6,,80,20,EA -FLANGE_BOLT,FLANGE BOLT 1-1/2" 150LB 5/8" X 100MM PSV ASTM A193 B7 ELECTRO GALVANIZED,1.5,,100,8,EA -FLANGE_BOLT,FLANGE BOLT 8" 150LB 3/4" X 130MM ASTM A193 B7 ELECTRO GALVANIZED,8,,130,12,EA -FLANGE_BOLT,FLANGE BOLT 4" 150LB 0.625" X 120MM ASTM A193 B7 ELECTRO GALVANIZED,4,,120,15,EA -FLANGE_BOLT,FLANGE BOLT 2" 300LB 1-1/2" X 180MM ASTM A193 B7 ELECTRO GALVANIZED,2,,180,5,EA \ No newline at end of file diff --git a/test_purchase_calculator.py b/test_purchase_calculator.py deleted file mode 100644 index cf3b7a5..0000000 --- a/test_purchase_calculator.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -""" -๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ๊ธฐ ํ…Œ์ŠคํŠธ -ํŠนํžˆ ํŒŒ์ดํ”„ ์ ˆ๋‹จ ์†์‹ค + 6M ๋‹จ์œ„ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ -""" - -import sys -import os -sys.path.append('backend') - -def test_pipe_calculation(): - """ํŒŒ์ดํ”„ ์ ˆ๋‹จ ์†์‹ค + 6M ๋‹จ์œ„ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ""" - - from app.services.purchase_calculator import calculate_pipe_purchase_quantity - - print("๐Ÿ”ง PIPE ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ\n") - - # ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋“ค - test_cases = [ - { - "name": "25,000mm ํ•„์š” (10ํšŒ ์ ˆ๋‹จ)", - "materials": [ - {"length_mm": 3000, "original_description": "PIPE 4\" SCH40 - 3M", "quantity": 1}, - {"length_mm": 2500, "original_description": "PIPE 4\" SCH40 - 2.5M", "quantity": 1}, - {"length_mm": 1800, "original_description": "PIPE 4\" SCH40 - 1.8M", "quantity": 1}, - {"length_mm": 4200, "original_description": "PIPE 4\" SCH40 - 4.2M", "quantity": 1}, - {"length_mm": 2100, "original_description": "PIPE 4\" SCH40 - 2.1M", "quantity": 1}, - {"length_mm": 1500, "original_description": "PIPE 4\" SCH40 - 1.5M", "quantity": 1}, - {"length_mm": 3800, "original_description": "PIPE 4\" SCH40 - 3.8M", "quantity": 1}, - {"length_mm": 2200, "original_description": "PIPE 4\" SCH40 - 2.2M", "quantity": 1}, - {"length_mm": 1900, "original_description": "PIPE 4\" SCH40 - 1.9M", "quantity": 1}, - {"length_mm": 2000, "original_description": "PIPE 4\" SCH40 - 2M", "quantity": 1} - ], - "expected_pipes": 5 - }, - { - "name": "5,900mm ํ•„์š” (3ํšŒ ์ ˆ๋‹จ)", - "materials": [ - {"length_mm": 2000, "original_description": "PIPE 6\" SCH40 - 2M", "quantity": 1}, - {"length_mm": 1900, "original_description": "PIPE 6\" SCH40 - 1.9M", "quantity": 1}, - {"length_mm": 2000, "original_description": "PIPE 6\" SCH40 - 2M", "quantity": 1} - ], - "expected_pipes": 1 - }, - { - "name": "12,000mm ์ •ํ™•ํžˆ (4ํšŒ ์ ˆ๋‹จ)", - "materials": [ - {"length_mm": 3000, "original_description": "PIPE 8\" SCH40 - 3M", "quantity": 4} - ], - "expected_pipes": 2 - } - ] - - for i, test_case in enumerate(test_cases, 1): - print(f"๐Ÿ“‹ ํ…Œ์ŠคํŠธ {i}: {test_case['name']}") - - result = calculate_pipe_purchase_quantity(test_case["materials"]) - - print(f" ๐ŸŽฏ BOM ์ด ๊ธธ์ด: {result['bom_quantity']:,}mm") - print(f" โœ‚๏ธ ์ ˆ๋‹จ ํšŸ์ˆ˜: {result['cutting_count']}ํšŒ") - print(f" ๐Ÿ“ ์ ˆ๋‹จ ์†์‹ค: {result['cutting_loss']}mm (๊ฐ ์ ˆ๋‹จ๋งˆ๋‹ค 3mm)") - print(f" ๐Ÿ”ข ์ด ํ•„์š”๋Ÿ‰: {result['required_length']:,}mm") - print(f" ๐Ÿ“ฆ ๊ตฌ๋งค ํŒŒ์ดํ”„: {result['pipes_count']}๋ณธ (๊ฐ 6M)") - print(f" ๐Ÿ’ฐ ๊ตฌ๋งค ์ด๋Ÿ‰: {result['calculated_qty']:,}mm") - print(f" โ™ป๏ธ ์—ฌ์œ ๋ถ„: {result['waste_length']:,}mm") - print(f" ๐Ÿ“Š ํ™œ์šฉ๋ฅ : {result['utilization_rate']:.1f}%") - - # ๊ฒฐ๊ณผ ํ™•์ธ - if result['pipes_count'] == test_case['expected_pipes']: - print(f" โœ… ์„ฑ๊ณต: ์˜ˆ์ƒ {test_case['expected_pipes']}๋ณธ = ๊ฒฐ๊ณผ {result['pipes_count']}๋ณธ") - else: - print(f" โŒ ์‹คํŒจ: ์˜ˆ์ƒ {test_case['expected_pipes']}๋ณธ โ‰  ๊ฒฐ๊ณผ {result['pipes_count']}๋ณธ") - - print() - -def test_standard_calculation(): - """์ผ๋ฐ˜ ์ž์žฌ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ""" - - from app.services.purchase_calculator import calculate_standard_purchase_quantity - - print("๐Ÿ”ง ์ผ๋ฐ˜ ์ž์žฌ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ\n") - - test_cases = [ - {"category": "VALVE", "bom_qty": 2, "expected_factor": 1.5, "desc": "๋ฐธ๋ธŒ 2๊ฐœ (50% ์˜ˆ๋น„ํ’ˆ)"}, - {"category": "BOLT", "bom_qty": 24, "expected_min": 50, "desc": "๋ณผํŠธ 24๊ฐœ (๋ฐ•์Šค ๋‹จ์œ„ 50๊ฐœ)"}, - {"category": "FITTING", "bom_qty": 5, "expected_factor": 1.1, "desc": "ํ”ผํŒ… 5๊ฐœ (10% ์—ฌ์œ )"}, - {"category": "GASKET", "bom_qty": 3, "expected_factor": 1.25, "desc": "๊ฐ€์Šค์ผ“ 3๊ฐœ (25% ๊ต์ฒด ์ฃผ๊ธฐ)"}, - {"category": "INSTRUMENT", "bom_qty": 1, "expected_factor": 1.0, "desc": "๊ณ„๊ธฐ 1๊ฐœ (์ •ํ™•ํ•œ ์ˆ˜๋Ÿ‰)"} - ] - - for i, test_case in enumerate(test_cases, 1): - print(f"๐Ÿ“‹ ํ…Œ์ŠคํŠธ {i}: {test_case['desc']}") - - result = calculate_standard_purchase_quantity( - test_case["category"], - test_case["bom_qty"] - ) - - print(f" ๐ŸŽฏ BOM ์ˆ˜๋Ÿ‰: {result['bom_quantity']}") - print(f" ๐Ÿ“ˆ ์—ฌ์œ ์œจ: {result['safety_factor']:.2f} ({(result['safety_factor']-1)*100:.0f}%)") - print(f" ๐Ÿ”ข ์—ฌ์œ  ์ ์šฉ: {result['safety_qty']:.1f}") - print(f" ๐Ÿ“ฆ ์ตœ์†Œ ์ฃผ๋ฌธ: {result['min_order_qty']}") - print(f" ๐Ÿ’ฐ ์ตœ์ข… ๊ตฌ๋งค: {result['calculated_qty']}") - print(f" โ™ป๏ธ ์—ฌ์œ ๋ถ„: {result['waste_quantity']}") - print(f" ๐Ÿ“Š ํ™œ์šฉ๋ฅ : {result['utilization_rate']:.1f}%") - print() - -if __name__ == "__main__": - test_pipe_calculation() - test_standard_calculation() \ No newline at end of file diff --git a/test_upload.csv b/test_upload.csv deleted file mode 100644 index d56a59e..0000000 --- a/test_upload.csv +++ /dev/null @@ -1,3 +0,0 @@ -description,qty,main_nom -PIPE A,10,4 -FITTING B,5,2 diff --git a/test_valve_bom.csv b/test_valve_bom.csv deleted file mode 100644 index 3edb28e..0000000 --- a/test_valve_bom.csv +++ /dev/null @@ -1,11 +0,0 @@ -DESCRIPTION,QTY,MAIN_NOM -"GATE VALVE, 150LB, FL, 4"", ASTM A216 WCB, RF",2,4" -"BALL VALVE, 300LB, THREADED, 2"", SS316, FULL PORT",3,2" -"GLOBE VALVE, 600LB, SW, 1"", A105, Y-TYPE",1,1" -"CHECK VALVE, 150LB, WAFER, 6"", DCI, DUAL PLATE",4,6" -"BUTTERFLY VALVE, 150LB, WAFER, 12"", DCI, GEAR OPERATED",1,12" -"NEEDLE VALVE, 6000LB, SW, 1/2"", A182 F316, FINE ADJUST",5,1/2" -"RELIEF VALVE, PSV, 150LB, FL, 3"", A216 WCB, SET 150 PSI",2,3" -"SOLENOID VALVE, 24VDC, 150LB, THD, 1/4"", SS316, 2-WAY NC",8,1/4" -"GATE VALVE, 300LB, BW, 8"", A216 WCB, RTJ",1,8" -"BALL VALVE, 600LB, SW, 1-1/2"", A182 F316, FIRE SAFE",2,1-1/2" \ No newline at end of file diff --git a/test_valve_classifier.py b/test_valve_classifier.py deleted file mode 100644 index 7885531..0000000 --- a/test_valve_classifier.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -""" -๋ฐธ๋ธŒ ๋ถ„๋ฅ˜๊ธฐ ํ…Œ์ŠคํŠธ -์‹ค์ œ ๋ฐธ๋ธŒ ๋ฐ์ดํ„ฐ๋กœ ๋ถ„๋ฅ˜ ์„ฑ๋Šฅ ํ™•์ธ -""" - -import sys -import os -sys.path.append('backend') - -from app.services.valve_classifier import classify_valve - -def test_valve_classifier(): - """๋‹ค์–‘ํ•œ ๋ฐธ๋ธŒ ๋ฐ์ดํ„ฐ๋กœ ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ""" - - print("๐Ÿ”ง ๋ฐธ๋ธŒ ๋ถ„๋ฅ˜๊ธฐ ํ…Œ์ŠคํŠธ ์‹œ์ž‘\n") - - # ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ (์‹ค์ œ BOM์—์„œ ๊ฐ€์ ธ์˜จ ๋ฐ์ดํ„ฐ) - test_valves = [ - { - "description": "GATE VALVE, 150LB, FL, 4\", ASTM A216 WCB, RF", - "main_nom": "4\"", - "expected": "GATE_VALVE" - }, - { - "description": "BALL VALVE, 300LB, THREADED, 2\", SS316, FULL PORT", - "main_nom": "2\"", - "expected": "BALL_VALVE" - }, - { - "description": "GLOBE VALVE, 600LB, SW, 1\", A105, Y-TYPE", - "main_nom": "1\"", - "expected": "GLOBE_VALVE" - }, - { - "description": "CHECK VALVE, 150LB, WAFER, 6\", DCI, DUAL PLATE", - "main_nom": "6\"", - "expected": "CHECK_VALVE" - }, - { - "description": "BUTTERFLY VALVE, 150LB, WAFER, 12\", DCI, GEAR OPERATED", - "main_nom": "12\"", - "expected": "BUTTERFLY_VALVE" - }, - { - "description": "NEEDLE VALVE, 6000LB, SW, 1/2\", A182 F316, FINE ADJUST", - "main_nom": "1/2\"", - "expected": "NEEDLE_VALVE" - }, - { - "description": "RELIEF VALVE, PSV, 150LB, FL, 3\", A216 WCB, SET 150 PSI", - "main_nom": "3\"", - "expected": "RELIEF_VALVE" - }, - { - "description": "SOLENOID VALVE, 24VDC, 150LB, THD, 1/4\", SS316, 2-WAY NC", - "main_nom": "1/4\"", - "expected": "SOLENOID_VALVE" - } - ] - - total_tests = len(test_valves) - passed_tests = 0 - - for i, test_data in enumerate(test_valves, 1): - print(f"\n๐Ÿ“‹ ํ…Œ์ŠคํŠธ {i}/{total_tests}: {test_data['description'][:50]}...") - - # ๋ถ„๋ฅ˜ ์‹คํ–‰ - result = classify_valve("", test_data["description"], test_data["main_nom"]) - - # ๊ฒฐ๊ณผ ์ถœ๋ ฅ - category = result.get("category", "UNKNOWN") - confidence = result.get("overall_confidence", 0.0) - valve_type = result.get("valve_type", {}).get("type", "UNKNOWN") - connection = result.get("connection_method", {}).get("method", "UNKNOWN") - pressure = result.get("pressure_rating", {}).get("rating", "UNKNOWN") - - print(f" โœ… ๋ถ„๋ฅ˜: {category}") - print(f" ๐Ÿ”ง ๋ฐธ๋ธŒํƒ€์ž…: {valve_type}") - print(f" ๐Ÿ”— ์—ฐ๊ฒฐ๋ฐฉ์‹: {connection}") - print(f" ๐Ÿ“Š ์••๋ ฅ๋“ฑ๊ธ‰: {pressure}") - print(f" ๐ŸŽฏ ์‹ ๋ขฐ๋„: {confidence:.2f}") - - # ์„ฑ๊ณต ์—ฌ๋ถ€ ํ™•์ธ - if category == "VALVE" and valve_type == test_data["expected"]: - print(f" โœ… ์„ฑ๊ณต: ์˜ˆ์ƒ({test_data['expected']}) = ๊ฒฐ๊ณผ({valve_type})") - passed_tests += 1 - else: - print(f" โŒ ์‹คํŒจ: ์˜ˆ์ƒ({test_data['expected']}) โ‰  ๊ฒฐ๊ณผ({valve_type})") - # ์‹คํŒจ ์›์ธ ๋ถ„์„ - evidence = result.get("valve_type", {}).get("evidence", []) - print(f" ์ฆ๊ฑฐ: {evidence}") - - # ์ตœ์ข… ๊ฒฐ๊ณผ - success_rate = (passed_tests / total_tests) * 100 - print(f"\n๐ŸŽ‰ ํ…Œ์ŠคํŠธ ์™„๋ฃŒ!") - print(f"๐Ÿ“Š ์„ฑ๊ณต๋ฅ : {passed_tests}/{total_tests} ({success_rate:.1f}%)") - - if success_rate >= 80: - print("โœ… ๋ฐธ๋ธŒ ๋ถ„๋ฅ˜๊ธฐ ์„ฑ๋Šฅ ์–‘ํ˜ธ!") - elif success_rate >= 60: - print("โš ๏ธ ๋ฐธ๋ธŒ ๋ถ„๋ฅ˜๊ธฐ ์„ฑ๋Šฅ ๋ณดํ†ต - ๊ฐœ์„  ํ•„์š”") - else: - print("โŒ ๋ฐธ๋ธŒ ๋ถ„๋ฅ˜๊ธฐ ์„ฑ๋Šฅ ๋ถˆ๋Ÿ‰ - ๋Œ€ํญ ๊ฐœ์„  ํ•„์š”") - -def test_special_valve_cases(): - """ํŠน์ˆ˜ํ•œ ๋ฐธ๋ธŒ ์ผ€์ด์Šค ํ…Œ์ŠคํŠธ""" - - print("\n๐Ÿ” ํŠน์ˆ˜ ๋ฐธ๋ธŒ ์ผ€์ด์Šค ํ…Œ์ŠคํŠธ\n") - - special_cases = [ - { - "description": "๋ฐธ๋ธŒ ์—†๋Š” ํŒŒ์ดํ”„", - "data": "PIPE, 4\", SCH40, ASTM A106 GR B, SMLS", - "main_nom": "4\"", - "should_reject": True - }, - { - "description": "๋ฐธ๋ธŒ ํ‚ค์›Œ๋“œ๋งŒ ์žˆ๋Š” ์• ๋งคํ•œ ์ผ€์ด์Šค", - "data": "VALVE HOUSING GASKET, 4\", GRAPHITE", - "main_nom": "4\"", - "should_reject": True - }, - { - "description": "๋ณตํ•ฉ ๋ฐธ๋ธŒ (์—ฌ๋Ÿฌ ํƒ€์ž… ํ˜ผ์žฌ)", - "data": "GATE BALL VALVE ASSEMBLY, 150LB, 2\"", - "main_nom": "2\"", - "should_reject": False - } - ] - - for case in special_cases: - print(f"๐Ÿ“ {case['description']}: {case['data']}") - - result = classify_valve("", case["data"], case["main_nom"]) - - category = result.get("category", "UNKNOWN") - confidence = result.get("overall_confidence", 0.0) - - print(f" ๊ฒฐ๊ณผ: {category} (์‹ ๋ขฐ๋„: {confidence:.2f})") - - if case["should_reject"]: - if category == "UNKNOWN" or confidence < 0.5: - print(" โœ… ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฑฐ๋ถ€๋จ") - else: - print(" โŒ ์ž˜๋ชป ์ˆ˜์šฉ๋จ") - else: - if category == "VALVE" and confidence >= 0.5: - print(" โœ… ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ˆ˜์šฉ๋จ") - else: - print(" โŒ ์ž˜๋ชป ๊ฑฐ๋ถ€๋จ") - print() - -if __name__ == "__main__": - test_valve_classifier() - test_special_valve_cases() \ No newline at end of file