Files
sam-react-prod/src/components/pricing/actions.ts
유병철 55e0791e16 refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일)
- sanitize 유틸 추가 (XSS 방지)
- middleware CSP 헤더 추가 및 Open Redirect 방지
- 프록시 라우트 로깅 개발환경 한정으로 변경
- 프로덕션 불필요 console.log 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:14:06 +09:00

418 lines
14 KiB
TypeScript

/**
* 단가관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/pricing - 목록 조회
* - GET /api/v1/pricing/{id} - 상세 조회
* - POST /api/v1/pricing - 등록
* - PUT /api/v1/pricing/{id} - 수정
* - DELETE /api/v1/pricing/{id} - 삭제
* - POST /api/v1/pricing/{id}/finalize - 확정
* - GET /api/v1/pricing/{id}/revisions - 이력 조회
*/
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PricingData, ItemInfo } from './types';
// API 응답 타입
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// 단가 API 응답 데이터 타입
interface PriceApiData {
id: number;
tenant_id: number;
item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일)
item_id: number;
client_group_id: number | null;
purchase_price: string | null;
processing_cost: string | null;
loss_rate: string | null;
margin_rate: string | null;
sales_price: string | null;
rounding_rule: 'round' | 'ceil' | 'floor';
rounding_unit: number;
supplier: string | null;
effective_from: string;
effective_to: string | null;
status: 'draft' | 'active' | 'finalized';
is_final: boolean;
finalized_at: string | null;
finalized_by: number | null;
note: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
client_group?: {
id: number;
name: string;
};
product?: {
id: number;
product_code: string;
product_name: string;
specification: string | null;
unit: string;
product_type: string;
};
material?: {
id: number;
item_code: string;
item_name: string;
specification: string | null;
unit: string;
product_type: string;
};
revisions?: Array<{
id: number;
revision_number: number;
changed_at: string;
changed_by: number;
change_reason: string | null;
before_snapshot: Record<string, unknown> | null;
after_snapshot: Record<string, unknown>;
changed_by_user?: {
id: number;
name: string;
};
}>;
}
/**
* API 데이터 → 프론트엔드 타입 변환
*/
function transformApiToFrontend(apiData: PriceApiData): PricingData {
const product = apiData.product;
const material = apiData.material;
const itemCode = product?.product_code || material?.item_code || `ITEM-${apiData.item_id}`;
const itemName = product?.product_name || material?.item_name || '품목명 없음';
const specification = product?.specification || material?.specification || undefined;
const unit = product?.unit || material?.unit || 'EA';
const itemType = product?.product_type || material?.product_type || 'PT';
// 리비전 변환
const revisions = apiData.revisions?.map((rev) => ({
revisionNumber: rev.revision_number,
revisionDate: rev.changed_at,
revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`,
revisionReason: rev.change_reason || undefined,
previousData: rev.before_snapshot as unknown as PricingData,
})) || [];
return {
id: String(apiData.id),
itemId: String(apiData.item_id),
itemCode,
itemName,
itemType,
specification,
unit,
effectiveDate: apiData.effective_from,
purchasePrice: apiData.purchase_price ? parseFloat(apiData.purchase_price) : undefined,
processingCost: apiData.processing_cost ? parseFloat(apiData.processing_cost) : undefined,
loss: apiData.loss_rate ? parseFloat(apiData.loss_rate) : undefined,
roundingRule: apiData.rounding_rule || 'round',
roundingUnit: apiData.rounding_unit || 1,
marginRate: apiData.margin_rate ? parseFloat(apiData.margin_rate) : undefined,
salesPrice: apiData.sales_price ? parseFloat(apiData.sales_price) : undefined,
supplier: apiData.supplier || undefined,
note: apiData.note || undefined,
currentRevision: revisions.length,
isFinal: apiData.is_final,
revisions,
finalizedDate: apiData.finalized_at || undefined,
status: apiData.status,
createdAt: apiData.created_at,
createdBy: '관리자',
updatedAt: apiData.updated_at,
updatedBy: '관리자',
};
}
/**
* 프론트엔드 데이터 → API 요청 형식 변환
* item_type_code는 품목 정보(data.itemType)에서 가져옴 (FG, PT, SM, RM, CS 등)
*/
function transformFrontendToApi(data: PricingData): Record<string, unknown> {
return {
item_type_code: data.itemType, // 품목에서 가져온 실제 item_type 값 사용
item_id: parseInt(data.itemId),
purchase_price: data.purchasePrice || null,
processing_cost: data.processingCost || null,
loss_rate: data.loss || null,
margin_rate: data.marginRate || null,
sales_price: data.salesPrice || null,
rounding_rule: data.roundingRule || 'round',
rounding_unit: data.roundingUnit || 1,
supplier: data.supplier || null,
effective_from: data.effectiveDate,
effective_to: null,
note: data.note || null,
status: data.status || 'draft',
};
}
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export async function getPricingById(id: string): Promise<PricingData | null> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}`,
transform: (data: PriceApiData) => transformApiToFrontend(data),
errorMessage: '단가 조회에 실패했습니다.',
});
return result.success ? result.data || null : null;
}
export async function getItemInfo(itemId: string): Promise<ItemInfo | null> {
interface ItemApiItem { id: number; code: string; name: string; item_type: string; specification?: string; unit?: string }
const result = await executeServerAction<ItemApiItem>({
url: `${API_URL}/api/v1/items/${itemId}`,
errorMessage: '품목 조회에 실패했습니다.',
});
if (!result.success || !result.data) return null;
const item = result.data;
return {
id: String(item.id), itemCode: item.code, itemName: item.name,
itemType: item.item_type || 'PT', specification: item.specification || undefined, unit: item.unit || 'EA',
};
}
export async function createPricing(
data: PricingData
): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing`,
method: 'POST',
body: apiData,
transform: (d: PriceApiData) => transformApiToFrontend(d),
errorMessage: '단가 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
export async function updatePricing(
id: string, data: PricingData, changeReason?: string
): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
const apiData = { ...transformFrontendToApi(data), change_reason: changeReason || null };
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}`,
method: 'PUT',
body: apiData,
transform: (d: PriceApiData) => transformApiToFrontend(d),
errorMessage: '단가 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
export async function deletePricing(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}`,
method: 'DELETE',
errorMessage: '단가 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
export async function finalizePricing(id: string): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}/finalize`,
method: 'POST',
transform: (d: PriceApiData) => transformApiToFrontend(d),
errorMessage: '단가 확정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ============================================
// 품목 목록 + 단가 목록 병합 조회
// ============================================
// 품목 API 응답 타입 (GET /api/v1/items)
interface ItemApiData {
id: number;
item_type: string; // FG, PT, SM, RM, CS (품목 유형)
code: string;
name: string;
unit: string;
category_id: number | null;
created_at: string;
deleted_at: string | null;
}
// 단가 목록 조회용 타입
interface PriceApiListItem {
id: number;
tenant_id: number;
item_type_code: string;
item_id: number;
client_group_id: number | null;
purchase_price: string | null;
processing_cost: string | null;
loss_rate: string | null;
margin_rate: string | null;
sales_price: string | null;
rounding_rule: 'round' | 'ceil' | 'floor';
rounding_unit: number;
supplier: string | null;
effective_from: string;
effective_to: string | null;
status: 'draft' | 'active' | 'finalized';
is_final: boolean;
finalized_at: string | null;
finalized_by: number | null;
note: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
// 목록 표시용 타입
export interface PricingListItem {
id: string;
itemId: string;
itemCode: string;
itemName: string;
itemType: string;
specification?: string;
unit: string;
purchasePrice?: number;
processingCost?: number;
salesPrice?: number;
marginRate?: number;
effectiveDate?: string;
status: 'draft' | 'active' | 'finalized' | 'not_registered';
currentRevision: number;
isFinal: boolean;
itemTypeCode: string;
}
// 품목 유형 매핑 (type_code → 프론트엔드 ItemType)
function mapItemTypeForList(typeCode?: string): string {
switch (typeCode) {
case 'FG': return 'FG';
case 'PT': return 'PT';
case 'SM': return 'SM';
case 'RM': return 'RM';
case 'CS': return 'CS';
default: return 'PT';
}
}
// API 상태 → 프론트엔드 상태 매핑
function mapStatusForList(apiStatus: string, isFinal: boolean): 'draft' | 'active' | 'finalized' | 'not_registered' {
if (isFinal) return 'finalized';
switch (apiStatus) {
case 'draft': return 'draft';
case 'active': return 'active';
case 'finalized': return 'finalized';
default: return 'draft';
}
}
/**
* 단가 목록 데이터 조회 (품목 + 단가 병합)
*/
export async function getPricingListData(): Promise<PricingListItem[]> {
interface ItemsPaginatedResponse { data: ItemApiData[]; current_page: number; last_page: number; per_page: number; total: number }
interface PricingPaginatedResponse { data: PriceApiListItem[]; current_page: number; last_page: number; per_page: number; total: number }
const [itemsResult, pricingResult] = await Promise.all([
executeServerAction<ItemsPaginatedResponse>({
url: `${API_URL}/api/v1/items?group_id=1&size=100`,
errorMessage: '품목 목록 조회에 실패했습니다.',
}),
executeServerAction<PricingPaginatedResponse>({
url: `${API_URL}/api/v1/pricing?size=100`,
errorMessage: '단가 목록 조회에 실패했습니다.',
}),
]);
const items: ItemApiData[] = itemsResult.success ? (itemsResult.data?.data || []) : [];
const pricings: PriceApiListItem[] = pricingResult.success ? (pricingResult.data?.data || []) : [];
if (items.length === 0) return [];
const pricingMap = new Map<string, PriceApiListItem>();
for (const pricing of pricings) {
const key = `${pricing.item_type_code}_${pricing.item_id}`;
if (!pricingMap.has(key)) pricingMap.set(key, pricing);
}
return items.map((item) => {
const key = `${item.item_type}_${item.id}`;
const pricing = pricingMap.get(key);
if (pricing) {
return {
id: String(pricing.id), itemId: String(item.id), itemCode: item.code, itemName: item.name,
itemType: mapItemTypeForList(item.item_type), specification: undefined, unit: item.unit || 'EA',
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
effectiveDate: pricing.effective_from,
status: mapStatusForList(pricing.status, pricing.is_final),
currentRevision: 0, isFinal: pricing.is_final, itemTypeCode: item.item_type,
};
} else {
return {
id: `item_${item.id}`, itemId: String(item.id), itemCode: item.code, itemName: item.name,
itemType: mapItemTypeForList(item.item_type), specification: undefined, unit: item.unit || 'EA',
purchasePrice: undefined, processingCost: undefined, salesPrice: undefined, marginRate: undefined,
effectiveDate: undefined, status: 'not_registered' as const,
currentRevision: 0, isFinal: false, itemTypeCode: item.item_type,
};
}
});
}
/**
* 단가 이력 조회
*/
export async function getPricingRevisions(priceId: string): Promise<{
success: boolean;
data?: Array<{
revisionNumber: number; revisionDate: string; revisionBy: string; revisionReason?: string;
beforeSnapshot: Record<string, unknown> | null; afterSnapshot: Record<string, unknown>;
}>;
error?: string; __authError?: boolean;
}> {
interface RevisionApiData {
data?: Array<{
revision_number: number; changed_at: string; changed_by: number;
change_reason: string | null; before_snapshot: Record<string, unknown> | null;
after_snapshot: Record<string, unknown>; changed_by_user?: { name: string };
}>;
}
const result = await executeServerAction<RevisionApiData>({
url: `${API_URL}/api/v1/pricing/${priceId}/revisions`,
errorMessage: '이력 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
const revisions = (result.data.data || []).map((rev) => ({
revisionNumber: rev.revision_number,
revisionDate: rev.changed_at,
revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`,
revisionReason: rev.change_reason || undefined,
beforeSnapshot: rev.before_snapshot,
afterSnapshot: rev.after_snapshot,
}));
return { success: true, data: revisions };
}