feat: 수주 관리 Phase 3 - Frontend API 연동
- createOrderFromQuote(): 견적→수주 변환 API 호출 - createProductionOrder(): 생산지시 생성 API 호출 - WorkOrder 타입 및 변환 함수 추가 - 변경 내역 문서 작성
This commit is contained in:
113
claudedocs/changes/20250108_order_phase3_advanced_features.md
Normal file
113
claudedocs/changes/20250108_order_phase3_advanced_features.md
Normal file
@@ -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: 고급 기능 (현재)
|
||||||
@@ -66,9 +66,34 @@ interface ApiClient {
|
|||||||
interface ApiQuote {
|
interface ApiQuote {
|
||||||
id: number;
|
id: number;
|
||||||
quote_no: string;
|
quote_no: string;
|
||||||
|
quote_number?: string;
|
||||||
site_name: string | null;
|
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 {
|
interface ApiOrderStats {
|
||||||
total: number;
|
total: number;
|
||||||
draft: number;
|
draft: number;
|
||||||
@@ -188,6 +213,46 @@ export interface OrderStats {
|
|||||||
confirmedAmount: number;
|
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<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 함수
|
// API 함수
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -628,3 +713,98 @@ export async function deleteOrders(ids: string[]): Promise<{
|
|||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
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<string, unknown> = {};
|
||||||
|
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<ApiOrder> = 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<string, unknown> = {};
|
||||||
|
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<ApiProductionOrderResponse> = 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: '서버 오류가 발생했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user