- 검사 템플릿 타입(InspectionTemplateData 등)을 WorkerScreen/types.ts로 분리 - use server 파일에서 export type 제거 (Turbopack 모듈 평가 시 값으로 처리되는 문제) - 모든 타입 import를 types.ts 직접 참조로 변경
555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
/**
|
|
* 작업자 화면 서버 액션
|
|
* API 연동 (2025-12-26 초기, 2026-02-06 options + step-progress 확장)
|
|
*
|
|
* WorkOrders API를 호출하고 WorkerScreen에 맞는 형식으로 변환
|
|
*/
|
|
|
|
'use server';
|
|
|
|
|
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
|
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
|
|
import type { WorkItemData, WorkStepData, ProcessTab } from './types';
|
|
|
|
// ===== API 타입 =====
|
|
interface WorkOrderApiItem {
|
|
id: number;
|
|
work_order_no: string;
|
|
project_name: string | null;
|
|
process_id: number | null;
|
|
process?: {
|
|
id: number;
|
|
process_name: string;
|
|
process_code: string;
|
|
department?: string | null;
|
|
};
|
|
/** @deprecated process_id + process relation 사용 */
|
|
process_type?: 'screen' | 'slat' | 'bending';
|
|
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
|
|
scheduled_date: string | null;
|
|
memo: string | null;
|
|
created_at: string;
|
|
sales_order?: {
|
|
id: number;
|
|
order_no: string;
|
|
client?: { id: number; name: string };
|
|
root_nodes_count?: number;
|
|
};
|
|
assignee?: { id: number; name: string };
|
|
items?: {
|
|
id: number;
|
|
item_name: string;
|
|
quantity: number;
|
|
specification?: string | null;
|
|
options?: Record<string, unknown> | null;
|
|
source_order_item?: {
|
|
id: number;
|
|
order_node_id: number | null;
|
|
node?: { id: number; name: string; code: string } | null;
|
|
} | null;
|
|
}[];
|
|
}
|
|
|
|
// ===== 상태 변환 =====
|
|
function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus {
|
|
switch (status) {
|
|
case 'in_progress':
|
|
return 'inProgress';
|
|
case 'completed':
|
|
case 'shipped':
|
|
return 'completed';
|
|
default:
|
|
return 'waiting';
|
|
}
|
|
}
|
|
|
|
// ===== API → WorkOrder 변환 =====
|
|
function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
|
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
|
|
const productName = api.items?.[0]?.item_name || '-';
|
|
|
|
// 납기일 계산 (지연 여부)
|
|
const dueDate = api.scheduled_date || '';
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const due = dueDate ? new Date(dueDate) : null;
|
|
const isDelayed = due ? due < today : false;
|
|
const delayDays = due && isDelayed
|
|
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
|
|
: undefined;
|
|
|
|
// process relation → processCode/processName 매핑
|
|
// 신규: process relation 사용, 폴백: deprecated process_type 필드
|
|
const rawProcessName = api.process?.process_name || '';
|
|
const rawProcessCode = api.process?.process_code || '';
|
|
|
|
// 한글 공정명 → 탭 키 매핑 (필터링에 사용)
|
|
const nameToTab: Record<string, { code: string; name: string }> = {
|
|
'스크린': { code: 'screen', name: '스크린' },
|
|
'슬랫': { code: 'slat', name: '슬랫' },
|
|
'절곡': { code: 'bending', name: '절곡' },
|
|
};
|
|
|
|
// 1차: process relation의 process_name으로 매핑
|
|
let processInfo = Object.entries(nameToTab).find(
|
|
([key]) => rawProcessName.includes(key)
|
|
)?.[1];
|
|
|
|
// 2차: deprecated process_type 폴백
|
|
if (!processInfo && api.process_type) {
|
|
const legacyMap: Record<string, { code: string; name: string }> = {
|
|
screen: { code: 'screen', name: '스크린' },
|
|
slat: { code: 'slat', name: '슬랫' },
|
|
bending: { code: 'bending', name: '절곡' },
|
|
};
|
|
processInfo = legacyMap[api.process_type];
|
|
}
|
|
|
|
// 3차: 그래도 없으면 원시값 사용
|
|
if (!processInfo) {
|
|
processInfo = { code: rawProcessCode || 'unknown', name: rawProcessName || '알수없음' };
|
|
}
|
|
|
|
// 아이템을 개소(node)별로 그룹핑
|
|
const nodeMap = new Map<string, { nodeId: number | null; nodeName: string; items: typeof api.items }>();
|
|
for (const item of (api.items || [])) {
|
|
const nodeId = item.source_order_item?.order_node_id ?? null;
|
|
const nodeName = item.source_order_item?.node?.name || '미지정';
|
|
const key = nodeId != null ? String(nodeId) : 'unassigned';
|
|
if (!nodeMap.has(key)) {
|
|
nodeMap.set(key, { nodeId, nodeName, items: [] });
|
|
}
|
|
nodeMap.get(key)!.items!.push(item);
|
|
}
|
|
|
|
const nodeGroups = Array.from(nodeMap.values()).map((g) => ({
|
|
nodeId: g.nodeId,
|
|
nodeName: g.nodeName,
|
|
items: (g.items || []).map((it) => ({
|
|
id: it.id,
|
|
itemName: it.item_name,
|
|
quantity: Number(it.quantity),
|
|
specification: it.specification,
|
|
options: it.options,
|
|
})),
|
|
totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0),
|
|
}));
|
|
|
|
return {
|
|
id: String(api.id),
|
|
orderNo: api.work_order_no,
|
|
productName,
|
|
processCode: processInfo.code,
|
|
processName: processInfo.name,
|
|
client: api.sales_order?.client?.name || '-',
|
|
projectName: api.project_name || '-',
|
|
assignees: api.assignee ? [api.assignee.name] : [],
|
|
quantity: totalQuantity,
|
|
shutterCount: api.sales_order?.root_nodes_count || 0,
|
|
dueDate,
|
|
priority: 5, // 기본 우선순위
|
|
status: mapApiStatus(api.status),
|
|
isUrgent: false, // 긴급 여부는 별도 필드 필요
|
|
isDelayed,
|
|
delayDays,
|
|
instruction: api.memo || undefined,
|
|
salesOrderNo: api.sales_order?.order_no || undefined,
|
|
createdAt: api.created_at,
|
|
nodeGroups,
|
|
};
|
|
}
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
|
|
// ===== 내 작업 목록 조회 =====
|
|
export async function getMyWorkOrders(): Promise<{
|
|
success: boolean;
|
|
data: WorkOrder[];
|
|
error?: string;
|
|
}> {
|
|
interface PaginatedWO { data: WorkOrderApiItem[] }
|
|
const result = await executeServerAction<PaginatedWO>({
|
|
url: `${API_URL}/api/v1/work-orders?per_page=100&worker_screen=1`,
|
|
errorMessage: '작업 목록 조회에 실패했습니다.',
|
|
});
|
|
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
|
const workOrders = (result.data.data || [])
|
|
.filter((item) => !['completed', 'shipped'].includes(item.status))
|
|
.map(transformToWorkerScreenFormat);
|
|
return { success: true, data: workOrders };
|
|
}
|
|
|
|
// ===== 작업 완료 처리 =====
|
|
export async function completeWorkOrder(
|
|
id: string,
|
|
materials?: { materialId: number; quantity: number; lotNo?: string }[]
|
|
): Promise<{ success: boolean; lotNo?: string; error?: string }> {
|
|
const result = await executeServerAction({
|
|
url: `${API_URL}/api/v1/work-orders/${id}/status`,
|
|
method: 'PATCH',
|
|
body: { status: 'completed', materials },
|
|
errorMessage: '작업 완료 처리에 실패했습니다.',
|
|
});
|
|
if (!result.success) return { success: false, error: result.error };
|
|
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
|
return { success: true, lotNo };
|
|
}
|
|
|
|
// ===== 자재 목록 조회 (로트 기준) =====
|
|
export interface MaterialForInput {
|
|
stockLotId: number | null; // StockLot ID (null이면 재고 없음)
|
|
itemId: number;
|
|
lotNo: string | null; // 실제 입고 로트번호
|
|
materialCode: string;
|
|
materialName: string;
|
|
specification: string;
|
|
unit: string;
|
|
requiredQty: number; // 필요 수량
|
|
lotAvailableQty: number; // 로트별 가용 수량
|
|
fifoRank: number;
|
|
}
|
|
|
|
export async function getMaterialsForWorkOrder(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data: MaterialForInput[];
|
|
error?: string;
|
|
}> {
|
|
interface MaterialApiItem {
|
|
stock_lot_id: number | null; item_id: number; lot_no: string | null;
|
|
material_code: string; material_name: string; specification: string;
|
|
unit: string; required_qty: number; lot_available_qty: number; fifo_rank: number;
|
|
}
|
|
const result = await executeServerAction<MaterialApiItem[]>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/materials`,
|
|
errorMessage: '자재 목록 조회에 실패했습니다.',
|
|
});
|
|
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
|
return {
|
|
success: true,
|
|
data: result.data.map((item) => ({
|
|
stockLotId: item.stock_lot_id, itemId: item.item_id, lotNo: item.lot_no,
|
|
materialCode: item.material_code, materialName: item.material_name,
|
|
specification: item.specification ?? '', unit: item.unit,
|
|
requiredQty: item.required_qty, lotAvailableQty: item.lot_available_qty, fifoRank: item.fifo_rank,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ===== 자재 투입 등록 (로트별 수량) =====
|
|
export async function registerMaterialInput(
|
|
workOrderId: string,
|
|
inputs: { stock_lot_id: number; qty: number }[]
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const result = await executeServerAction({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`,
|
|
method: 'POST',
|
|
body: { inputs },
|
|
errorMessage: '자재 투입 등록에 실패했습니다.',
|
|
});
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 이슈 보고 =====
|
|
export async function reportIssue(
|
|
workOrderId: string,
|
|
data: {
|
|
title: string;
|
|
description?: string;
|
|
priority?: 'low' | 'medium' | 'high';
|
|
}
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const result = await executeServerAction({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/issues`,
|
|
method: 'POST',
|
|
body: data,
|
|
errorMessage: '이슈 보고에 실패했습니다.',
|
|
});
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 공정 단계 조회 =====
|
|
export interface ProcessStepItem {
|
|
id: string;
|
|
itemNo: string;
|
|
location: string;
|
|
isPriority: boolean;
|
|
spec: string;
|
|
material: string;
|
|
lot: string;
|
|
}
|
|
|
|
export interface ProcessStep {
|
|
id: string;
|
|
stepNo: number;
|
|
name: string;
|
|
isInspection?: boolean;
|
|
completed: number;
|
|
total: number;
|
|
items: ProcessStepItem[];
|
|
}
|
|
|
|
export async function getProcessSteps(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data: ProcessStep[];
|
|
error?: string;
|
|
}> {
|
|
interface StepApiItem {
|
|
id: number; step_no: number; name: string; is_inspection?: boolean;
|
|
completed: number; total: number;
|
|
items?: { id: number; item_no: string; location: string; is_priority: boolean; spec: string; material: string; lot: string }[];
|
|
}
|
|
const result = await executeServerAction<StepApiItem[]>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/process-steps`,
|
|
errorMessage: '공정 단계 조회에 실패했습니다.',
|
|
});
|
|
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
|
return {
|
|
success: true,
|
|
data: result.data.map((step) => ({
|
|
id: String(step.id), stepNo: step.step_no, name: step.name,
|
|
isInspection: step.is_inspection, completed: step.completed, total: step.total,
|
|
items: (step.items || []).map((item) => ({
|
|
id: String(item.id), itemNo: item.item_no, location: item.location,
|
|
isPriority: item.is_priority, spec: item.spec, material: item.material, lot: item.lot,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ===== 검사 요청 =====
|
|
export async function requestInspection(
|
|
workOrderId: string,
|
|
stepId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const result = await executeServerAction({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/process-steps/${stepId}/inspection-request`,
|
|
method: 'POST',
|
|
body: {},
|
|
errorMessage: '검사 요청에 실패했습니다.',
|
|
});
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 공정 단계 진행 현황 조회 =====
|
|
export interface StepProgressItem {
|
|
id: number;
|
|
process_step_id: number;
|
|
step_code: string;
|
|
step_name: string;
|
|
sort_order: number;
|
|
needs_inspection: boolean;
|
|
connection_type: string | null;
|
|
completion_type: string | null;
|
|
status: string;
|
|
is_completed: boolean;
|
|
completed_at: string | null;
|
|
completed_by: number | null;
|
|
}
|
|
|
|
export async function getStepProgress(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data: StepProgressItem[];
|
|
error?: string;
|
|
}> {
|
|
const result = await executeServerAction<StepProgressItem[]>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/step-progress`,
|
|
errorMessage: '단계 진행 조회에 실패했습니다.',
|
|
});
|
|
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
|
return { success: true, data: result.data };
|
|
}
|
|
|
|
// ===== 공정 단계 완료 토글 =====
|
|
export async function toggleStepProgress(
|
|
workOrderId: string,
|
|
progressId: number
|
|
): Promise<{ success: boolean; data?: StepProgressItem; error?: string }> {
|
|
const result = await executeServerAction<StepProgressItem>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/step-progress/${progressId}/toggle`,
|
|
method: 'PATCH',
|
|
errorMessage: '단계 토글에 실패했습니다.',
|
|
});
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 작업지시 상세 조회 (items + options 포함) =====
|
|
export async function getWorkOrderDetail(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data: WorkItemData[];
|
|
error?: string;
|
|
}> {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const result = await executeServerAction<any>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}`,
|
|
errorMessage: '작업지시 상세 조회에 실패했습니다.',
|
|
});
|
|
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
|
|
|
const wo = result.data;
|
|
const processName = (wo.process?.process_name || '').toLowerCase();
|
|
const processType: ProcessTab =
|
|
processName.includes('스크린') ? 'screen' :
|
|
processName.includes('슬랫') ? 'slat' :
|
|
processName.includes('절곡') ? 'bending' : 'screen';
|
|
|
|
const items: WorkItemData[] = (wo.items || []).map((item: {
|
|
id: number; item_name: string; specification: string | null;
|
|
quantity: string; unit: string | null; status: string;
|
|
sort_order: number; options: Record<string, unknown> | null;
|
|
}, index: number) => {
|
|
const opts = item.options || {};
|
|
const stepProgressList = wo.step_progress || [];
|
|
const processSteps = wo.process?.steps || [];
|
|
|
|
let steps: WorkStepData[];
|
|
if (stepProgressList.length > 0) {
|
|
steps = stepProgressList
|
|
.filter((sp: { work_order_item_id: number | null }) => !sp.work_order_item_id || sp.work_order_item_id === item.id)
|
|
.map((sp: { id: number; process_step: { step_name: string; step_code: string } | null; status: string }) => ({
|
|
id: String(sp.id),
|
|
name: sp.process_step?.step_name || '',
|
|
isMaterialInput: (sp.process_step?.step_code || '').includes('MAT') || (sp.process_step?.step_name || '').includes('자재투입'),
|
|
isCompleted: sp.status === 'completed',
|
|
}));
|
|
} else {
|
|
steps = processSteps.map((ps: { id: number; step_name: string; step_code: string }, si: number) => ({
|
|
id: `${item.id}-step-${si}`,
|
|
name: ps.step_name,
|
|
isMaterialInput: ps.step_code.includes('MAT') || ps.step_name.includes('자재투입'),
|
|
isCompleted: false,
|
|
}));
|
|
}
|
|
|
|
const workItem: WorkItemData = {
|
|
id: String(item.id), itemNo: index + 1,
|
|
itemCode: wo.work_order_no || '-', itemName: item.item_name || '-',
|
|
floor: (opts.floor as string) || '-', code: (opts.code as string) || '-',
|
|
width: (opts.width as number) || 0, height: (opts.height as number) || 0,
|
|
quantity: Number(item.quantity) || 0, processType, steps, materialInputs: [],
|
|
};
|
|
|
|
if (opts.cutting_info) {
|
|
const ci = opts.cutting_info as { width: number; sheets: number };
|
|
workItem.cuttingInfo = { width: ci.width, sheets: ci.sheets };
|
|
}
|
|
if (opts.slat_info) {
|
|
const si = opts.slat_info as { length: number; slat_count: number; joint_bar: number };
|
|
workItem.slatInfo = { length: si.length, slatCount: si.slat_count, jointBar: si.joint_bar };
|
|
}
|
|
if (opts.bending_info) {
|
|
const bi = opts.bending_info as {
|
|
common: { kind: string; type: string; length_quantities: { length: number; quantity: number }[] };
|
|
detail_parts: { part_name: string; material: string; barcy_info: string }[];
|
|
};
|
|
workItem.bendingInfo = {
|
|
common: { kind: bi.common.kind, type: bi.common.type, lengthQuantities: bi.common.length_quantities || [] },
|
|
detailParts: (bi.detail_parts || []).map(dp => ({ partName: dp.part_name, material: dp.material, barcyInfo: dp.barcy_info })),
|
|
};
|
|
}
|
|
if (opts.is_wip) {
|
|
workItem.isWip = true;
|
|
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
|
|
if (wi) {
|
|
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
|
|
}
|
|
}
|
|
if (opts.is_joint_bar) {
|
|
workItem.isJointBar = true;
|
|
const jb = opts.slat_joint_bar_info as { specification: string; length: number; quantity: number } | undefined;
|
|
if (jb) workItem.slatJointBarInfo = jb;
|
|
}
|
|
|
|
return workItem;
|
|
});
|
|
|
|
return { success: true, data: items };
|
|
}
|
|
|
|
// ===== 개소별 중간검사 데이터 저장 =====
|
|
export async function saveItemInspection(
|
|
workOrderId: string,
|
|
itemId: number,
|
|
processType: string,
|
|
inspectionData: Record<string, unknown>
|
|
): Promise<{ success: boolean; data?: Record<string, unknown>; error?: string }> {
|
|
const result = await executeServerAction<Record<string, unknown>>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/inspection`,
|
|
method: 'POST',
|
|
body: { process_type: processType, inspection_data: inspectionData },
|
|
errorMessage: '검사 데이터 저장에 실패했습니다.',
|
|
});
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 작업지시 전체 검사 데이터 조회 =====
|
|
export interface InspectionDataItem {
|
|
item_id: number;
|
|
item_name: string;
|
|
specification: string | null;
|
|
quantity: number;
|
|
sort_order: number;
|
|
options: Record<string, unknown> | null;
|
|
inspection_data: Record<string, unknown>;
|
|
}
|
|
|
|
export async function getWorkOrderInspectionData(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: { work_order_id: number; items: InspectionDataItem[]; total: number };
|
|
error?: string;
|
|
}> {
|
|
const result = await executeServerAction<{ work_order_id: number; items: InspectionDataItem[]; total: number }>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-data`,
|
|
errorMessage: '검사 데이터 조회에 실패했습니다.',
|
|
});
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 검사 문서 템플릿 타입 (types.ts에서 import) =====
|
|
import type { InspectionTemplateData } from './types';
|
|
|
|
export async function getInspectionTemplate(
|
|
workOrderId: string
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: InspectionTemplateData;
|
|
error?: string;
|
|
}> {
|
|
const result = await executeServerAction<InspectionTemplateData>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-template`,
|
|
errorMessage: '검사 템플릿 조회에 실패했습니다.',
|
|
});
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 검사 문서 저장 (Document + DocumentData) =====
|
|
export async function saveInspectionDocument(
|
|
workOrderId: string,
|
|
data: {
|
|
title?: string;
|
|
data: Record<string, unknown>[];
|
|
approvers?: { role_name: string; user_id?: number }[];
|
|
}
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: { document_id: number; document_no: string; status: string };
|
|
error?: string;
|
|
}> {
|
|
const result = await executeServerAction<{ document_id: number; document_no: string; status: string }>({
|
|
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-document`,
|
|
method: 'POST',
|
|
body: data,
|
|
errorMessage: '검사 문서 저장에 실패했습니다.',
|
|
});
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
} |