Files
sam-react-prod/src/components/process-management/actions.ts
권혁성 0b81e9c1dd feat: [process] 공정 단계에 검사범위(InspectionScope) 설정 추가
- 전수검사/샘플링/그룹 유형 선택 UI
- 샘플링 시 샘플 크기(n) 입력
- options JSON으로 API 저장/복원
2026-03-04 22:28:16 +09:00

769 lines
27 KiB
TypeScript

'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { Process, ProcessFormData, ClassificationRule, IndividualItem, ProcessStep } from '@/types/process';
// ============================================================================
// API 타입 정의
// ============================================================================
interface ApiProcess {
id: number;
tenant_id: number;
process_code: string;
process_name: string;
description: string | null;
process_type: string;
department: string | null;
manager: string | null;
process_category: string | null;
use_production_date: boolean;
work_log_template: string | null;
document_template_id: number | null;
document_template?: { id: number; name: string; category: string } | null;
work_log_template_id: number | null;
work_log_template_relation?: { id: number; name: string; category: string } | null;
options?: {
needs_inspection?: boolean;
needs_work_log?: boolean;
} | null;
required_workers: number;
equipment_info: string | null;
work_steps: string[] | null;
note: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
classification_rules?: ApiClassificationRule[];
process_items?: ApiProcessItem[];
steps?: ApiProcessStep[];
}
interface ApiClassificationRule {
id: number;
process_id: number;
registration_type: string;
rule_type: string;
matching_type: string;
condition_value: string;
priority: number;
description: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
interface ApiProcessItem {
id: number;
process_id: number;
item_id: number;
priority: number;
is_active: boolean;
item?: {
id: number;
code: string;
name: string;
};
}
interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
}
// ============================================================================
// 데이터 변환 함수
// ============================================================================
function transformApiToFrontend(apiData: ApiProcess): Process {
// Pattern 규칙 변환
const patternRules = (apiData.classification_rules ?? []).map(transformRuleApiToFrontend);
// 개별 품목 → individual 분류 규칙으로 변환
const individualRules = transformProcessItemsToRules(apiData.process_items ?? []);
return {
id: String(apiData.id),
processCode: apiData.process_code,
processName: apiData.process_name,
description: apiData.description ?? undefined,
processType: apiData.process_type as Process['processType'],
department: apiData.department ?? '',
manager: apiData.manager ?? undefined,
processCategory: apiData.process_category ?? undefined,
useProductionDate: apiData.use_production_date ?? false,
workLogTemplate: apiData.work_log_template ?? undefined,
documentTemplateId: apiData.document_template_id ?? undefined,
documentTemplateName: apiData.document_template?.name ?? undefined,
workLogTemplateId: apiData.work_log_template_id ?? undefined,
workLogTemplateName: apiData.work_log_template_relation?.name ?? undefined,
needsInspection: apiData.options?.needs_inspection ?? false,
needsWorkLog: apiData.options?.needs_work_log ?? false,
classificationRules: [...patternRules, ...individualRules],
requiredWorkers: apiData.required_workers,
equipmentInfo: apiData.equipment_info ?? undefined,
workSteps: apiData.work_steps ?? [],
steps: (apiData.steps ?? []).map(transformStepApiToFrontend),
note: apiData.note ?? undefined,
status: apiData.is_active ? '사용중' : '미사용',
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
/**
* process_items 배열을 individual 분류 규칙으로 변환
* 모든 개별 품목을 하나의 규칙으로 통합
*/
function transformProcessItemsToRules(processItems: ApiProcessItem[]): ClassificationRule[] {
if (processItems.length === 0) return [];
const activeItems = processItems.filter(pi => pi.is_active);
if (activeItems.length === 0) return [];
// 모든 품목 ID를 쉼표로 구분하여 하나의 규칙으로 통합
const itemIds = activeItems
.map(pi => String(pi.item_id))
.join(',');
// 품목 상세 정보 추출 (code, name 포함)
const items: IndividualItem[] = activeItems
.filter(pi => pi.item) // item 정보가 있는 것만
.map(pi => ({
id: String(pi.item!.id),
code: pi.item!.code,
name: pi.item!.name,
}));
return [{
id: `individual-${Date.now()}`,
registrationType: 'individual',
ruleType: '품목코드',
matchingType: 'equals',
conditionValue: itemIds,
priority: 0,
description: `개별 품목 ${activeItems.length}`,
isActive: true,
createdAt: new Date().toISOString(),
items, // 품목 상세 정보 추가
}];
}
function transformRuleApiToFrontend(apiRule: ApiClassificationRule): ClassificationRule {
return {
id: String(apiRule.id),
registrationType: apiRule.registration_type as ClassificationRule['registrationType'],
ruleType: apiRule.rule_type as ClassificationRule['ruleType'],
matchingType: apiRule.matching_type as ClassificationRule['matchingType'],
conditionValue: apiRule.condition_value,
priority: apiRule.priority,
description: apiRule.description ?? undefined,
isActive: apiRule.is_active,
createdAt: apiRule.created_at,
};
}
function transformFrontendToApi(data: ProcessFormData): Record<string, unknown> {
// 패턴 규칙만 분리 (individual 제외)
const patternRules = data.classificationRules.filter(
(rule) => rule.registrationType === 'pattern'
);
// 개별 품목 규칙에서 item_ids 추출
const individualRules = data.classificationRules.filter(
(rule) => rule.registrationType === 'individual'
);
// 개별 품목의 conditionValue에서 ID 배열 추출 (쉼표 구분)
const itemIds: number[] = individualRules.flatMap((rule) =>
rule.conditionValue
.split(',')
.map((id) => parseInt(id.trim(), 10))
.filter((n) => !isNaN(n) && n > 0)
);
return {
process_name: data.processName,
process_type: data.processType,
department: data.department || null,
manager: data.manager || null,
process_category: data.processCategory || null,
use_production_date: data.useProductionDate ?? false,
work_log_template: data.workLogTemplate || null,
document_template_id: data.documentTemplateId || null,
work_log_template_id: data.workLogTemplateId || null,
options: {
needs_inspection: data.needsInspection ?? false,
needs_work_log: data.needsWorkLog ?? false,
},
required_workers: data.requiredWorkers,
equipment_info: data.equipmentInfo || null,
work_steps: data.workSteps ? data.workSteps.split(',').map((s) => s.trim()).filter(Boolean) : [],
note: data.note || null,
is_active: data.isActive,
// 패턴 규칙만 전송 (registration_type 제외)
classification_rules: patternRules.map((rule) => ({
rule_type: rule.ruleType,
matching_type: rule.matchingType,
condition_value: rule.conditionValue,
priority: rule.priority,
description: rule.description || null,
is_active: rule.isActive,
})),
// 개별 품목 ID 배열 전송
item_ids: itemIds,
};
}
// ============================================================================
// API 함수
// ============================================================================
/**
* 공정 목록 조회
*/
export async function getProcessList(params?: {
page?: number;
size?: number;
q?: string;
status?: string;
process_type?: string;
}): Promise<{ success: boolean; data?: { items: Process[]; total: number; page: number; totalPages: number }; error?: string; __authError?: boolean }> {
const result = await executeServerAction<PaginatedApiResponse<ApiProcess>>({
url: buildApiUrl('/api/v1/processes', {
page: params?.page,
size: params?.size,
q: params?.q,
status: params?.status,
process_type: params?.process_type,
}),
errorMessage: '공정 목록 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
items: result.data.data.map(transformApiToFrontend),
total: result.data.total,
page: result.data.current_page,
totalPages: result.data.last_page,
},
};
}
/**
* 공정 상세 조회
*/
export async function getProcessById(id: string): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${id}`),
transform: (data: ApiProcess) => transformApiToFrontend(data),
errorMessage: '공정 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 생성
*/
export async function createProcess(data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/processes'),
method: 'POST',
body: transformFrontendToApi(data),
transform: (d: ApiProcess) => transformApiToFrontend(d),
errorMessage: '공정 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 수정
*/
export async function updateProcess(id: string, data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${id}`),
method: 'PUT',
body: transformFrontendToApi(data),
transform: (d: ApiProcess) => transformApiToFrontend(d),
errorMessage: '공정 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 품목 제거 (item_ids만 업데이트)
*/
export async function removeProcessItem(processId: string, remainingItemIds: number[]): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${processId}`),
method: 'PUT',
body: { item_ids: remainingItemIds },
transform: (d: ApiProcess) => transformApiToFrontend(d),
errorMessage: '품목 제거에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 삭제
*/
export async function deleteProcess(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${id}`),
method: 'DELETE',
errorMessage: '공정 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
/**
* 공정 일괄 삭제
*/
export async function deleteProcesses(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string; __authError?: boolean }> {
const result = await executeServerAction<{ deleted_count: number }>({
url: buildApiUrl('/api/v1/processes'),
method: 'DELETE',
body: { ids: ids.map((id) => parseInt(id, 10)) },
errorMessage: '공정 일괄 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, deletedCount: result.data.deleted_count };
}
/**
* 공정 상태 토글
*/
export async function toggleProcessActive(id: string): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${id}/toggle`),
method: 'PATCH',
transform: (d: ApiProcess) => transformApiToFrontend(d),
errorMessage: '공정 상태 변경에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 순서 변경
*/
export async function reorderProcesses(
processes: { id: string; order: number }[]
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/processes/reorder'),
method: 'PATCH',
body: {
items: processes.map((p) => ({
id: parseInt(p.id, 10),
sort_order: p.order,
})),
},
errorMessage: '공정 순서 변경에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
/**
* 공정 옵션 목록 (드롭다운용)
*/
export async function getProcessOptions(): Promise<{
success: boolean;
data?: Array<{ id: string; processCode: string; processName: string; processType: string; department: string }>;
error?: string;
__authError?: boolean;
}> {
interface ApiOptionItem { id: number; process_code: string; process_name: string; process_type: string; department: string }
const result = await executeServerAction<ApiOptionItem[]>({
url: buildApiUrl('/api/v1/processes/options'),
errorMessage: '공정 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: result.data.map((item) => ({
id: String(item.id),
processCode: item.process_code,
processName: item.process_name,
processType: item.process_type,
department: item.department ?? '',
})),
};
}
/**
* 공정 통계
*/
export async function getProcessStats(): Promise<{
success: boolean;
data?: { total: number; active: number; inactive: number; byType: Record<string, number> };
error?: string;
__authError?: boolean;
}> {
interface ApiStats { total: number; active: number; inactive: number; by_type: Record<string, number> }
const result = await executeServerAction<ApiStats>({
url: buildApiUrl('/api/v1/processes/stats'),
errorMessage: '공정 통계 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
total: result.data.total,
active: result.data.active,
inactive: result.data.inactive,
byType: result.data.by_type,
},
};
}
// ============================================================================
// 부서 옵션 타입 및 함수
// ============================================================================
export interface DepartmentOption {
id: string;
value: string;
label: string;
}
/**
* 부서 목록 조회
*/
export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
const defaultOptions: DepartmentOption[] = [
{ id: 'default-1', value: '생산부', label: '생산부' },
{ id: 'default-2', value: '품질관리부', label: '품질관리부' },
{ id: 'default-3', value: '물류부', label: '물류부' },
{ id: 'default-4', value: '영업부', label: '영업부' },
];
interface DeptResponse { data: Array<{ id: number; name: string }> }
const result = await executeServerAction<DeptResponse>({
url: buildApiUrl('/api/v1/departments'),
errorMessage: '부서 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return defaultOptions;
const seenNames = new Set<string>();
return result.data.data
.filter((dept) => {
if (seenNames.has(dept.name)) return false;
seenNames.add(dept.name);
return true;
})
.map((dept) => ({
id: String(dept.id),
value: dept.name,
label: dept.name,
}));
}
// ============================================================================
// 품목 옵션 타입 및 함수
// ============================================================================
export interface ItemOption {
value: string;
label: string;
code: string;
id: string;
fullName: string;
type: string;
// TODO: API 응답에 process_name, process_category 필드 추가 후 활성화
processName?: string;
processCategory?: string;
}
interface GetItemListParams {
q?: string;
itemType?: string;
size?: number;
/** 해당 공정 외 다른 공정에 이미 배정된 품목 제외 (공정 ID) */
excludeProcessId?: string;
}
/**
* 품목 목록 조회 (분류 규칙용)
* - excludeProcessId: 다른 공정에 이미 배정된 품목 제외 (중복 방지)
*
* TODO: 백엔드 API 수정 요청
* - 응답에 process_name, process_category 필드 추가 필요 (공정 품목 선택 팝업에서 공정/구분 컬럼 표시용)
* - 파라미터에 processName, processCategory 필터 추가 필요 (공정/구분 필터링용)
*/
export async function getItemList(params?: GetItemListParams): Promise<ItemOption[]> {
interface ItemListResponse { data: Array<{ id: number; name: string; item_code?: string; item_type?: string; item_type_name?: string }> }
const result = await executeServerAction<ItemListResponse>({
url: buildApiUrl('/api/v1/items', {
size: params?.size || 1000,
q: params?.q,
item_type: params?.itemType,
exclude_process_id: params?.excludeProcessId,
}),
errorMessage: '품목 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return [];
return result.data.data.map((item) => ({
value: String(item.id),
label: item.name,
code: item.item_code || '',
id: String(item.id),
fullName: item.name,
type: item.item_type_name || item.item_type || '',
}));
}
/**
* 품목 유형 옵션 조회 (common_codes에서 동적 조회)
*/
export async function getItemTypeOptions(): Promise<Array<{ value: string; label: string }>> {
const result = await executeServerAction<Array<{ code: string; name: string }>>({
url: buildApiUrl('/api/v1/settings/common/item_type'),
errorMessage: '품목 유형 옵션 조회에 실패했습니다.',
});
if (!result.success || !result.data) return [];
return result.data.map((item) => ({ value: item.code, label: item.name }));
}
// ============================================================================
// 문서 양식 (Document Template) API
// ============================================================================
export interface DocumentTemplateOption {
id: number;
name: string;
category: string;
}
/**
* 문서 양식 목록 조회 (드롭다운용)
*/
export async function getDocumentTemplates(): Promise<{
success: boolean;
data?: DocumentTemplateOption[];
error?: string;
}> {
interface ApiTemplateItem { id: number; name: string; category: string }
const result = await executeServerAction<{ data: ApiTemplateItem[] }>({
url: buildApiUrl('/api/v1/document-templates', { is_active: 1, per_page: 100 }),
errorMessage: '문서 양식 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return { success: false, error: result.error };
return {
success: true,
data: result.data.data.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
})),
};
}
// ============================================================================
// 공정 단계 (Process Step) API
// ============================================================================
interface ApiProcessStepOptions {
inspection_setting?: Record<string, unknown>;
inspection_scope?: {
type: string;
sample_size?: number;
sample_base?: string;
};
}
interface ApiProcessStep {
id: number;
process_id: number;
step_code: string;
step_name: string;
is_required: boolean;
needs_approval: boolean;
needs_inspection: boolean;
is_active: boolean;
sort_order: number;
connection_type: string | null;
connection_target: string | null;
completion_type: string | null;
options: ApiProcessStepOptions | null;
created_at: string;
updated_at: string;
}
function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
const opts = apiStep.options;
return {
id: String(apiStep.id),
stepCode: apiStep.step_code,
stepName: apiStep.step_name,
isRequired: apiStep.is_required,
needsApproval: apiStep.needs_approval,
needsInspection: apiStep.needs_inspection,
isActive: apiStep.is_active,
order: apiStep.sort_order,
connectionType: (apiStep.connection_type as ProcessStep['connectionType']) || '없음',
connectionTarget: apiStep.connection_target ?? undefined,
completionType: (apiStep.completion_type as ProcessStep['completionType']) || 'click_complete',
inspectionSetting: opts?.inspection_setting as ProcessStep['inspectionSetting'],
inspectionScope: opts?.inspection_scope ? {
type: opts.inspection_scope.type as 'all' | 'sampling' | 'group',
sampleSize: opts.inspection_scope.sample_size,
sampleBase: opts.inspection_scope.sample_base as 'order' | 'lot' | undefined,
} : undefined,
};
}
/**
* 공정 단계 목록 조회
*/
export async function getProcessSteps(processId: string): Promise<{
success: boolean;
data?: ProcessStep[];
error?: string;
}> {
const result = await executeServerAction<ApiProcessStep[]>({
url: buildApiUrl(`/api/v1/processes/${processId}/steps`),
errorMessage: '공정 단계 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, data: result.data.map(transformStepApiToFrontend) };
}
/**
* 공정 단계 상세 조회
*/
export async function getProcessStepById(processId: string, stepId: string): Promise<{
success: boolean;
data?: ProcessStep;
error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${processId}/steps/${stepId}`),
transform: (d: ApiProcessStep) => transformStepApiToFrontend(d),
errorMessage: '공정 단계 조회에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 단계 생성
*/
export async function createProcessStep(
processId: string,
data: Omit<ProcessStep, 'id'>
): Promise<{ success: boolean; data?: ProcessStep; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${processId}/steps`),
method: 'POST',
body: {
step_name: data.stepName,
is_required: data.isRequired,
needs_approval: data.needsApproval,
needs_inspection: data.needsInspection,
is_active: data.isActive,
connection_type: data.connectionType || null,
connection_target: data.connectionTarget || null,
completion_type: data.completionType || null,
options: (data.inspectionSetting || data.inspectionScope) ? {
inspection_setting: data.inspectionSetting || null,
inspection_scope: data.inspectionScope ? {
type: data.inspectionScope.type,
sample_size: data.inspectionScope.sampleSize,
sample_base: data.inspectionScope.sampleBase,
} : null,
} : null,
},
transform: (d: ApiProcessStep) => transformStepApiToFrontend(d),
errorMessage: '공정 단계 등록에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 단계 수정
*/
export async function updateProcessStep(
processId: string,
stepId: string,
data: Partial<ProcessStep>
): Promise<{ success: boolean; data?: ProcessStep; error?: string }> {
const apiData: Record<string, unknown> = {};
if (data.stepName !== undefined) apiData.step_name = data.stepName;
if (data.isRequired !== undefined) apiData.is_required = data.isRequired;
if (data.needsApproval !== undefined) apiData.needs_approval = data.needsApproval;
if (data.needsInspection !== undefined) apiData.needs_inspection = data.needsInspection;
if (data.isActive !== undefined) apiData.is_active = data.isActive;
if (data.connectionType !== undefined) apiData.connection_type = data.connectionType || null;
if (data.connectionTarget !== undefined) apiData.connection_target = data.connectionTarget || null;
if (data.completionType !== undefined) apiData.completion_type = data.completionType || null;
if (data.inspectionSetting !== undefined || data.inspectionScope !== undefined) {
apiData.options = {
inspection_setting: data.inspectionSetting || null,
inspection_scope: data.inspectionScope ? {
type: data.inspectionScope.type,
sample_size: data.inspectionScope.sampleSize,
sample_base: data.inspectionScope.sampleBase,
} : null,
};
}
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${processId}/steps/${stepId}`),
method: 'PUT',
body: apiData,
transform: (d: ApiProcessStep) => transformStepApiToFrontend(d),
errorMessage: '공정 단계 수정에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/**
* 공정 단계 삭제
*/
export async function deleteProcessStep(
processId: string,
stepId: string
): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/processes/${processId}/steps/${stepId}`),
method: 'DELETE',
errorMessage: '공정 단계 삭제에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
/**
* 공정 단계 순서 변경
*/
export async function reorderProcessSteps(
processId: string,
steps: { id: string; order: number }[]
): Promise<{ success: boolean; data?: ProcessStep[]; error?: string }> {
const result = await executeServerAction<ApiProcessStep[]>({
url: buildApiUrl(`/api/v1/processes/${processId}/steps/reorder`),
method: 'PATCH',
body: {
items: steps.map((s) => ({
id: parseInt(s.id, 10),
sort_order: s.order,
})),
},
errorMessage: '공정 단계 순서 변경에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, data: result.data.map(transformStepApiToFrontend) };
}