Files
sam-react-prod/src/lib/api/items.ts

820 lines
22 KiB
TypeScript
Raw Normal View History

/**
* API
*
* Laravel API
* Next.js 15 Server Components와 Client Components
*/
import type {
ItemMaster,
CreateItemData,
UpdateItemData,
FetchItemsParams,
ApiResponse,
PaginatedResponse,
BOMLine,
} from '@/types/item';
// ===== 환경 변수 =====
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// ===== 유틸리티 함수 =====
/**
*
* TODO: 실제
*/
function getAuthToken(): string | null {
// Server Component에서는 쿠키에서 자동으로
// Client Component에서는 localStorage 또는 쿠키에서
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
}
/**
* Fetch
*/
function createFetchOptions(options: RequestInit = {}): RequestInit {
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
...options,
headers,
credentials: 'include', // 쿠키 포함
};
}
/**
* API
*/
async function handleApiResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({
message: 'API 요청 실패',
}));
throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
}
// ===== 품목 CRUD =====
/**
*
*
* @example
* // Server Component에서
* const items = await fetchItems({ itemType: 'FG' });
*
* // Client Component에서
* const [items, setItems] = useState([]);
* useEffect(() => {
* fetchItems().then(setItems);
* }, []);
*/
// API 응답 → 프론트엔드(camelCase) 변환
interface ApiItemResponse {
id?: number | string;
// DB 필드명 (code, name)
code?: string;
name?: string;
item_type?: string;
unit?: string;
specification?: string;
is_active?: boolean | number;
// snake_case 대안 (item_code, item_name)
item_code?: string;
item_name?: string;
// camelCase도 지원 (이미 변환된 경우)
itemCode?: string;
itemName?: string;
itemType?: string;
isActive?: boolean;
}
function transformItemFromApi(apiItem: ApiItemResponse): ItemMaster {
return {
id: String(apiItem.id || ''),
// 우선순위: code > item_code > itemCode
itemCode: apiItem.code || apiItem.item_code || apiItem.itemCode || '',
// 우선순위: name > item_name > itemName
itemName: apiItem.name || apiItem.item_name || apiItem.itemName || '',
itemType: (apiItem.item_type || apiItem.itemType || 'FG') as ItemMaster['itemType'],
unit: apiItem.unit || '',
specification: apiItem.specification || '',
isActive: apiItem.is_active === true || apiItem.is_active === 1 || apiItem.isActive === true,
currentRevision: 0,
isFinal: false,
createdAt: '',
};
}
export async function fetchItems(
params?: FetchItemsParams
): Promise<ItemMaster[]> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
}
// 클라이언트에서 호출 시 프록시 라우트 사용 (HttpOnly 쿠키 인증 지원)
const isClient = typeof window !== 'undefined';
const baseUrl = isClient ? '/api/proxy' : `${API_URL}/api/v1`;
const url = `${baseUrl}/items${queryParams.toString() ? `?${queryParams}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`품목 조회 실패: ${response.status}`);
}
const result = await response.json();
// 디버깅: API 응답 구조 확인
console.log('[fetchItems] API 응답:', JSON.stringify(result, null, 2).slice(0, 1000));
// 데이터 추출
let rawItems: ApiItemResponse[] = [];
if (result.success && result.data) {
// 페이지네이션 응답: { success: true, data: { data: [...], ... } }
if (result.data.data && Array.isArray(result.data.data)) {
rawItems = result.data.data;
}
// 단순 배열 응답: { success: true, data: [...] }
else if (Array.isArray(result.data)) {
rawItems = result.data;
}
}
// 직접 배열 응답 (fallback)
else if (Array.isArray(result)) {
rawItems = result;
}
// 디버깅: rawItems 첫 번째 항목 확인
if (rawItems.length > 0) {
console.log('[fetchItems] rawItems[0]:', rawItems[0]);
}
// snake_case → camelCase 변환
const transformed = rawItems.map(transformItemFromApi);
// 디버깅: 변환된 첫 번째 항목 확인
if (transformed.length > 0) {
console.log('[fetchItems] transformed[0]:', transformed[0]);
}
return transformed;
}
/**
* ()
*/
export async function fetchItemsPaginated(
params?: FetchItemsParams
): Promise<PaginatedResponse<ItemMaster>> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
}
const url = `${API_URL}/api/items/paginated${queryParams.toString() ? `?${queryParams}` : ''}`;
const response = await fetch(url, createFetchOptions());
const data = await handleApiResponse<ApiResponse<PaginatedResponse<ItemMaster>>>(response);
return data.data;
}
/**
*
*
* @param itemCode - (: "KD-FG-001")
*
* @example
* const item = await fetchItemByCode('KD-FG-001');
*/
export async function fetchItemByCode(itemCode: string): Promise<ItemMaster> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
return data.data;
}
/**
*
*
* @example
* const newItem = await createItem({
* itemCode: 'KD-FG-001',
* itemName: '스크린 제품',
* itemType: 'FG',
* unit: 'EA',
* });
*/
export async function createItem(
itemData: CreateItemData
): Promise<ItemMaster> {
const response = await fetch(
`${API_URL}/api/items`,
createFetchOptions({
method: 'POST',
body: JSON.stringify(itemData),
})
);
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
return data.data;
}
/**
*
*
* @param itemCode -
* @param updates -
*
* @example
* const updatedItem = await updateItem('KD-FG-001', {
* itemName: '스크린 제품 (수정)',
* salesPrice: 150000,
* });
*/
export async function updateItem(
itemCode: string,
updates: UpdateItemData
): Promise<ItemMaster> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
createFetchOptions({
method: 'PUT',
body: JSON.stringify(updates),
})
);
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
return data.data;
}
/**
*
*
* @param itemCode -
*
* @example
* await deleteItem('KD-FG-001');
*/
export async function deleteItem(itemCode: string): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== BOM (자재명세서) 관리 =====
/**
* BOM
*
* @param itemCode -
*/
export async function fetchBOM(itemCode: string): Promise<BOMLine[]> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<BOMLine[]>>(response);
return data.data;
}
/**
* BOM ( )
*
* @param itemCode -
*/
export async function fetchBOMTree(itemCode: string): Promise<any> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/tree`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<any>>(response);
return data.data;
}
/**
* BOM
*
* @param itemCode -
* @param bomLine - BOM
*/
export async function addBOMLine(
itemCode: string,
bomLine: Omit<BOMLine, 'id'>
): Promise<BOMLine> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`,
createFetchOptions({
method: 'POST',
body: JSON.stringify(bomLine),
})
);
const data = await handleApiResponse<ApiResponse<BOMLine>>(response);
return data.data;
}
/**
* BOM
*
* @param itemCode -
* @param lineId - BOM ID
* @param updates -
*/
export async function updateBOMLine(
itemCode: string,
lineId: string,
updates: Partial<BOMLine>
): Promise<BOMLine> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`,
createFetchOptions({
method: 'PUT',
body: JSON.stringify(updates),
})
);
const data = await handleApiResponse<ApiResponse<BOMLine>>(response);
return data.data;
}
/**
* BOM
*
* @param itemCode -
* @param lineId - BOM ID
*/
export async function deleteBOMLine(
itemCode: string,
lineId: string
): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== 파일 업로드 =====
/** 파일 타입 */
export type ItemFileType = 'specification' | 'certification' | 'bending_diagram';
/** 파일 업로드 옵션 */
export interface UploadFileOptions {
/** 필드 키 (백엔드에서 파일 식별용) - 예: specification_file, certification_file, bending_diagram */
fieldKey?: string;
/** 파일 ID (같은 field_key 내 여러 파일 구분용) - 0, 1, 2... (없으면 최초 등록, 있으면 덮어쓰기) */
fileId?: number;
/** 인증번호 (certification 타입일 때) */
certificationNumber?: string;
/** 인증 시작일 (certification 타입일 때) */
certificationStartDate?: string;
/** 인증 종료일 (certification 타입일 때) */
certificationEndDate?: string;
/** 절곡 상세 정보 (bending_diagram 타입일 때) */
bendingDetails?: Array<{
angle: number;
length: number;
type: string;
}>;
}
/** 파일 업로드 응답 */
export interface UploadFileResponse {
file_type: string;
file_url: string;
file_path: string;
file_name: string;
product: Record<string, unknown>;
}
/**
* (ID , )
*
* HttpOnly Next.js API .
*
* @param itemId - ID ()
* @param file -
* @param fileType - (specification, certification, bending_diagram)
* @param options - (certification , bending_details )
*
* @example
* // 시방서 업로드
* await uploadItemFile(123, specFile, 'specification');
*
* // 인정서 업로드 (추가 정보 포함)
* await uploadItemFile(123, certFile, 'certification', {
* certificationNumber: 'CERT-001',
* certificationStartDate: '2025-01-01',
* certificationEndDate: '2026-01-01',
* });
*
* // 절곡/조립 전개도 업로드
* await uploadItemFile(123, diagramFile, 'bending_diagram');
*/
export async function uploadItemFile(
itemId: number,
file: File,
fileType: ItemFileType,
options?: UploadFileOptions
): Promise<UploadFileResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', fileType);
// field_key, file_id 추가 (백엔드에서 파일 식별용)
// - field_key: 파일 종류 식별자 (예: specification_file, certification_file, bending_diagram)
// - file_id: 같은 field_key 내 파일 순번 (없으면 최초 등록, 있으면 해당 파일 덮어쓰기)
if (options?.fieldKey) {
formData.append('field_key', options.fieldKey);
}
if (options?.fileId !== undefined) {
formData.append('file_id', String(options.fileId));
}
// certification 관련 추가 필드
if (fileType === 'certification' && options) {
if (options.certificationNumber) {
formData.append('certification_number', options.certificationNumber);
}
if (options.certificationStartDate) {
formData.append('certification_start_date', options.certificationStartDate);
}
if (options.certificationEndDate) {
formData.append('certification_end_date', options.certificationEndDate);
}
}
// bending_diagram 관련 추가 필드
// 백엔드가 배열 형태를 기대하므로 각 항목을 개별적으로 append
if (fileType === 'bending_diagram' && options?.bendingDetails) {
options.bendingDetails.forEach((detail, index) => {
Object.entries(detail).forEach(([key, value]) => {
formData.append(`bending_details[${index}][${key}]`, String(value));
});
});
}
// 프록시 경유: /api/proxy/items/{id}/files → /api/v1/items/{id}/files
const response = await fetch(`/api/proxy/items/${itemId}/files`, {
method: 'POST',
body: formData,
credentials: 'include',
// Content-Type은 FormData 사용 시 자동 설정됨 (boundary 포함)
});
const data = await handleApiResponse<ApiResponse<UploadFileResponse>>(response);
return data.data;
}
/**
* ( ID , )
*
* @param itemId - ID ()
* @param fileId - ID (files id)
* @param itemType - (FG, PT, SM ) - 'FG'
*
* @example
* await deleteItemFile(123, 456, 'FG');
*/
export async function deleteItemFile(
itemId: number,
fileId: number,
itemType: string = 'FG'
): Promise<{ file_id: number; deleted: boolean }> {
// 프록시 경유: /api/proxy/items/{id}/files/{fileId} → /api/v1/items/{id}/files/{fileId}
const response = await fetch(`/api/proxy/items/${itemId}/files/${fileId}?item_type=${itemType}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await handleApiResponse<ApiResponse<{ file_id: number; deleted: boolean }>>(response);
return data.data;
}
// ===== 레거시 파일 업로드 (하위 호환성) =====
/**
* @deprecated uploadItemFile (ID )
* (, , ) -
*/
export async function uploadFile(
itemCode: string,
file: File,
fileType: 'specification' | 'certification' | 'bending_diagram'
): Promise<{ url: string; filename: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', fileType);
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files`,
{
method: 'POST',
headers,
body: formData,
credentials: 'include',
}
);
const data = await handleApiResponse<ApiResponse<{ url: string; filename: string }>>(response);
return data.data;
}
/**
* @deprecated deleteItemFile (ID )
* -
*/
export async function deleteFile(
itemCode: string,
fileType: 'specification' | 'certification' | 'bending_diagram'
): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files/${fileType}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== 검색 및 필터 =====
/**
* ()
*
* @param query -
* @param limit -
*/
export async function searchItemCodes(
query: string,
limit: number = 10
): Promise<string[]> {
const response = await fetch(
`${API_URL}/api/items/search/codes?q=${encodeURIComponent(query)}&limit=${limit}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<string[]>>(response);
return data.data;
}
/**
* ()
*
* @param query -
* @param limit -
*/
export async function searchItemNames(
query: string,
limit: number = 10
): Promise<Array<{ itemCode: string; itemName: string }>> {
const response = await fetch(
`${API_URL}/api/items/search/names?q=${encodeURIComponent(query)}&limit=${limit}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<Array<{ itemCode: string; itemName: string }>>>(response);
return data.data;
}
// ===== 유틸리티 =====
/**
*
*
* @param itemCode -
* @returns (true: , false: )
*/
export async function checkItemCodeAvailability(
itemCode: string
): Promise<boolean> {
const response = await fetch(
`${API_URL}/api/items/check/${encodeURIComponent(itemCode)}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<{ available: boolean }>>(response);
return data.data.available;
}
/** 품목 코드 중복 체크 결과 */
export interface DuplicateCheckResult {
/** 중복 여부 */
isDuplicate: boolean;
/** 중복된 품목 ID (중복인 경우에만 존재) */
duplicateId?: number;
/** 중복된 품목 타입 (중복인 경우에만 존재) */
duplicateItemType?: string;
}
/**
* (ID )
*
* GET /api/v1/items/code/{code} API를
* - 200 OK: 중복 ( id )
* - 404 Not Found: 중복
*
* @param itemCode -
* @param excludeId - ID ( )
* @returns ( + ID)
*
* @example
* // 등록 시
* const result = await checkItemCodeDuplicate('PT-ASM-001');
* if (result.isDuplicate) {
* // 중복! result.duplicateId로 수정 페이지 이동 가능
* }
*
* // 수정 시 (자기 자신 제외)
* const result = await checkItemCodeDuplicate('PT-ASM-001', currentItemId);
*/
export async function checkItemCodeDuplicate(
itemCode: string,
excludeId?: number
): Promise<DuplicateCheckResult> {
try {
// 프록시 경유: /api/proxy/items/code/{code} → /api/v1/items/code/{code}
const response = await fetch(
`/api/proxy/items/code/${encodeURIComponent(itemCode)}`,
{
method: 'GET',
credentials: 'include',
}
);
if (response.status === 404) {
// 404: 해당 코드의 품목이 없음 → 중복 아님
return { isDuplicate: false };
}
if (!response.ok) {
// 다른 에러는 중복 아님으로 처리 (안전한 방향)
console.warn('[checkItemCodeDuplicate] API 에러:', response.status);
return { isDuplicate: false };
}
// 200 OK: 해당 코드의 품목이 존재함
const data = await response.json();
const duplicateItem = data.data;
// 수정 모드에서 자기 자신인 경우 제외
if (excludeId && duplicateItem.id === excludeId) {
return { isDuplicate: false };
}
return {
isDuplicate: true,
duplicateId: duplicateItem.id,
// 백엔드에서 product_type, item_type, type_code 등 다양한 필드명 사용 가능
duplicateItemType: duplicateItem.product_type || duplicateItem.item_type || duplicateItem.type_code,
};
} catch (error) {
console.error('[checkItemCodeDuplicate] 에러:', error);
// 에러 시 중복 아님으로 처리 (등록/수정 진행 허용)
return { isDuplicate: false };
}
}
/**
* ( )
*
* @param itemType -
* @returns (: "KD-FG-001")
*/
export async function generateItemCode(
itemType: string
): Promise<string> {
const response = await fetch(
`${API_URL}/api/items/generate-code?itemType=${itemType}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<{ itemCode: string }>>(response);
return data.data.itemCode;
}
/**
* Next.js revalidation
* Server Component에서
*/
export async function revalidateItems(): Promise<void> {
if (typeof window === 'undefined') {
// Server Component에서만 실행
const { revalidatePath } = await import('next/cache');
revalidatePath('/items');
}
}
// ===== 품목 단가 조회 =====
/** 품목 단가 조회 결과 */
export interface ItemPriceResult {
item_code: string;
unit_price: number;
}
/**
* ( )
*
* @param itemCodes -
* @returns
*
* @example
* const prices = await fetchItemPrices(['N71807', 'N71808']);
* console.log(prices['N71807']?.unit_price); // 1320000
*/
export async function fetchItemPrices(
itemCodes: string[]
): Promise<Record<string, ItemPriceResult>> {
if (!itemCodes.length) {
return {};
}
try {
// 프록시 경유: /api/proxy/quotes/items/prices → /api/v1/quotes/items/prices
const response = await fetch('/api/proxy/quotes/items/prices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ item_codes: itemCodes }),
credentials: 'include',
});
if (!response.ok) {
console.error('[fetchItemPrices] API 에러:', response.status);
return {};
}
const result = await response.json();
console.log('[fetchItemPrices] API 응답:', result);
if (result.success && result.data) {
return result.data as Record<string, ItemPriceResult>;
}
return {};
} catch (error) {
console.error('[fetchItemPrices] 에러:', error);
return {};
}
}