diff --git a/claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md b/claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md new file mode 100644 index 00000000..4e75315b --- /dev/null +++ b/claudedocs/construction/[IMPL-2026-01-09] partner-management-api-integration.md @@ -0,0 +1,117 @@ +# Phase 2.2 거래처관리 API 연동 + +**날짜**: 2026-01-09 +**작업**: 거래처관리 Mock → API 연동 + +## 개요 + +시공사 페이지 API 연동 계획 Phase 2.2 - 거래처관리(partners) API 연동 완료. + +## 변경 사항 + +### Backend (API) + +#### 1. 서비스 (ClientService.php) +- `stats()` - 거래처 통계 조회 (신규) + - total: 전체 거래처 수 + - sales: 판매 거래처 (client_type='SALES') + - purchase: 구매 거래처 (client_type='PURCHASE') + - both: 판매/구매 거래처 (client_type='BOTH') + - badDebt: 악성채권 보유 거래처 수 + - normal: 정상 거래처 수 +- `bulkDestroy()` - 일괄 삭제 (신규) + - 주문 존재 시 해당 거래처는 건너뜀 + +#### 2. 컨트롤러 (ClientController.php) +- `stats()` - GET /api/v1/clients/stats +- `bulkDestroy()` - DELETE /api/v1/clients/bulk + +#### 3. 라우트 (api.php) +```php +Route::get('/stats', [ClientController::class, 'stats']); +Route::delete('/bulk', [ClientController::class, 'bulkDestroy']); +``` + +### Frontend (React) + +#### 1. actions.ts +- Mock 데이터 제거 (mockPartners 배열) +- API 연동 구현 + - `getPartnerList()` - GET /api/v1/clients + - `getPartner()` - GET /api/v1/clients/{id} + - `createPartner()` - POST /api/v1/clients + - `updatePartner()` - PUT /api/v1/clients/{id} + - `getPartnerStats()` - GET /api/v1/clients/stats + - `deletePartner()` - DELETE /api/v1/clients/{id} + - `deletePartners()` - DELETE /api/v1/clients/bulk + +#### 2. 변환 함수 +- `transformClientType()` - client_type → partnerType 변환 +- `transformPartnerType()` - partnerType → client_type 변환 +- `transformPartner()` - API 응답 → Partner 타입 변환 +- `transformPartnerToApi()` - PartnerFormData → API 요청 데이터 변환 + +## API 매핑 + +| Frontend | Backend | 비고 | +|----------|---------|------| +| id | id | string ↔ int | +| partnerCode | client_code | 자동 생성 | +| businessNumber | business_no | | +| partnerName | name | | +| representative | contact_person | | +| partnerType | client_type | sales/SALES, purchase/PURCHASE, both/BOTH | +| businessType | business_type | | +| businessCategory | business_item | | +| address1 | address | | +| phone | phone | | +| mobile | mobile | | +| fax | fax | | +| email | email | | +| manager | manager_name | | +| managerPhone | manager_tel | | +| systemManager | system_manager | | +| outstandingAmount | outstanding_amount | 계산 필드 (매출-입금) | +| overdueToggle | is_overdue | | +| isBadDebt | has_bad_debt | 계산 필드 | +| isActive | is_active | | +| createdAt | created_at | | +| updatedAt | updated_at | | + +### Frontend 전용 필드 (기본값 사용) +- zipCode, address2: '' +- logoUrl, logoBlob: null +- salesPaymentDay, paymentDay: 0 +- creditRating, transactionGrade: '' +- memos, documents: [] +- category: '' +- overdueDays: is_overdue ? 30 : 0 + +## 설계 결정 + +### 기존 Client API 재사용 +- `/api/v1/clients` 기존 엔드포인트 확장 사용 +- 별도의 `/api/v1/construction/partners` 생성하지 않음 +- accounting/vendors 와 construction/partners 모두 Client API 사용 + +### 악성채권 통계 +- BadDebt 테이블과 연계하여 악성채권 보유 거래처 수 계산 +- 상태가 '추심중' 또는 '법적조치'인 활성 악성채권만 카운트 + +### 필터링 전략 +- 검색(`q`): API에서 처리 (name, client_code, contact_person) +- 악성채권 필터: 프론트엔드에서 처리 (API 전체 반환 후 필터) +- 정렬: 프론트엔드에서 처리 (API 기본 정렬 사용) + +## 진행률 + +시공사 API 연동: 4/9 (44%) +- [x] Phase 1.1 견적관리 +- [x] Phase 1.2 인수인계보고서관리 +- [x] Phase 2.1 현장관리 +- [x] Phase 2.2 거래처관리 ← 현재 완료 +- [ ] Phase 2.3 자재관리 +- [ ] Phase 3.1 발주관리 +- [ ] Phase 3.2 재고관리 +- [ ] Phase 4.1 정산관리 +- [ ] Phase 4.2 급여관리 \ No newline at end of file diff --git a/src/components/business/construction/partners/actions.ts b/src/components/business/construction/partners/actions.ts index 3f9baada..ec09ce55 100644 --- a/src/components/business/construction/partners/actions.ts +++ b/src/components/business/construction/partners/actions.ts @@ -1,193 +1,241 @@ 'use server'; +import { cookies } from 'next/headers'; import type { Partner, PartnerStats, PartnerFilter, PartnerListResponse, PartnerFormData } from './types'; /** * 주일 기업 - 거래처 관리 Server Actions - * TODO: 실제 API 연동 시 구현 + * API 연동 버전 */ -// 목업 데이터 (확장된 타입 적용) -const mockPartners: Partner[] = [ - { - id: '1', - partnerCode: 'P-001', - businessNumber: '123-12-12345', - partnerName: '대한건설', - representative: '홍길동', - partnerType: 'sales', - businessType: '건설업', - businessCategory: '토목건축', - zipCode: '06234', - address1: '서울특별시 서초구 서초대로 123', - address2: '대한건물 12층 1201호', - phone: '02-1234-1234', - mobile: '010-1234-1234', - fax: '02-1234-1235', - email: 'abc@email.com', - manager: '담당자명', - managerPhone: '010-1234-1234', - systemManager: '관리자명', - logoUrl: null, - logoBlob: null, - salesPaymentDay: 15, - creditRating: 'AAA', - transactionGrade: 'A', - taxInvoiceEmail: 'abc@email.com', - outstandingAmount: 11000000, - overdueDays: 15, - overdueToggle: true, - badDebtToggle: false, - memos: [ - { - id: '1', - content: '2025-12-12 12:21 [홍길동] 메모 내용', - createdAt: '2025-12-12T12:21:00Z', - }, - ], - documents: [], - category: '건설사', - paymentDay: 15, - isBadDebt: false, - isActive: true, - createdAt: '2025-01-01', - updatedAt: '2025-01-01', - }, - { - id: '2', - partnerCode: 'P-002', - businessNumber: '456-45-45678', - partnerName: '삼성시공', - representative: '김철수', - partnerType: 'purchase', - businessType: '시공업', - businessCategory: '건축시공', - zipCode: '06235', - address1: '서울특별시 강남구 테헤란로 456', - address2: '삼성빌딩 5층', - phone: '02-5678-5678', - mobile: '010-5678-5678', - fax: '02-5678-5679', - email: 'samsung@email.com', - manager: '이영희', - managerPhone: '010-5678-5678', - systemManager: '', - logoUrl: null, - logoBlob: null, - salesPaymentDay: 10, - creditRating: 'AA', - transactionGrade: 'B', - taxInvoiceEmail: 'tax@samsung.com', - outstandingAmount: 5000000, - overdueDays: 0, - overdueToggle: false, - badDebtToggle: false, - memos: [], - documents: [], - category: '시공사', - paymentDay: 10, - isBadDebt: false, - isActive: true, - createdAt: '2025-01-02', - updatedAt: '2025-01-02', - }, - { - id: '3', - partnerCode: 'P-003', - businessNumber: '789-78-78901', - partnerName: 'LG건설', - representative: '박영수', - partnerType: 'both', - businessType: '종합건설', - businessCategory: '건설', - zipCode: '06236', - address1: '서울특별시 영등포구 여의대로 789', - address2: 'LG타워 20층', - phone: '02-7890-7890', - mobile: '010-7890-7890', - fax: '02-7890-7891', - email: 'lg@email.com', - manager: '최민수', - managerPhone: '010-7890-7890', - systemManager: '시스템관리자', - logoUrl: null, - logoBlob: null, - salesPaymentDay: 20, - creditRating: 'BBB', - transactionGrade: 'C', - taxInvoiceEmail: 'tax@lg.com', - outstandingAmount: 20000000, - overdueDays: 30, - overdueToggle: true, - badDebtToggle: true, - memos: [], - documents: [], - category: '건설사', - paymentDay: 20, - isBadDebt: true, - isActive: true, - createdAt: '2025-01-03', - updatedAt: '2025-01-03', - }, -]; +// API 기본 URL +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.sam.kr'; +const API_KEY = process.env.API_KEY || ''; -// 거래처 목록 조회 +/** + * API 요청 헬퍼 함수 + */ +async function apiRequest( + endpoint: string, + options: RequestInit = {} +): Promise<{ success: boolean; data?: T; error?: string; message?: string }> { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('access_token')?.value; + + const headers: Record = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-API-KEY': API_KEY, + }; + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + const url = `${API_BASE_URL}/api/v1${endpoint}`; + console.log('🔵 [Partner API]', options.method || 'GET', url); + + const response = await fetch(url, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + + const result = await response.json(); + console.log('🔵 [Partner API] Response status:', response.status); + + if (!response.ok) { + return { + success: false, + error: result.message || `API 오류: ${response.status}`, + }; + } + + return { + success: result.success ?? true, + data: result.data, + message: result.message, + }; + } catch (error) { + console.error('API request error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', + }; + } +} + +/** + * client_type API → Frontend partnerType 변환 + */ +function transformClientType(clientType: string | null | undefined): Partner['partnerType'] { + const typeMap: Record = { + 'SALES': 'sales', + 'PURCHASE': 'purchase', + 'BOTH': 'both', + }; + return typeMap[clientType || ''] || 'sales'; +} + +/** + * partnerType Frontend → API client_type 변환 + */ +function transformPartnerType(partnerType: Partner['partnerType']): string { + const typeMap: Record = { + 'sales': 'SALES', + 'purchase': 'PURCHASE', + 'both': 'BOTH', + }; + return typeMap[partnerType] || 'SALES'; +} + +/** + * API 응답 → 프론트엔드 Partner 타입 변환 + */ +function transformPartner(apiData: Record): Partner { + return { + id: String(apiData.id), + partnerCode: String(apiData.client_code || ''), + businessNumber: String(apiData.business_no || ''), + partnerName: String(apiData.name || ''), + representative: String(apiData.contact_person || ''), + partnerType: transformClientType(apiData.client_type as string | null), + businessType: String(apiData.business_type || ''), + businessCategory: String(apiData.business_item || ''), + zipCode: '', // API에 없는 필드 + address1: String(apiData.address || ''), + address2: '', // API에 없는 필드 + phone: String(apiData.phone || ''), + mobile: String(apiData.mobile || ''), + fax: String(apiData.fax || ''), + email: String(apiData.email || ''), + manager: String(apiData.manager_name || ''), + managerPhone: String(apiData.manager_tel || ''), + systemManager: String(apiData.system_manager || ''), + logoUrl: null, // API에 없는 필드 + logoBlob: null, // API에 없는 필드 + salesPaymentDay: 0, // API에 없는 필드 + creditRating: '', // API에 없는 필드 + transactionGrade: '', // API에 없는 필드 + taxInvoiceEmail: String(apiData.email || ''), // 동일한 이메일 사용 + outstandingAmount: Number(apiData.outstanding_amount || 0), + overdueDays: apiData.is_overdue ? 30 : 0, // 연체 여부만 있음 + overdueToggle: Boolean(apiData.is_overdue), + badDebtToggle: Boolean(apiData.has_bad_debt), + memos: [], // API에 없는 필드 + documents: [], // API에 없는 필드 + category: '', // API에 없는 필드 + paymentDay: 0, // API에 없는 필드 + isBadDebt: Boolean(apiData.has_bad_debt), + isActive: apiData.is_active !== false, + createdAt: String(apiData.created_at || ''), + updatedAt: String(apiData.updated_at || ''), + }; +} + +/** + * 프론트엔드 PartnerFormData → API 요청 데이터 변환 + */ +function transformPartnerToApi(data: PartnerFormData): Record { + return { + business_no: data.businessNumber, + name: data.partnerName, + contact_person: data.representative, + client_type: transformPartnerType(data.partnerType), + business_type: data.businessType, + business_item: data.businessCategory, + address: data.address1 + (data.address2 ? ` ${data.address2}` : ''), + phone: data.phone, + mobile: data.mobile, + fax: data.fax, + email: data.email, + manager_name: data.manager, + manager_tel: data.managerPhone, + system_manager: data.systemManager, + is_overdue: data.overdueToggle, + is_active: true, + }; +} + +// ============================================================ +// API 연동 함수 +// ============================================================ + +/** + * 거래처 목록 조회 + */ export async function getPartnerList( filter?: PartnerFilter ): Promise<{ success: boolean; data?: PartnerListResponse; error?: string }> { try { - // TODO: 실제 API 호출 - let filtered = [...mockPartners]; + const queryParams = new URLSearchParams(); - // 검색 필터 + // 검색어 if (filter?.search) { - const search = filter.search.toLowerCase(); - filtered = filtered.filter( - (p) => - p.partnerName.toLowerCase().includes(search) || - p.partnerCode.toLowerCase().includes(search) || - p.representative.toLowerCase().includes(search) - ); + queryParams.append('q', filter.search); } - // 악성채권 필터 + // 악성채권 필터 (Frontend badDebtFilter → 백엔드는 별도 필터 없음, 목록에서 처리) + // API는 전체 데이터 반환, 프론트에서 필터링 + + // 페이지네이션 + if (filter?.page) queryParams.append('page', String(filter.page)); + if (filter?.size) queryParams.append('size', String(filter.size)); + + const queryString = queryParams.toString(); + const endpoint = `/clients${queryString ? `?${queryString}` : ''}`; + + const result = await apiRequest<{ + data: Record[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }>(endpoint); + + if (!result.success || !result.data) { + return { success: false, error: result.error || '거래처 목록 조회에 실패했습니다.' }; + } + + const apiData = result.data; + let items = (apiData.data || []).map(transformPartner); + + // 악성채권 필터 (프론트엔드에서 처리) if (filter?.badDebtFilter && filter.badDebtFilter !== 'all') { - filtered = filtered.filter((p) => + items = items.filter((p) => filter.badDebtFilter === 'badDebt' ? p.isBadDebt : !p.isBadDebt ); } - // 정렬 + // 정렬 (프론트엔드에서 처리 - API가 sort 미지원 시) if (filter?.sortBy) { switch (filter.sortBy) { case 'latest': - filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); break; case 'oldest': - filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + items.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); break; case 'nameAsc': - filtered.sort((a, b) => a.partnerName.localeCompare(b.partnerName)); + items.sort((a, b) => a.partnerName.localeCompare(b.partnerName)); break; case 'nameDesc': - filtered.sort((a, b) => b.partnerName.localeCompare(a.partnerName)); + items.sort((a, b) => b.partnerName.localeCompare(a.partnerName)); break; } } - const page = filter?.page ?? 1; - const size = filter?.size ?? 20; - const start = (page - 1) * size; - const paginatedItems = filtered.slice(start, start + size); - return { success: true, data: { - items: paginatedItems, - total: filtered.length, - page, - size, - totalPages: Math.ceil(filtered.length / size), + items, + total: apiData.total || 0, + page: apiData.current_page || 1, + size: apiData.per_page || 20, + totalPages: apiData.last_page || 1, }, }; } catch (error) { @@ -196,150 +244,102 @@ export async function getPartnerList( } } -// 거래처 상세 조회 +/** + * 거래처 상세 조회 + */ export async function getPartner( id: string ): Promise<{ success: boolean; data?: Partner; error?: string }> { try { - // TODO: 실제 API 호출 - const partner = mockPartners.find((p) => p.id === id); + const result = await apiRequest>(`/clients/${id}`); - if (!partner) { - return { success: false, error: '거래처를 찾을 수 없습니다.' }; + if (!result.success || !result.data) { + return { success: false, error: result.error || '거래처를 찾을 수 없습니다.' }; } - return { success: true, data: partner }; + return { success: true, data: transformPartner(result.data) }; } catch (error) { console.error('getPartner error:', error); return { success: false, error: '거래처 조회에 실패했습니다.' }; } } -// 거래처 등록 +/** + * 거래처 등록 + */ export async function createPartner( data: PartnerFormData ): Promise<{ success: boolean; data?: Partner; error?: string }> { try { - // TODO: 실제 API 호출 - console.log('Create partner:', data); + const apiData = transformPartnerToApi(data); - const newPartner: Partner = { - id: String(Date.now()), - partnerCode: `P-${String(mockPartners.length + 1).padStart(3, '0')}`, - businessNumber: data.businessNumber, - partnerName: data.partnerName, - representative: data.representative, - partnerType: data.partnerType, - businessType: data.businessType, - businessCategory: data.businessCategory, - zipCode: data.zipCode, - address1: data.address1, - address2: data.address2, - phone: data.phone, - mobile: data.mobile, - fax: data.fax, - email: data.email, - manager: data.manager, - managerPhone: data.managerPhone, - systemManager: data.systemManager, - logoUrl: data.logoUrl, - logoBlob: data.logoBlob, - salesPaymentDay: data.salesPaymentDay, - creditRating: data.creditRating, - transactionGrade: data.transactionGrade, - taxInvoiceEmail: data.taxInvoiceEmail, - outstandingAmount: data.outstandingAmount, - overdueDays: data.overdueDays, - overdueToggle: data.overdueToggle, - badDebtToggle: data.badDebtToggle, - memos: data.memos, - documents: data.documents, - category: data.category, - paymentDay: data.salesPaymentDay, - isBadDebt: data.badDebtToggle, - isActive: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; + const result = await apiRequest>('/clients', { + method: 'POST', + body: JSON.stringify(apiData), + }); - return { success: true, data: newPartner }; + if (!result.success || !result.data) { + return { success: false, error: result.error || '거래처 등록에 실패했습니다.' }; + } + + return { success: true, data: transformPartner(result.data) }; } catch (error) { console.error('createPartner error:', error); return { success: false, error: '거래처 등록에 실패했습니다.' }; } } -// 거래처 수정 +/** + * 거래처 수정 + */ export async function updatePartner( id: string, data: PartnerFormData ): Promise<{ success: boolean; data?: Partner; error?: string }> { try { - // TODO: 실제 API 호출 - console.log('Update partner:', id, data); + const apiData = transformPartnerToApi(data); - const existingPartner = mockPartners.find((p) => p.id === id); - if (!existingPartner) { - return { success: false, error: '거래처를 찾을 수 없습니다.' }; + const result = await apiRequest>(`/clients/${id}`, { + method: 'PUT', + body: JSON.stringify(apiData), + }); + + if (!result.success || !result.data) { + return { success: false, error: result.error || '거래처 수정에 실패했습니다.' }; } - const updatedPartner: Partner = { - ...existingPartner, - businessNumber: data.businessNumber, - partnerName: data.partnerName, - representative: data.representative, - partnerType: data.partnerType, - businessType: data.businessType, - businessCategory: data.businessCategory, - zipCode: data.zipCode, - address1: data.address1, - address2: data.address2, - phone: data.phone, - mobile: data.mobile, - fax: data.fax, - email: data.email, - manager: data.manager, - managerPhone: data.managerPhone, - systemManager: data.systemManager, - logoUrl: data.logoUrl, - logoBlob: data.logoBlob, - salesPaymentDay: data.salesPaymentDay, - creditRating: data.creditRating, - transactionGrade: data.transactionGrade, - taxInvoiceEmail: data.taxInvoiceEmail, - outstandingAmount: data.outstandingAmount, - overdueDays: data.overdueDays, - overdueToggle: data.overdueToggle, - badDebtToggle: data.badDebtToggle, - memos: data.memos, - documents: data.documents, - category: data.category, - paymentDay: data.salesPaymentDay, - isBadDebt: data.badDebtToggle, - updatedAt: new Date().toISOString(), - }; - - return { success: true, data: updatedPartner }; + return { success: true, data: transformPartner(result.data) }; } catch (error) { console.error('updatePartner error:', error); return { success: false, error: '거래처 수정에 실패했습니다.' }; } } -// 거래처 통계 조회 +/** + * 거래처 통계 조회 + */ export async function getPartnerStats(): Promise<{ success: boolean; data?: PartnerStats; error?: string }> { try { - // TODO: 실제 API 호출 - const total = mockPartners.length; - const badDebt = mockPartners.filter((p) => p.isBadDebt).length; + const result = await apiRequest<{ + total: number; + sales: number; + purchase: number; + both: number; + badDebt: number; + normal: number; + }>('/clients/stats'); + + if (!result.success || !result.data) { + return { success: false, error: result.error || '통계 조회에 실패했습니다.' }; + } return { success: true, data: { - total, - unregistered: 5, // 목업 - badDebt, - normal: total - badDebt, + total: result.data.total || 0, + unregistered: 0, // Client API에서 미지원 (거래처는 등록 완료 상태만) + badDebt: result.data.badDebt || 0, + normal: result.data.normal || 0, }, }; } catch (error) { @@ -348,11 +348,19 @@ export async function getPartnerStats(): Promise<{ success: boolean; data?: Part } } -// 거래처 삭제 +/** + * 거래처 삭제 + */ export async function deletePartner(id: string): Promise<{ success: boolean; error?: string }> { try { - // TODO: 실제 API 호출 - console.log('Delete partner:', id); + const result = await apiRequest(`/clients/${id}`, { + method: 'DELETE', + }); + + if (!result.success) { + return { success: false, error: result.error || '거래처 삭제에 실패했습니다.' }; + } + return { success: true }; } catch (error) { console.error('deletePartner error:', error); @@ -360,12 +368,24 @@ export async function deletePartner(id: string): Promise<{ success: boolean; err } } -// 거래처 일괄 삭제 +/** + * 거래처 일괄 삭제 + */ export async function deletePartners(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> { try { - // TODO: 실제 API 호출 - console.log('Delete partners:', ids); - return { success: true, deletedCount: ids.length }; + const result = await apiRequest<{ deleted_count: number }>('/clients/bulk', { + method: 'DELETE', + body: JSON.stringify({ ids: ids.map((id) => Number(id)) }), + }); + + if (!result.success) { + return { success: false, error: result.error || '일괄 삭제에 실패했습니다.' }; + } + + return { + success: true, + deletedCount: result.data?.deleted_count || ids.length, + }; } catch (error) { console.error('deletePartners error:', error); return { success: false, error: '일괄 삭제에 실패했습니다.' };