feat: 거래처관리 API 연동 (Phase 2.2)

- partners/actions.ts: Mock → API 연동 전환
- apiRequest 헬퍼 함수 추가 (쿠키 기반 인증)
- transform 함수: client_type ↔ partnerType 변환
- getPartnerList, getPartner, createPartner, updatePartner
- getPartnerStats, deletePartner, deletePartners
- 구현 문서 추가
This commit is contained in:
2026-01-09 16:46:32 +09:00
parent 273d5709cd
commit 749f0ce3c3
2 changed files with 395 additions and 258 deletions

View File

@@ -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 급여관리

View File

@@ -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<T>(
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<string, string> = {
'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<string, Partner['partnerType']> = {
'SALES': 'sales',
'PURCHASE': 'purchase',
'BOTH': 'both',
};
return typeMap[clientType || ''] || 'sales';
}
/**
* partnerType Frontend → API client_type 변환
*/
function transformPartnerType(partnerType: Partner['partnerType']): string {
const typeMap: Record<Partner['partnerType'], string> = {
'sales': 'SALES',
'purchase': 'PURCHASE',
'both': 'BOTH',
};
return typeMap[partnerType] || 'SALES';
}
/**
* API 응답 → 프론트엔드 Partner 타입 변환
*/
function transformPartner(apiData: Record<string, unknown>): 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<string, unknown> {
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<string, unknown>[];
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<Record<string, unknown>>(`/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<Record<string, unknown>>('/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<Record<string, unknown>>(`/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: '일괄 삭제에 실패했습니다.' };