From 3d2dea61184038e8a4a91d77ac9534c7db61b40a Mon Sep 17 00:00:00 2001 From: kent Date: Thu, 8 Jan 2026 20:17:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20Phase=203=20-=20Frontend=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createOrderFromQuote(): 견적→수주 변환 API 호출 - createProductionOrder(): 생산지시 생성 API 호출 - WorkOrder 타입 및 변환 함수 추가 - 변경 내역 문서 작성 --- ...20250108_order_phase3_advanced_features.md | 113 +++++++++++ src/components/orders/actions.ts | 180 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 claudedocs/changes/20250108_order_phase3_advanced_features.md diff --git a/claudedocs/changes/20250108_order_phase3_advanced_features.md b/claudedocs/changes/20250108_order_phase3_advanced_features.md new file mode 100644 index 00000000..208d7ad4 --- /dev/null +++ b/claudedocs/changes/20250108_order_phase3_advanced_features.md @@ -0,0 +1,113 @@ +# 수주 관리 Phase 3 - 고급 기능 + +**날짜:** 2025-01-08 +**Phase:** Phase 3 - 고급 기능 +**관련 Plan:** docs/plans/order-management-plan.md + +## 변경 개요 + +수주 관리 시스템에 견적→수주 변환 및 생산지시 생성 기능 추가. + +## API 추가 사항 + +### 1. 견적에서 수주 생성 +- **Endpoint**: `POST /api/v1/orders/from-quote/{quoteId}` +- **기능**: 기존 견적서를 기반으로 수주를 자동 생성 +- **검증**: 이미 수주가 생성된 견적은 중복 생성 방지 + +### 2. 생산지시 생성 +- **Endpoint**: `POST /api/v1/orders/{id}/production-order` +- **기능**: 확정된 수주에서 작업지시(WorkOrder) 생성 +- **검증**: CONFIRMED 상태의 수주만 생산지시 가능 + +## 수정된 파일 + +### API (Laravel) + +#### 1. `app/Services/OrderService.php` +- `createFromQuote(int $quoteId, array $data)`: 견적→수주 변환 로직 +- `createProductionOrder(int $orderId, array $data)`: 생산지시 생성 로직 +- `generateWorkOrderNo(int $tenantId)`: 작업지시번호 자동 생성 + +#### 2. `app/Http/Controllers/Api/V1/OrderController.php` +- `createFromQuote()`: 견적→수주 액션 +- `createProductionOrder()`: 생산지시 생성 액션 + +#### 3. `app/Http/Requests/Order/CreateFromQuoteRequest.php` (신규) +- 견적→수주 변환 요청 검증 +- 선택 필드: delivery_date, memo + +#### 4. `app/Http/Requests/Order/CreateProductionOrderRequest.php` (신규) +- 생산지시 생성 요청 검증 +- 선택 필드: process_type, assignee_id, team_id, scheduled_date, memo + +#### 5. `routes/api.php` +- `POST /orders/from-quote/{quoteId}`: 견적→수주 라우트 +- `POST /orders/{id}/production-order`: 생산지시 라우트 + +#### 6. `lang/ko/message.php` +- `order.created_from_quote`: 견적에서 수주가 생성되었습니다. +- `order.production_order_created`: 생산지시가 생성되었습니다. + +#### 7. `lang/ko/error.php` +- `order.already_created_from_quote`: 이미 해당 견적에서 수주가 생성되었습니다. +- `order.must_be_confirmed_for_production`: 확정 상태의 수주만 생산지시를 생성할 수 있습니다. +- `order.production_order_already_exists`: 이미 생산지시가 존재합니다. +- `quote.not_found`: 견적을 찾을 수 없습니다. + +### Frontend (React) + +#### 1. `src/components/orders/actions.ts` +- 타입 추가: `CreateFromQuoteData`, `CreateProductionOrderData`, `WorkOrder`, `ProductionOrderResult` +- API 인터페이스 추가: `ApiWorkOrder`, `ApiProductionOrderResponse` +- `createOrderFromQuote(quoteId, data)`: 견적→수주 API 호출 +- `createProductionOrder(orderId, data)`: 생산지시 생성 API 호출 +- `transformWorkOrderApiToFrontend()`: WorkOrder 데이터 변환 + +## 비즈니스 로직 + +### 견적→수주 변환 흐름 +``` +Quote (견적) + ↓ createFromQuote() +Order (수주) - DRAFT 상태 + - quote_id 연결 + - client, site_name 복사 + - items 변환 (quantity=calculated_quantity) + - 금액 재계산 +``` + +### 생산지시 생성 흐름 +``` +Order (수주) - CONFIRMED 상태 + ↓ createProductionOrder() +WorkOrder (작업지시) - PENDING 상태 + - sales_order_id 연결 + - project_name = site_name + - process_type 설정 + ↓ +Order 상태 → IN_PROGRESS +``` + +### 상태 전환 규칙 (기존) +``` +DRAFT → CONFIRMED → IN_PROGRESS → COMPLETED + ↓ ↓ ↓ +CANCELLED (어느 단계에서든 취소 가능) +``` + +## 테스트 체크리스트 + +- [ ] 견적→수주 생성 (정상 케이스) +- [ ] 견적→수주 생성 (중복 방지) +- [ ] 견적→수주 생성 (존재하지 않는 견적) +- [ ] 생산지시 생성 (정상 케이스) +- [ ] 생산지시 생성 (CONFIRMED 아닌 수주) +- [ ] 생산지시 생성 (중복 방지) +- [ ] 수주 상태 자동 변경 (CONFIRMED → IN_PROGRESS) + +## 연관 작업 + +- Phase 1: Order API 백엔드 구현 (커밋: de19ac9) +- Phase 2: Frontend API 연동 (커밋: 572ffe8) +- Phase 3: 고급 기능 (현재) diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index e2c8dfaa..6083efc7 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -66,9 +66,34 @@ interface ApiClient { interface ApiQuote { id: number; quote_no: string; + quote_number?: string; site_name: string | null; } +interface ApiWorkOrder { + id: number; + tenant_id: number; + work_order_no: string; + sales_order_id: number; + project_name: string | null; + process_type: string; + status: string; + assignee_id: number | null; + team_id: number | null; + scheduled_date: string | null; + memo: string | null; + is_active: boolean; + created_at: string; + updated_at: string; + assignee?: { id: number; name: string } | null; + team?: { id: number; name: string } | null; +} + +interface ApiProductionOrderResponse { + work_order: ApiWorkOrder; + order: ApiOrder; +} + interface ApiOrderStats { total: number; draft: number; @@ -188,6 +213,46 @@ export interface OrderStats { confirmedAmount: number; } +// 견적→수주 변환용 +export interface CreateFromQuoteData { + deliveryDate?: string; + memo?: string; +} + +// 생산지시 생성용 +export interface CreateProductionOrderData { + processType?: 'screen' | 'slat' | 'bending'; + assigneeId?: number; + teamId?: number; + scheduledDate?: string; + memo?: string; +} + +// 생산지시(작업지시) 타입 +export interface WorkOrder { + id: string; + workOrderNo: string; + salesOrderId: number; + projectName: string | null; + processType: string; + status: string; + assigneeId?: number; + assigneeName?: string; + teamId?: number; + teamName?: string; + scheduledDate?: string; + memo?: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +// 생산지시 생성 결과 +export interface ProductionOrderResult { + workOrder: WorkOrder; + order: Order; +} + // ============================================================================ // 상태 매핑 // ============================================================================ @@ -287,6 +352,26 @@ function transformFrontendToApi(data: OrderFormData): Record { }; } +function transformWorkOrderApiToFrontend(apiData: ApiWorkOrder): WorkOrder { + return { + id: String(apiData.id), + workOrderNo: apiData.work_order_no, + salesOrderId: apiData.sales_order_id, + projectName: apiData.project_name, + processType: apiData.process_type, + status: apiData.status, + assigneeId: apiData.assignee_id ?? undefined, + assigneeName: apiData.assignee?.name ?? undefined, + teamId: apiData.team_id ?? undefined, + teamName: apiData.team?.name ?? undefined, + scheduledDate: apiData.scheduled_date ?? undefined, + memo: apiData.memo ?? undefined, + isActive: apiData.is_active, + createdAt: apiData.created_at, + updatedAt: apiData.updated_at, + }; +} + // ============================================================================ // API 함수 // ============================================================================ @@ -628,3 +713,98 @@ export async function deleteOrders(ids: string[]): Promise<{ return { success: false, error: '서버 오류가 발생했습니다.' }; } } + +/** + * 견적에서 수주 생성 + */ +export async function createOrderFromQuote( + quoteId: number, + data?: CreateFromQuoteData +): Promise<{ + success: boolean; + data?: Order; + error?: string; + __authError?: boolean; +}> { + try { + const apiData: Record = {}; + if (data?.deliveryDate) apiData.delivery_date = data.deliveryDate; + if (data?.memo) apiData.memo = data.memo; + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/from-quote/${quoteId}`, + { method: 'POST', body: JSON.stringify(apiData) } + ); + + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '수주 생성에 실패했습니다.' }; + } + + const result: ApiResponse = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || '수주 생성에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; + } catch (error) { + console.error('[createOrderFromQuote] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 생산지시 생성 + */ +export async function createProductionOrder( + orderId: string, + data?: CreateProductionOrderData +): Promise<{ + success: boolean; + data?: ProductionOrderResult; + error?: string; + __authError?: boolean; +}> { + try { + const apiData: Record = {}; + if (data?.processType) apiData.process_type = data.processType; + if (data?.assigneeId) apiData.assignee_id = data.assigneeId; + if (data?.teamId) apiData.team_id = data.teamId; + if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate; + if (data?.memo) apiData.memo = data.memo; + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/${orderId}/production-order`, + { method: 'POST', body: JSON.stringify(apiData) } + ); + + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '생산지시 생성에 실패했습니다.' }; + } + + const result: ApiResponse = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || '생산지시 생성에 실패했습니다.' }; + } + + return { + success: true, + data: { + workOrder: transformWorkOrderApiToFrontend(result.data.work_order), + order: transformApiToFrontend(result.data.order), + }, + }; + } catch (error) { + console.error('[createProductionOrder] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +}