diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock new file mode 100644 index 00000000..e69de29b diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt new file mode 100644 index 00000000..363424f2 --- /dev/null +++ b/.scannerwork/report-task.txt @@ -0,0 +1,6 @@ +projectKey=vexplor +serverUrl=http://localhost:9000 +serverVersion=26.3.0.120487 +dashboardUrl=http://localhost:9000/dashboard?id=vexplor +ceTaskId=f2c72369-4d50-4483-bf76-b03788385757 +ceTaskUrl=http://localhost:9000/api/ce/task?id=f2c72369-4d50-4483-bf76-b03788385757 diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 8fba4591..c68b1172 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -39,7 +39,6 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", - "playwright": "^1.58.2", "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", @@ -9920,50 +9919,6 @@ "node": ">=8" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 8154371b..e827da0c 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -53,7 +53,6 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", - "playwright": "^1.58.2", "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 33d072d4..c2d9a21f 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -394,6 +394,18 @@ app.use("/api/messenger", messengerRoutes); // 메신저 // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); +// /api prefix 없이 들어온 요청을 /api로 리라우팅 (배포 환경 호환) +app.use("/auth", authRoutes); +app.use("/admin", adminRoutes); +app.use("/admin/web-types", webTypeStandardRoutes); +app.use("/admin/button-actions", buttonActionStandardRoutes); +app.use("/admin/template-standards", templateStandardRoutes); +app.use("/admin/component-standards", componentStandardRoutes); +app.use("/admin/reports", reportRoutes); +app.use("/admin/barcode-labels", barcodeLabelRoutes); +app.use("/messenger", messengerRoutes); +app.use("/screen-management", screenManagementRoutes); + // 404 핸들러 app.use("*", (req, res) => { res.status(404).json({ @@ -450,6 +462,7 @@ async function initializeServices() { runUserMailAccountsMigration, runMessengerMigration, runSmartFactoryLogMigration, + runSmartFactoryScheduleMigration, } = await import("./database/runMigration"); await runDashboardMigration(); @@ -459,6 +472,7 @@ async function initializeServices() { await runUserMailAccountsMigration(); await runMessengerMigration(); await runSmartFactoryLogMigration(); + await runSmartFactoryScheduleMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } @@ -472,6 +486,11 @@ async function initializeServices() { const { CrawlService } = await import("./services/crawlService"); await CrawlService.initializeScheduler(); logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`); + + // 스마트공장 로그 스케줄러 초기화 + const { initSmartFactoryScheduler } = await import("./utils/smartFactoryLog"); + await initSmartFactoryScheduler(); + logger.info(`🏭 스마트공장 로그 스케줄러가 시작되었습니다.`); } catch (error) { logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); } diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 2b04b8e7..97dcab9d 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -4108,6 +4108,7 @@ interface UserWithDeptRequest { dept_name?: string; position_code?: string; position_name?: string; + end_date?: string | null; }; mainDept?: { dept_code: string; @@ -4199,6 +4200,7 @@ export const saveUserWithDept = async ( dept_name: deptName, position_code: userInfo.position_code, position_name: positionName, + end_date: userInfo.end_date !== undefined ? (userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null) : undefined, company_code: companyCode !== "*" ? companyCode : undefined, }; @@ -4230,8 +4232,8 @@ export const saveUserWithDept = async ( email, tel, cell_phone, sabun, user_type, user_type_name, status, locale, dept_code, dept_name, position_code, position_name, - company_code, regdate - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`, + company_code, end_date, regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW())`, [ userInfo.user_id, userInfo.user_name, @@ -4250,6 +4252,7 @@ export const saveUserWithDept = async ( userInfo.position_code || null, positionName, companyCode !== "*" ? companyCode : null, + userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null, ] ); } diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts index 33109dc2..c893226c 100644 --- a/backend-node/src/controllers/analyticsReportController.ts +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -256,11 +256,11 @@ export async function getPurchaseReportData(req: any, res: Response): Promise { + logger.warn("POP 레이아웃 자동 초기화 중 오류 (무시):", err); + }); + } } catch (popError) { logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); } @@ -563,4 +572,78 @@ export class AuthController { }); } } + + /** + * POP 레이아웃 자동 초기화 + * 해당 회사의 screen_layouts_pop 레코드가 없으면 + * 템플릿(공통 '*' 또는 COMPANY_7)에서 복제하여 생성 + * + * 기본 POP 화면 ID: 5, 6, 7, 8, 6526, 6527, 6528, 6529 + */ + static async initPopLayoutsForCompany(companyCode: string): Promise { + // SUPER_ADMIN이나 공통(*)은 초기화 불필요 + if (companyCode === "*" || companyCode === "COMPANY_7") return; + + const POP_SCREEN_IDS = [5, 6, 7, 8, 6526, 6527, 6528, 6529]; + + // 이미 해당 회사의 POP 레이아웃이 하나라도 있으면 스킵 (중복 초기화 방지) + const existing = await query<{ cnt: string }>( + `SELECT COUNT(*)::text AS cnt FROM screen_layouts_pop + WHERE company_code = $1 AND screen_id = ANY($2::int[])`, + [companyCode, POP_SCREEN_IDS], + ); + const existingCount = parseInt(existing[0]?.cnt || "0", 10); + if (existingCount > 0) { + logger.debug(`POP 레이아웃 이미 존재 (${companyCode}): ${existingCount}개, 스킵`); + return; + } + + logger.info(`POP 레이아웃 자동 초기화 시작: ${companyCode}`); + + // 회사명 조회 (레이아웃 내 회사명 치환용) + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode], + ); + const companyName = companyInfo?.company_name || companyCode; + + let initCount = 0; + for (const screenId of POP_SCREEN_IDS) { + // 템플릿 조회: 공통(*) 우선, 없으면 COMPANY_7 폴백 + let template = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + if (!template) { + template = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, + [screenId], + ); + } + + if (!template) { + logger.debug(`POP 템플릿 없음 (screen_id=${screenId}), 스킵`); + continue; + } + + // 레이아웃 복제 + 회사명 치환 + const layoutStr = JSON.stringify(template.layout_data); + const replacedStr = layoutStr + .replace(/\(주\)탑씰/g, companyName) + .replace(/탑씰/g, companyName) + .replace(/TOPSEAL/gi, companyName); + + await query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM') + ON CONFLICT (screen_id, company_code) DO NOTHING`, + [screenId, companyCode, replacedStr], + ); + initCount++; + } + + logger.info(`POP 레이아웃 자동 초기화 완료: ${companyCode}, ${initCount}개 화면`); + } } diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 66418099..ea9bd7a9 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -843,45 +843,45 @@ export const previewFile = async ( return; } - // 파일 경로에서 회사코드와 날짜 폴더 추출 - const filePathParts = fileRecord.file_path!.split("/"); - let fileCompanyCode = filePathParts[2] || "DEFAULT"; - - // company_* 처리 (실제 회사 코드로 변환) - if (fileCompanyCode === "company_*") { - fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 - } - + // file_path의 /uploads/ 이후를 baseUploadDir과 직접 결합 const fileName = fileRecord.saved_file_name!; - - // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) - let dateFolder = ""; - if (filePathParts.length >= 6) { - dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; + const dbFilePath = fileRecord.file_path || ""; + const uploadsIdx = dbFilePath.indexOf("/uploads/"); + let finalPath: string; + if (uploadsIdx !== -1) { + const relativePath = dbFilePath.substring(uploadsIdx + "/uploads/".length); + finalPath = path.join(baseUploadDir, relativePath); + } else { + // fallback: 기존 방식 + const filePathParts = dbFilePath.split("/"); + let fileCompanyCode = filePathParts[2] || "DEFAULT"; + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; + } + let dateFolder = ""; + if (filePathParts.length >= 6) { + dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; + } + const companyUploadDir = getCompanyUploadDir( + fileCompanyCode, + dateFolder || undefined + ); + finalPath = path.join(companyUploadDir, fileName); } - const companyUploadDir = getCompanyUploadDir( - fileCompanyCode, - dateFolder || undefined - ); - const filePath = path.join(companyUploadDir, fileName); - console.log("🔍 파일 미리보기 경로 확인:", { objid: objid, filePathFromDB: fileRecord.file_path, companyCode: companyCode, - dateFolder: dateFolder, - fileName: fileName, - companyUploadDir: companyUploadDir, - finalFilePath: filePath, - fileExists: fs.existsSync(filePath), + finalFilePath: finalPath, + fileExists: fs.existsSync(finalPath), }); - if (!fs.existsSync(filePath)) { - console.error("❌ 파일 없음:", filePath); + if (!fs.existsSync(finalPath)) { + console.error("❌ 파일 없음:", finalPath); res.status(404).json({ success: false, - message: `실제 파일을 찾을 수 없습니다: ${filePath}`, + message: `실제 파일을 찾을 수 없습니다: ${finalPath}`, }); return; } @@ -929,7 +929,7 @@ export const previewFile = async ( res.setHeader("Content-Type", mimeType); // 파일 스트림으로 전송 - const fileStream = fs.createReadStream(filePath); + const fileStream = fs.createReadStream(finalPath); fileStream.pipe(res); } catch (error) { console.error("파일 미리보기 오류:", error); diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index b4b942a0..c2d8ed00 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -229,15 +229,33 @@ export async function create(req: AuthenticatedRequest, res: Response) { ); } - // 판매출고인 경우 출하지시의 ship_qty 업데이트 + // 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") { + const outQtyNum = Number(item.outbound_qty) || 0; await client.query( `UPDATE shipment_instruction_detail SET ship_qty = COALESCE(ship_qty, 0) + $1, updated_date = NOW() WHERE id = $2 AND company_code = $3`, - [item.outbound_qty || 0, item.source_id, companyCode] + [outQtyNum, item.source_id, companyCode] ); + + // 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트 + const sidRes = await client.query( + `SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode] + ); + const detailId = sidRes.rows[0]?.detail_id; + if (detailId) { + await client.query( + `UPDATE sales_order_detail + SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text, + balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [outQtyNum, detailId, companyCode] + ); + } } } @@ -477,8 +495,21 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { export async function generateNumber(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const pool = getPool(); + const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string); + // 1순위: POP 화면설정에서 선택한 채번규칙 사용 + if (ruleId && ruleId !== "__none__") { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode); + return res.json({ success: true, data: newNumber }); + } catch (e: any) { + logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message }); + } + } + + // 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX) + const pool = getPool(); const today = new Date(); const yyyy = today.getFullYear(); const prefix = `OUT-${yyyy}-`; diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 6ce685fb..8d0af7c5 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -11,6 +11,65 @@ interface DefectDetailItem { disposition: string; } +// 자동 마이그레이션: batch_id 컬럼 추가 (배치/로트 추적용) +let _batchMigrationDone = false; +async function ensureBatchIdColumn() { + if (_batchMigrationDone) return; + try { + const pool = getPool(); + await pool.query("ALTER TABLE work_order_process ADD COLUMN IF NOT EXISTS batch_id VARCHAR(100)"); + _batchMigrationDone = true; + } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } +} + +/** + * inventory_stock UPSERT 공통 함수 + * PC의 receivingController와 동일한 SELECT→INSERT/UPDATE 패턴 사용. + * (inventory_stock에 UNIQUE 제약조건이 없으므로 ON CONFLICT 사용 불가) + */ +async function upsertInventoryStock( + client: { query: (text: string, values?: any[]) => Promise }, + companyCode: string, + itemCode: string, + warehouseCode: string, + locationCode: string | null, + qty: number, + userId: string +): Promise { + const whCode = warehouseCode || null; + const locCode = locationCode || null; + + const existing = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + + if (existing.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), + last_in_date = NOW(), + updated_date = NOW(), + writer = $2 + WHERE id = $3`, + [qty, userId, existing.rows[0].id] + ); + } else { + await client.query( + `INSERT INTO inventory_stock ( + id, company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_in_date, + created_date, updated_date, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, + [companyCode, itemCode, whCode, locCode, String(qty), userId] + ); + } +} + /** * 체크리스트 복사 공통 함수 * 분할 행/재작업 카드 생성 시 마스터의 체크리스트를 새 행에 복사한다. @@ -53,13 +112,15 @@ async function copyChecklistToSplit( ORDER BY pwi.sort_order, pwd.sort_order`, [newProcessId, userId, routingDetailId, companyCode] ); - return result.rowCount ?? 0; + const countA = result.rowCount ?? 0; + if (countA > 0) return countA; + // A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough } - // B. routing_detail_id가 없으면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) + // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) const result = await client.query( `INSERT INTO process_work_result ( - id, company_code, work_order_process_id, + company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -68,7 +129,7 @@ async function copyChecklistToSplit( status, writer ) SELECT - gen_random_uuid()::text, company_code, $1, + company_code, $1, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -84,6 +145,101 @@ async function copyChecklistToSplit( return result.rowCount ?? 0; } +/** + * 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성 + * createWorkProcesses와 syncWorkInstructions 양쪽에서 재사용한다. + * + * @returns 생성된 공정 목록 + 체크리스트 총 수. 이미 존재하면 null 반환. + */ +async function generateWorkProcessesForInstruction( + client: { query: (text: string, values?: any[]) => Promise }, + workInstructionId: string, + routingVersionId: string, + planQty: string | null, + companyCode: string, + userId: string +): Promise<{ + processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>; + total_checklists: number; +} | null> { + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [workInstructionId, companyCode] + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + return null; // 이미 존재 + } + + // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, + COALESCE(pm.process_name, rd.process_code) as process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON pm.process_code = rd.process_code + AND pm.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, + [routingVersionId, companyCode] + ); + + if (routingDetails.rows.length === 0) { + return null; // 공정 없음 + } + + const processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }> = []; + let totalChecklists = 0; + + for (const rd of routingDetails.rows) { + // 2. work_order_process INSERT + const wopResult = await client.query( + `INSERT INTO work_order_process ( + id, company_code, wo_id, seq_no, process_code, process_name, + is_required, is_fixed_order, standard_time, plan_qty, + status, routing_detail_id, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + companyCode, + workInstructionId, + rd.seq_no, + rd.process_code, + rd.process_name, + rd.is_required, + rd.is_fixed_order, + rd.standard_time, + planQty || null, + parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" ? "acceptable" : "waiting", + rd.id, + userId, + ] + ); + const wopId = wopResult.rows[0].id; + + // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) + const checklistCount = await copyChecklistToSplit( + client, wopId, wopId, rd.id, companyCode, userId + ); + totalChecklists += checklistCount; + + processes.push({ + id: wopId, + seq_no: rd.seq_no, + process_name: rd.process_name, + checklist_count: checklistCount, + }); + } + + return { processes, total_checklists: totalChecklists }; +} + /** * D-BE1: 작업지시 공정 일괄 생성 * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. @@ -121,92 +277,15 @@ export const createWorkProcesses = async ( await client.query("BEGIN"); - // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 - const existCheck = await client.query( - `SELECT COUNT(*) as cnt FROM work_order_process - WHERE wo_id = $1 AND company_code = $2`, - [work_instruction_id, companyCode] + const result = await generateWorkProcessesForInstruction( + client, work_instruction_id, routing_version_id, plan_qty, companyCode, userId ); - if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + + if (!result) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, - message: "이미 공정이 생성된 작업지시입니다.", - }); - } - - // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) - const routingDetails = await client.query( - `SELECT rd.id, rd.seq_no, rd.process_code, - COALESCE(pm.process_name, rd.process_code) as process_name, - rd.is_required, rd.is_fixed_order, rd.standard_time - FROM item_routing_detail rd - LEFT JOIN process_mng pm ON pm.process_code = rd.process_code - AND pm.company_code = rd.company_code - WHERE rd.routing_version_id = $1 AND rd.company_code = $2 - ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, - [routing_version_id, companyCode] - ); - - if (routingDetails.rows.length === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ - success: false, - message: "라우팅 버전에 등록된 공정이 없습니다.", - }); - } - - const processes: Array<{ - id: string; - seq_no: string; - process_name: string; - checklist_count: number; - }> = []; - let totalChecklists = 0; - - for (const rd of routingDetails.rows) { - // 2. work_order_process INSERT - const wopResult = await client.query( - `INSERT INTO work_order_process ( - id, company_code, wo_id, seq_no, process_code, process_name, - is_required, is_fixed_order, standard_time, plan_qty, - status, routing_detail_id, writer - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id`, - [ - companyCode, - work_instruction_id, - rd.seq_no, - rd.process_code, - rd.process_name, - rd.is_required, - rd.is_fixed_order, - rd.standard_time, - plan_qty || null, - parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting", - rd.id, - userId, - ] - ); - const wopId = wopResult.rows[0].id; - - // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) - const checklistCount = await copyChecklistToSplit( - client, wopId, wopId, rd.id, companyCode, userId - ); - totalChecklists += checklistCount; - - processes.push({ - id: wopId, - seq_no: rd.seq_no, - process_name: rd.process_name, - checklist_count: checklistCount, - }); - - logger.info("[pop/production] 공정 생성 완료", { - wopId, - processName: rd.process_name, - checklistCount, + message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.", }); } @@ -215,16 +294,16 @@ export const createWorkProcesses = async ( logger.info("[pop/production] create-work-processes 완료", { companyCode, work_instruction_id, - total_processes: processes.length, - total_checklists: totalChecklists, + total_processes: result.processes.length, + total_checklists: result.total_checklists, }); return res.json({ success: true, data: { - processes, - total_processes: processes.length, - total_checklists: totalChecklists, + processes: result.processes, + total_processes: result.processes.length, + total_checklists: result.total_checklists, }, }); } catch (error: any) { @@ -239,6 +318,130 @@ export const createWorkProcesses = async ( } }; +/** + * POP 온디맨드 Pull: 미동기화 작업지시 일괄 sync + * routing이 있지만 work_order_process가 없는 작업지시를 찾아 공정을 자동 생성한다. + * 각 건별 개별 try-catch로 하나 실패해도 나머지 진행. + */ +export const syncWorkInstructions = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + logger.info("[pop/production] sync-work-instructions 요청", { + companyCode, + userId, + }); + + // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 + const unsyncedResult = await pool.query( + `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty + FROM work_instruction wi + WHERE wi.company_code = $1 + AND wi.routing IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM work_order_process wop + WHERE wop.wo_id = wi.id AND wop.company_code = $1 + )`, + [companyCode] + ); + + const unsynced = unsyncedResult.rows; + + if (unsynced.length === 0) { + return res.json({ + success: true, + data: { synced: 0, skipped: 0, errors: 0, details: [] }, + }); + } + + let synced = 0; + let skipped = 0; + let errors = 0; + const details: Array<{ + work_instruction_id: string; + work_instruction_no: string; + status: "synced" | "skipped" | "error"; + process_count?: number; + error?: string; + }> = []; + + for (const wi of unsynced) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const result = await generateWorkProcessesForInstruction( + client, wi.id, wi.routing, wi.qty || null, companyCode, userId + ); + + if (!result) { + await client.query("ROLLBACK"); + skipped++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "skipped", + }); + continue; + } + + await client.query("COMMIT"); + synced++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "synced", + process_count: result.processes.length, + }); + + logger.info("[pop/production] sync: 공정 생성 완료", { + work_instruction_no: wi.work_instruction_no, + process_count: result.processes.length, + }); + } catch (err: any) { + await client.query("ROLLBACK"); + errors++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + status: "error", + error: err.message || "알 수 없는 오류", + }); + logger.error("[pop/production] sync: 개별 오류", { + work_instruction_no: wi.work_instruction_no, + error: err.message, + }); + } finally { + client.release(); + } + } + + logger.info("[pop/production] sync-work-instructions 완료", { + companyCode, + synced, + skipped, + errors, + }); + + return res.json({ + success: true, + data: { synced, skipped, errors, details }, + }); + } catch (error: any) { + logger.error("[pop/production] sync-work-instructions 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "작업지시 동기화 중 오류가 발생했습니다.", + }); + } +}; + /** * D-BE2: 타이머 API (시작/일시정지/재시작) */ @@ -752,6 +955,33 @@ export const saveResult = async ( }); } + // === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 === + if (prev.parent_process_id) { + await pool.query( + `UPDATE work_order_process + SET good_qty = sub.sum_good, + defect_qty = sub.sum_defect, + total_production_qty = sub.sum_total, + concession_qty = sub.sum_concession, + updated_date = NOW() + FROM ( + SELECT + COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, + COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, + COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, + COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2 + ) sub + WHERE id = $1 AND company_code = $2`, + [prev.parent_process_id, companyCode] + ); + logger.info("[pop/production] master 합산 업데이트", { + masterId: prev.parent_process_id, + splitId: work_order_process_id, + }); + } + // 현재 분할 행의 공정 정보 조회 const currentSeq = await pool.query( `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, @@ -768,14 +998,44 @@ export const saveResult = async ( // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) if (currentSeq.rowCount > 0 && defect_detail && Array.isArray(defect_detail)) { let totalReworkQty = 0; + let targetProcessCode: string | null = null; for (const item of defect_detail) { if (item.disposition === "rework") { totalReworkQty += parseInt(item.qty, 10) || 0; + if (item.target_process_code) targetProcessCode = item.target_process_code; } } if (totalReworkQty > 0) { const proc = currentSeq.rows[0]; const masterId = proc.parent_process_id || work_order_process_id; + + // 재작업 대상 공정 결정 + let reworkSeqNo = proc.seq_no; + let reworkProcessCode = proc.process_code; + let reworkProcessName = proc.process_name; + let reworkRoutingDetailId = proc.routing_detail_id; + let reworkMasterId = masterId; + + // target_process_code가 지정되면 해당 공정 정보를 조회 + if (targetProcessCode) { + const targetProc = await pool.query( + `SELECT id, seq_no, process_code, process_name, routing_detail_id + FROM work_order_process + WHERE wo_id = $1 AND process_code = $2 AND company_code = $3 + AND parent_process_id IS NULL + LIMIT 1`, + [proc.wo_id, targetProcessCode, companyCode] + ); + if (targetProc.rowCount > 0) { + const tp = targetProc.rows[0]; + reworkSeqNo = tp.seq_no; + reworkProcessCode = tp.process_code; + reworkProcessName = tp.process_name; + reworkRoutingDetailId = tp.routing_detail_id; + reworkMasterId = tp.id; // 지정 공정의 마스터 ID + } + } + const reworkInsert = await pool.query( `INSERT INTO work_order_process ( id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, @@ -790,134 +1050,134 @@ export const saveResult = async ( $12, $13, $14 ) RETURNING id`, [ - proc.wo_id, proc.seq_no, proc.process_code, proc.process_name, + proc.wo_id, reworkSeqNo, reworkProcessCode, reworkProcessName, proc.is_required, proc.is_fixed_order, proc.standard_time, - proc.equipment_code, proc.routing_detail_id, + proc.equipment_code, reworkRoutingDetailId, String(totalReworkQty), work_order_process_id, - masterId, companyCode, userId, + reworkMasterId, companyCode, userId, ] ); // 재작업 카드에 체크리스트 복사 const reworkId = reworkInsert.rows[0]?.id; if (reworkId) { const reworkChecklistCount = await copyChecklistToSplit( - pool, masterId, reworkId, proc.routing_detail_id, companyCode, userId + pool, reworkMasterId, reworkId, reworkRoutingDetailId, companyCode, userId ); logger.info("[pop/production] 재작업 카드 자동 생성", { reworkId, sourceId: work_order_process_id, reworkQty: totalReworkQty, + targetProcess: targetProcessCode || "(같은 공정)", + reworkSeqNo, checklistCount: reworkChecklistCount, }); } } } - // 다음 공정 활성화: 양품 발생 시 다음 seq_no의 원본(마스터) 행을 acceptable로 - // waiting -> acceptable (최초 활성화) - // in_progress -> acceptable (앞공정 추가양품으로 접수가능량 복원) - if (addGood > 0 && currentSeq.rowCount > 0) { - const { seq_no, wo_id } = currentSeq.rows[0]; - const nextSeq = String(parseInt(seq_no, 10) + 1); - const nextUpdate = await pool.query( - `UPDATE work_order_process - SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, - updated_date = NOW() - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - AND status != 'completed' - RETURNING id, process_name, status`, - [wo_id, nextSeq, companyCode] - ); - if (nextUpdate.rowCount > 0) { - logger.info("[pop/production] 다음 공정 상태 전환", { - nextProcess: nextUpdate.rows[0], - }); - } - } - - // 개별 분할 행 자동완료: 이 분할 행의 접수분 전량 생산 시 completed + // 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행) if (currentSeq.rowCount > 0) { - const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0]; - const myInputQty = parseInt(current_input_qty, 10) || 0; + const { seq_no: csSeq, wo_id: csWoId, current_input_qty: csInputQty, instruction_qty: csInstrQty, parent_process_id: csParentId } = currentSeq.rows[0]; + const csMyInput = parseInt(csInputQty, 10) || 0; - if (newTotal >= myInputQty && myInputQty > 0) { + if (newTotal >= csMyInput && csMyInput > 0) { await pool.query( - `UPDATE work_order_process - SET status = 'completed', - result_status = 'confirmed', - completed_at = NOW()::text, - completed_by = $3, - updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [work_order_process_id, companyCode, userId] ); - logger.info("[pop/production] 분할 행 자동 완료", { - work_order_process_id, newTotal, myInputQty, - }); - // 같은 공정의 모든 분할 행이 completed인지 체크 -> 원본도 completed로 - const seqNum = parseInt(seq_no, 10); - const instrQty = parseInt(instruction_qty, 10) || 0; - - // 앞공정 양품 합산 (접수가능 잔여 계산용) - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, - [wo_id, prevSeq, companyCode] + // 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed + const csSeqNum = parseInt(csSeq, 10); + let csPrevGood = parseInt(csInstrQty, 10) || 0; + if (csSeqNum > 1) { + const prev = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg + FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [csWoId, String(csSeqNum - 1), companyCode] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } + if (prev.rowCount > 0) csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; } - - // 같은 seq_no의 모든 분할 행 접수량 합산 + 미완료 행 카운트 - const siblingCheck = await pool.query( - `SELECT - COALESCE(SUM(input_qty::int), 0) as total_input, - COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, seq_no, companyCode] + const sibCheck = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic + FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, + [csWoId, csSeq, companyCode] ); - - const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; - const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; - const remainingAcceptable = prevGoodQty - totalInput; - - // 모든 분할 행 완료 + 잔여 접수가능 0 -> 원본(마스터)도 completed - if (incompleteCount === 0 && remainingAcceptable <= 0) { - const masterId = currentSeq.rows[0].parent_process_id; - if (masterId) { - await pool.query( - `UPDATE work_order_process - SET status = 'completed', - result_status = 'confirmed', - completed_at = NOW()::text, - completed_by = $3, - updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, - [masterId, companyCode, userId] - ); - logger.info("[pop/production] 원본(마스터) 공정 자동 완료", { - masterId, totalInput, prevGoodQty, - }); - } + const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0; + const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0; + if (csIncomplete === 0 && csPrevGood - csTotalInput <= 0 && csParentId) { + await pool.query( + `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND status != 'completed'`, + [csParentId, companyCode, userId] + ); } } - // 작업지시 전체 완료 판정 - const { wo_id: woIdForWi } = currentSeq.rows[0]; - await checkAndCompleteWorkInstruction(pool, woIdForWi, companyCode, userId); + await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId); } + // 다음 공정 활성화 (다중공정 대응) + // is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화 + if (addGood > 0 && currentSeq.rowCount > 0) { + const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0]; + const seqNum = parseInt(seq_no, 10); + + let shouldActivateNext = true; + + if (is_fixed_order === "Y") { + // 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인 + // (병렬 그룹 = 같은 seq_no를 공유하는 공정들) + const groupCheck = await pool.query( + `SELECT id, seq_no, status, + COALESCE(good_qty::int, 0) + COALESCE(concession_qty::int, 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND parent_process_id IS NULL + AND seq_no = $3 + ORDER BY CAST(seq_no AS int)`, + [wo_id, companyCode, seq_no] + ); + + // 같은 seq의 미완료 공정 확인 (병렬 그룹 내) + const incomplete = groupCheck.rows.filter((r: Record) => + String(r.status) !== "completed" && parseInt(String(r.total_good), 10) <= 0 + ); + shouldActivateNext = incomplete.length === 0; + + if (!shouldActivateNext) { + logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", { + groupSize: groupCheck.rows.length, + incomplete: incomplete.length, + }); + } + } + + if (shouldActivateNext) { + // 다음 seq 활성화 (completed도 재활성화 — 새 양품이 들어오면 추가 접수 가능) + const nextSeq = String(seqNum + 1); + const nextUpdate = await pool.query( + `UPDATE work_order_process + SET status = 'acceptable', + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + RETURNING id, process_name, status`, + [wo_id, nextSeq, companyCode] + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } + } + } + + // (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨) + logger.info("[pop/production] save-result 완료 (누적)", { companyCode, work_order_process_id, @@ -932,9 +1192,22 @@ export const saveResult = async ( [work_order_process_id, companyCode] ); + // 리워크 정보도 응답에 포함 (프론트에서 다음 공정 접수 시 전달 가능) + const responseData = latestData.rows[0] || result.rows[0]; + if (responseData) { + const reworkInfo = await pool.query( + `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, + [work_order_process_id] + ); + if (reworkInfo.rows[0]?.rework_source_id) { + responseData.rework_source_id = reworkInfo.rows[0].rework_source_id; + responseData.is_rework = reworkInfo.rows[0].is_rework; + } + } + return res.json({ success: true, - data: latestData.rows[0] || result.rows[0], + data: responseData, }); } catch (error: any) { logger.error("[pop/production] save-result 오류:", error); @@ -979,13 +1252,14 @@ const checkAndCompleteWorkInstruction = async ( const totalGoodResult = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, [woId, maxSeq, companyCode] ); const completedQty = totalGoodResult.rows[0].total_good; - await pool.query( + const updateResult = await pool.query( `UPDATE work_instruction SET status = 'completed', progress_status = 'completed', @@ -993,13 +1267,60 @@ const checkAndCompleteWorkInstruction = async ( writer = $4, updated_date = NOW() WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, + AND status != 'completed' + RETURNING id, item_id`, [woId, companyCode, String(completedQty), userId] ); logger.info("[pop/production] 작업지시 전체 완료", { woId, completedQty, companyCode, }); + + // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT + if (updateResult.rowCount > 0 && completedQty > 0) { + try { + const itemId = updateResult.rows[0].item_id; + + // item_info에서 item_number 조회 + const itemResult = await pool.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode] + ); + if (itemResult.rowCount === 0) { + logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { itemId, companyCode }); + return; + } + const itemCode = itemResult.rows[0].item_number; + + // 마지막 공정의 창고 설정 조회 (마스터 행에서) + const warehouseResult = await pool.query( + `SELECT target_warehouse_id, target_location_code + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + LIMIT 1`, + [woId, maxSeq, companyCode] + ); + + if (warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id) { + logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { woId }); + return; + } + + const warehouseCode = warehouseResult.rows[0].target_warehouse_id; + const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; + + // inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock(pool, companyCode, itemCode, warehouseCode, locationCode, completedQty, userId); + + logger.info("[pop/production] 생산완료→재고 입고 완료", { + woId, itemCode, warehouseCode, locationCode, qty: completedQty, companyCode, + }); + } catch (inventoryError: any) { + // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) + logger.error("[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError); + } + } }; /** @@ -1090,15 +1411,37 @@ export const confirmResult = async ( const nextSeq = String(seqNum + 1); await pool.query( `UPDATE work_order_process - SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, + SET status = 'acceptable', updated_date = NOW() WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - AND status != 'completed'`, + AND parent_process_id IS NULL`, [wo_id, nextSeq, companyCode] ); } + // === BUG-2 FIX: confirmResult에서도 master 합산 === + if (parent_process_id) { + await pool.query( + `UPDATE work_order_process + SET good_qty = sub.sum_good, + defect_qty = sub.sum_defect, + total_production_qty = sub.sum_total, + concession_qty = sub.sum_concession, + updated_date = NOW() + FROM ( + SELECT + COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, + COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, + COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, + COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2 + ) sub + WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode] + ); + } + // 마스터 자동완료 캐스케이드 (분할 행인 경우) if (parent_process_id) { let prevGoodQty = instrQty; @@ -1107,7 +1450,8 @@ export const confirmResult = async ( const prevProcess = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { @@ -1298,32 +1642,51 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) const instrQty = parseInt(instruction_qty, 10) || 0; const seqNum = parseInt(seq_no, 10); - // 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산 - const totalAccepted = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, seq_no, companyCode] + // 재작업 카드 여부 확인 + const reworkCheck = await pool.query( + `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, + [work_order_process_id] ); - const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + const isRework = reworkCheck.rows[0]?.is_rework === "Y"; - // 앞공정 양품+특채 합산 - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + let myInputQty: number; + let prevGoodQty: number; + let availableQty: number; + + if (isRework) { + // 재작업 카드: 자체 input_qty가 접수 가능 수량 + const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0; + myInputQty = 0; + prevGoodQty = reworkInput; + availableQty = reworkInput; + } else { + // 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외) + const totalAccepted = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, - [wo_id, prevSeq, companyCode] + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND (is_rework IS NULL OR is_rework != 'Y')`, + [wo_id, seq_no, companyCode] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } + myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - const availableQty = Math.max(0, prevGoodQty - myInputQty); + prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + availableQty = Math.max(0, prevGoodQty - myInputQty); + } logger.info("[pop/production] available-qty 조회", { work_order_process_id, @@ -1333,6 +1696,41 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) instructionQty: instrQty, }); + // 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용) + // rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산 + let reworkAvailableQty = 0; + if (!isRework && seqNum > 1) { + const prevSeq = String(seqNum - 1); + // 앞공정의 리워크 완료 SPLIT들 (rework_source_id별) + const reworkSplits = await pool.query( + `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND status = 'completed' + AND good_qty::int > 0 + GROUP BY rework_source_id`, + [wo_id, prevSeq, companyCode] + ); + // 현재 공정에서 각 rework_source_id별로 소비된 수량 + for (const rs of reworkSplits.rows) { + const srcId = rs.rework_source_id; + const srcGood = parseInt(rs.rg, 10) || 0; + const consumedResult = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as consumed + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND rework_source_id = $4`, + [wo_id, seq_no, companyCode, srcId] + ); + const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; + reworkAvailableQty += Math.max(0, srcGood - consumed); + } + } + return res.json({ success: true, data: { @@ -1340,6 +1738,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) myInputQty, availableQty, instructionQty: instrQty, + reworkAvailableQty, // 리워크 물량 포함 수량 }, }); } catch (error: any) { @@ -1361,12 +1760,14 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) */ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); + const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, accept_qty } = req.body; if (!work_order_process_id || !accept_qty) { + client.release(); return res.status(400).json({ success: false, message: "work_order_process_id와 accept_qty가 필요합니다.", @@ -1375,102 +1776,232 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => const qty = parseInt(accept_qty, 10); if (qty <= 0) { + client.release(); return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); } - // 원본(마스터) 행 조회 - parent_process_id가 NULL인 행 또는 직접 지정된 행 - const current = await pool.query( + await client.query("BEGIN"); + + // 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지) + const current = await client.query( `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.equipment_code, wop.routing_detail_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code - WHERE wop.id = $1 AND wop.company_code = $2`, + WHERE wop.id = $1 AND wop.company_code = $2 + FOR UPDATE OF wop`, [work_order_process_id, companyCode] ); if (current.rowCount === 0) { + await client.query("ROLLBACK"); + client.release(); return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); } const row = current.rows[0]; - // 접수 대상은 원본(마스터) 행이어야 함 const masterId = row.parent_process_id || row.id; if (row.status === "completed") { + await client.query("ROLLBACK"); + client.release(); return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); } if (row.status !== "acceptable") { - return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다. 접수가능 상태의 카드에서 접수해주세요.` }); + await client.query("ROLLBACK"); + client.release(); + return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.` }); } const instrQty = parseInt(row.instruction_qty, 10) || 0; const seqNum = parseInt(row.seq_no, 10); - // 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산 - const totalAccepted = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [row.wo_id, row.seq_no, companyCode] + // 재작업 카드 여부 확인 + const isReworkCard = await client.query( + `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, + [work_order_process_id] ); - const currentTotalInput = totalAccepted.rows[0].total_input; + const isRework = isReworkCard.rows[0]?.is_rework === "Y"; + const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0; - // 앞공정 양품+특채 합산 - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + let prevGoodQty: number; + let currentTotalInput: number; + let availableQty: number; + + if (isRework) { + // 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관) + prevGoodQty = reworkInputQty; + currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음 + availableQty = reworkInputQty; + } else { + // 일반 카드: 앞공정 양품 - 기접수합계 + const totalAccepted = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, - [row.wo_id, prevSeq, companyCode] + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND (is_rework IS NULL OR is_rework != 'Y')`, + [row.wo_id, row.seq_no, companyCode] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + + prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await client.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [row.wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } } + availableQty = prevGoodQty - currentTotalInput; } - const availableQty = prevGoodQty - currentTotalInput; if (qty > availableQty) { + await client.query("ROLLBACK"); + client.release(); return res.status(400).json({ success: false, message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, }); } - // 분할 행 INSERT (원본 행에서 공정 정보 복사) - const result = await pool.query( - `INSERT INTO work_order_process ( - id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + // batch_id: 컬럼이 있으면 포함, 없으면 제외 + const batchId = req.body.batch_id || `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const hasBatchCol = _batchMigrationDone; + + // 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지 + let splitIsRework: string | null = null; + let splitReworkSourceId: string | null = null; + + if (isRework) { + // 케이스 1: 리워크 카드에서 직접 접수 + const parentReworkInfo = await client.query( + `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, [work_order_process_id] + ); + splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null; + splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null; + } else if (req.body.rework_source_id) { + // 케이스 2: 프론트에서 리워크 추적 정보 전달 + splitIsRework = "Y"; + splitReworkSourceId = req.body.rework_source_id; + } else if (seqNum > 1) { + // 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인 + const prevSeq = String(seqNum - 1); + // rework_source_id별로 개별 추적 + const prevReworkSplits = await client.query( + `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND status = 'completed' + AND good_qty::int > 0 + GROUP BY rework_source_id`, + [row.wo_id, prevSeq, companyCode] + ); + + // 각 rework_source별로 미소진 수량 확인 + for (const rs of prevReworkSplits.rows) { + const srcId = rs.rework_source_id; + const srcGood = parseInt(rs.rework_good, 10) || 0; + const consumedResult = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as consumed + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND rework_source_id = $4`, + [row.wo_id, row.seq_no, companyCode, srcId] + ); + const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; + const remaining = srcGood - consumed; + + if (remaining > 0 && qty <= remaining) { + // 합류 판정: 일반 물량이 있으면 합류(마크 없음), 없으면 마크 부착 + const normalAvailable = availableQty - remaining; + if (normalAvailable <= 0) { + // 일반 물량 없음 → 합류 불가 → 리워크 마크 + splitIsRework = "Y"; + splitReworkSourceId = srcId; + } + // normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null) + break; + } + } + } + + // 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함) + const reworkCols = splitIsRework ? ", is_rework, rework_source_id" : ""; + const reworkVals = splitIsRework ? `, $${hasBatchCol ? 15 : 14}, $${hasBatchCol ? 16 : 15}` : ""; + const reworkParams = splitIsRework ? [splitIsRework, splitReworkSourceId] : []; + + const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, total_production_qty, result_status, accepted_by, accepted_at, started_at, - parent_process_id, company_code, writer - ) VALUES ( - gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, + parent_process_id, company_code, writer${hasBatchCol ? ", batch_id" : ""}${reworkCols}`; + const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'in_progress', $10, '0', '0', '0', 'draft', $11, NOW()::text, NOW()::text, - $12, $13, $11 - ) RETURNING id, input_qty, status, process_name, result_status, accepted_by`, - [ - row.wo_id, row.seq_no, row.process_code, row.process_name, - row.is_required, row.is_fixed_order, row.standard_time, - row.equipment_code, row.routing_detail_id, - String(qty), userId, masterId, companyCode, - ] + $12, $13, $11${hasBatchCol ? ", $14" : ""}${reworkVals}`; + const insertParams = [ + row.wo_id, row.seq_no, row.process_code, row.process_name, + row.is_required, row.is_fixed_order, row.standard_time, + row.equipment_code, row.routing_detail_id, + String(qty), userId, masterId, companyCode, + ...(hasBatchCol ? [batchId] : []), + ...reworkParams, + ]; + + const result = await client.query( + `INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals}) + RETURNING id, input_qty, status, process_name, result_status, accepted_by`, + insertParams ); - // 분할 행에 체크리스트 복사 (마스터의 routing_detail_id 또는 마스터의 기존 체크리스트에서) + // 분할 행에 체크리스트 복사 const splitId = result.rows[0].id; const checklistCount = await copyChecklistToSplit( - pool, masterId, splitId, row.routing_detail_id, companyCode, userId + client, masterId, splitId, row.routing_detail_id, companyCode, userId ); - // 마스터는 항상 acceptable 유지 (completed 전환은 saveResult에서 처리) - const newTotalInput = currentTotalInput + qty; + // 마스터 행의 input_qty를 분할 합계로 갱신 (리워크 접수 시에는 마스터 input_qty 변경 안 함) + let newTotalInput = currentTotalInput + qty; + if (!isRework) { + await client.query( + `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [masterId, companyCode, String(newTotalInput)] + ); + } else { + newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지 + // 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경 + // (다른 리워크 카드에 영향 없도록 id 정확히 지정) + const reworkAlreadyAccepted = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + const totalReworkAccepted = (parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty; + if (totalReworkAccepted >= reworkInputQty) { + await client.query( + `UPDATE work_order_process SET status = 'completed', updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } + } + + await client.query("COMMIT"); logger.info("[pop/production] accept-process 분할 접수 완료", { companyCode, userId, masterId, @@ -1481,17 +2012,26 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => checklistCount, }); + const acceptData = result.rows[0] || {}; + if (splitReworkSourceId) { + acceptData.rework_source_id = splitReworkSourceId; + acceptData.is_rework = splitIsRework; + } + return res.json({ success: true, - data: result.rows[0], + data: acceptData, message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, }); } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] accept-process 오류:", error); return res.status(500).json({ success: false, message: error.message || "접수 중 오류가 발생했습니다.", }); + } finally { + client.release(); } }; @@ -1519,7 +2059,8 @@ export const cancelAccept = async ( const current = await pool.query( `SELECT id, status, input_qty, total_production_qty, result_status, - parent_process_id, wo_id, seq_no, process_name + parent_process_id, wo_id, seq_no, process_name, + target_warehouse_id, target_location_code, good_qty, concession_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] @@ -1558,32 +2099,87 @@ export const cancelAccept = async ( } let cancelledQty = unproducedQty; + const client = await pool.connect(); - if (totalProduced === 0) { - // 실적이 없으면 분할 행 완전 삭제 - await pool.query( - `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode] + try { + await client.query("BEGIN"); + + if (totalProduced === 0) { + // 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제 + await client.query( + `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + await client.query( + `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } else { + // 실적이 있으면 input_qty를 실적 수량으로 축소 + completed + await client.query( + `UPDATE work_order_process + SET input_qty = $3, status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $4, + updated_date = NOW(), writer = $4 + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode, String(totalProduced), userId] + ); + } + + // 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감 + if (proc.target_warehouse_id) { + const inboundQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); + if (inboundQty > 0) { + // work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode] + ); + if (wiResult.rowCount > 0) { + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [wiResult.rows[0].item_id, companyCode] + ); + if (itemResult.rowCount > 0) { + const itemCode = itemResult.rows[0].item_number; + const locCode = proc.target_location_code || proc.target_warehouse_id; + await client.query( + `UPDATE inventory_stock + SET current_qty = GREATEST((COALESCE(current_qty::numeric, 0) - $4::numeric), 0)::text, + updated_date = NOW(), writer = $5 + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 AND location_code = $6`, + [companyCode, itemCode, proc.target_warehouse_id, String(inboundQty), userId, locCode] + ); + } + } + } + } + + // 마스터 행의 input_qty를 분할 합계로 재계산 + const remainingSplits = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2`, + [proc.parent_process_id, companyCode] ); - } else { - // 실적이 있으면 input_qty를 실적 수량으로 축소 + 접수분 전량 생산이므로 completed - await pool.query( + const newMasterInput = parseInt(remainingSplits.rows[0].total_input, 10) || 0; + + // 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지 + await client.query( `UPDATE work_order_process - SET input_qty = $3, status = 'completed', result_status = 'confirmed', - completed_at = NOW()::text, completed_by = $4, - updated_date = NOW(), writer = $4 - WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode, String(totalProduced), userId] + SET status = 'acceptable', input_qty = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [proc.parent_process_id, companyCode, String(newMasterInput)] ); - } - // 원본(마스터) 행을 다시 acceptable로 복원 (잔여 접수 가능하도록) - await pool.query( - `UPDATE work_order_process - SET status = 'acceptable', updated_date = NOW() - WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, - [proc.parent_process_id, companyCode] - ); + await client.query("COMMIT"); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } logger.info("[pop/production] cancel-accept 완료 (분할 행)", { companyCode, userId, work_order_process_id, @@ -1608,354 +2204,850 @@ export const cancelAccept = async ( } }; -// ======================================== -// POP 전용 함수 (PC 코드와 분리) -// ======================================== +/** + * 창고 목록 조회 (POP 생산용) + */ +export const getWarehouses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const result = await pool.query( + `SELECT id, warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 AND COALESCE(status, '') != '삭제' + ORDER BY warehouse_name`, + [companyCode] + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 목록 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; /** - * 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성 - * syncWorkInstructions에서 사용한다. + * 특정 창고의 위치(로케이션) 목록 조회 + * warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭 */ -async function generateWorkProcessesForInstruction( - client: { query: (text: string, values?: any[]) => Promise }, - workInstructionId: string, - routingVersionId: string, - planQty: string | null, - companyCode: string, - userId: string -): Promise<{ - processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>; - total_checklists: number; -} | null> { - const existCheck = await client.query( - `SELECT COUNT(*) as cnt FROM work_order_process - WHERE wo_id = $1 AND company_code = $2`, - [workInstructionId, companyCode] - ); - if (parseInt(existCheck.rows[0].cnt, 10) > 0) { - return null; - } +export const getWarehouseLocations = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { warehouseId } = req.params; + if (!warehouseId) { + return res.status(400).json({ success: false, message: "warehouseId는 필수입니다." }); + } - const routingDetails = await client.query( - `SELECT rd.id, rd.seq_no, rd.process_code, - COALESCE(pm.process_name, rd.process_code) as process_name, - rd.is_required, rd.is_fixed_order, rd.standard_time - FROM item_routing_detail rd - LEFT JOIN process_mng pm ON pm.process_code = rd.process_code - AND pm.company_code = rd.company_code - WHERE rd.routing_version_id = $1 AND rd.company_code = $2 - ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, - [routingVersionId, companyCode] - ); - - if (routingDetails.rows.length === 0) { - return null; - } - - const processes: Array<{ - id: string; seq_no: string; process_name: string; checklist_count: number; - }> = []; - let totalChecklists = 0; - - for (const rd of routingDetails.rows) { - const wopResult = await client.query( - `INSERT INTO work_order_process ( - id, company_code, wo_id, seq_no, process_code, process_name, - is_required, is_fixed_order, standard_time, plan_qty, - status, routing_detail_id, writer - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id`, - [ - companyCode, workInstructionId, rd.seq_no, rd.process_code, rd.process_name, - rd.is_required, rd.is_fixed_order, rd.standard_time, planQty || null, - parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting", rd.id, userId, - ] + // warehouse_info.id → warehouse_code 변환 + const whInfo = await pool.query( + `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, + [warehouseId, companyCode] ); - const wopId = wopResult.rows[0].id; + if (whInfo.rowCount === 0) { + return res.json({ success: true, data: [] }); + } + const warehouseCode = whInfo.rows[0].warehouse_code; - const checklistCount = await copyChecklistToSplit( - client, wopId, wopId, rd.id, companyCode, userId + const result = await pool.query( + `SELECT id, location_code, location_name + FROM warehouse_location + WHERE warehouse_code = $1 AND company_code = $2 + ORDER BY location_name`, + [warehouseCode, companyCode] ); - totalChecklists += checklistCount; + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] 창고 위치 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; - processes.push({ - id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount, +/** + * 마지막 공정 여부 확인 + * 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막 + */ +export const isLastProcess = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.json({ success: true, data: { isLast: false } }); + } + + // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) + const process = await pool.query( + `SELECT wo_id, seq_no, parent_process_id + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + if (process.rowCount === 0) { + return res.json({ success: true, data: { isLast: false } }); + } + + const { wo_id, seq_no, parent_process_id } = process.rows[0]; + + // 분할 행이면 마스터의 seq_no 기준으로 판단 + let effectiveSeqNo = seq_no; + if (parent_process_id) { + const master = await pool.query( + `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode] + ); + if (master.rowCount > 0) { + effectiveSeqNo = master.rows[0].seq_no; + } + } + + const next = await pool.query( + `SELECT id FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND CAST(seq_no AS int) > CAST($3 AS int) + AND parent_process_id IS NULL + LIMIT 1`, + [wo_id, companyCode, effectiveSeqNo] + ); + + // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) + const warehouseInfo = await pool.query( + `SELECT target_warehouse_id, target_location_code + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + + return res.json({ + success: true, + data: { + isLast: next.rowCount === 0, + woId: wo_id, + seqNo: effectiveSeqNo, + targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, + targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, + }, }); + } catch (error: any) { + logger.error("[pop/production] 마지막 공정 확인 오류:", error); + return res.status(500).json({ success: false, message: error.message }); } - - return { processes, total_checklists: totalChecklists }; -} +}; /** - * POP: 미동기화 작업지시 일괄 동기화 + * 공정의 목표 창고/위치 업데이트 + * 마지막 공정 완료 전 또는 완료 후 창고를 지정한다. + * 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다. */ -export const syncWorkInstructions = async ( +export const updateTargetWarehouse = async ( req: AuthenticatedRequest, res: Response ) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const unsyncedResult = await pool.query( - `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty - FROM work_instruction wi - WHERE wi.company_code = $1 - AND wi.routing IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM work_order_process wop - WHERE wop.wo_id = wi.id AND wop.company_code = $1 - )`, - [companyCode] - ); - const unsynced = unsyncedResult.rows; - if (unsynced.length === 0) { - return res.json({ success: true, data: { synced: 0, skipped: 0, errors: 0, details: [] } }); - } - let synced = 0, skipped = 0, errors = 0; - const details: Array<{ - work_instruction_id: string; work_instruction_no: string; - status: "synced" | "skipped" | "error"; process_count?: number; error?: string; - }> = []; - for (const wi of unsynced) { - const client = await pool.connect(); - try { - await client.query("BEGIN"); - const result = await generateWorkProcessesForInstruction( - client, wi.id, wi.routing, wi.qty || null, companyCode, userId - ); - if (!result) { - await client.query("ROLLBACK"); - skipped++; - details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, status: "skipped" }); - continue; - } - await client.query("COMMIT"); - synced++; - details.push({ - work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, - status: "synced", process_count: result.processes.length, - }); - } catch (err: any) { - await client.query("ROLLBACK"); - errors++; - details.push({ - work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, - status: "error", error: err.message || "알 수 없는 오류", - }); - } finally { - client.release(); - } - } - return res.json({ success: true, data: { synced, skipped, errors, details } }); - } catch (error: any) { - logger.error("[pop/production] sync-work-instructions 오류:", error); - return res.status(500).json({ success: false, message: error.message || "동기화 오류" }); - } -}; - -export const getWarehouses = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const result = await pool.query( - `SELECT id, warehouse_code, warehouse_name, warehouse_type - FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, - [companyCode] - ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - return res.status(500).json({ success: false, message: error.message }); - } -}; - -export const getWarehouseLocations = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { warehouseId } = req.params; - if (!warehouseId) return res.status(400).json({ success: false, message: "warehouseId 필수" }); - const whInfo = await pool.query( - `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, - [warehouseId, companyCode] - ); - if (whInfo.rowCount === 0) return res.json({ success: true, data: [] }); - const result = await pool.query( - `SELECT id, location_code, location_name FROM warehouse_location - WHERE warehouse_code = $1 AND company_code = $2 ORDER BY location_name`, - [whInfo.rows[0].warehouse_code, companyCode] - ); - return res.json({ success: true, data: result.rows }); - } catch (error: any) { - return res.status(500).json({ success: false, message: error.message }); - } -}; - -export const isLastProcess = async (req: AuthenticatedRequest, res: Response) => { - const pool = getPool(); - try { - const companyCode = req.user!.companyCode; - const { processId } = req.params; - if (!processId) return res.json({ success: true, data: { isLast: false } }); - const process = await pool.query( - `SELECT wo_id, seq_no, parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, - [processId, companyCode] - ); - if (process.rowCount === 0) return res.json({ success: true, data: { isLast: false } }); - const { wo_id, seq_no, parent_process_id } = process.rows[0]; - let effectiveSeqNo = seq_no; - if (parent_process_id) { - const master = await pool.query( - `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, - [parent_process_id, companyCode] - ); - if (master.rowCount > 0) effectiveSeqNo = master.rows[0].seq_no; - } - const next = await pool.query( - `SELECT id FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND parent_process_id IS NULL LIMIT 1`, - [wo_id, companyCode, effectiveSeqNo] - ); - const warehouseInfo = await pool.query( - `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, - [processId, companyCode] - ); - return res.json({ - success: true, - data: { - isLast: next.rowCount === 0, woId: wo_id, seqNo: effectiveSeqNo, - targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, - targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, - }, - }); - } catch (error: any) { - return res.status(500).json({ success: false, message: error.message }); - } -}; - -export const updateTargetWarehouse = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, target_warehouse_id, target_location_code } = req.body; - if (!work_order_process_id || !target_warehouse_id) - return res.status(400).json({ success: false, message: "work_order_process_id와 target_warehouse_id 필수" }); + + if (!work_order_process_id || !target_warehouse_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 target_warehouse_id는 필수입니다.", + }); + } + + // 분할 행이면 마스터 행도 함께 업데이트 const procInfo = await pool.query( `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); + const idsToUpdate = [work_order_process_id]; - if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) idsToUpdate.push(procInfo.rows[0].parent_process_id); + if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { + idsToUpdate.push(procInfo.rows[0].parent_process_id); + } + for (const id of idsToUpdate) { await pool.query( - `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() WHERE id = $1 AND company_code = $2`, [id, companyCode, target_warehouse_id, target_location_code || null, userId] ); } + + logger.info("[pop/production] 목표 창고 업데이트", { + companyCode, userId, work_order_process_id, + target_warehouse_id, target_location_code, + updatedIds: idsToUpdate, + }); + return res.json({ success: true, data: { target_warehouse_id, target_location_code } }); } catch (error: any) { + logger.error("[pop/production] 목표 창고 업데이트 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; -export const inventoryInbound = async (req: AuthenticatedRequest, res: Response) => { +/** + * 독립 재고 입고 API + * 창고 저장 + inventory_stock UPSERT를 한 번에 수행한다. + * 실적(save-result) 완료 후 나중에 창고를 선택해도 재고가 들어가도록 분리. + * 이중 입고 방지: target_warehouse_id가 이미 설정된 경우 "이미 입고됨" 반환. + */ +export const inventoryInbound = async ( + req: AuthenticatedRequest, + res: Response +) => { const pool = getPool(); const client = await pool.connect(); + try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, warehouse_code, location_code } = req.body; - if (!work_order_process_id || !warehouse_code) - return res.status(400).json({ success: false, message: "work_order_process_id와 warehouse_code 필수" }); + + if (!work_order_process_id || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 warehouse_code는 필수입니다.", + }); + } + await client.query("BEGIN"); + + // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 const procResult = await client.query( - `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework + FROM work_order_process + WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); - if (procResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "공정 없음" }); } + + if (procResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "해당 공정을 찾을 수 없습니다.", + }); + } + const proc = procResult.rows[0]; - if (proc.target_warehouse_id) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 입고 완료" }); } + + // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 + if (proc.target_warehouse_id) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 재고 입고가 완료된 공정입니다.", + data: { existing_warehouse: proc.target_warehouse_id }, + }); + } + const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); - if (goodQty <= 0) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: "양품 0" }); } - const wiResult = await client.query(`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode]); - if (wiResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "작업지시 없음" }); } - const itemResult = await client.query(`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [wiResult.rows[0].item_id, companyCode]); - if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); } - const itemCode = itemResult.rows[0].item_number; - const locCode = location_code || warehouse_code; - await client.query( - `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) - ON CONFLICT (company_code, item_code, warehouse_code, location_code) - DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, - last_in_date = NOW(), updated_date = NOW(), writer = $6`, - [companyCode, itemCode, warehouse_code, locCode, String(goodQty), userId] + + if (goodQty <= 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: "양품 수량이 0이므로 재고 입고할 수 없습니다.", + }); + } + + // 2. work_instruction에서 item_id 조회 + const wiResult = await client.query( + `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, + [proc.wo_id, companyCode] ); + + if (wiResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "작업지시를 찾을 수 없습니다.", + }); + } + + const itemId = wiResult.rows[0].item_id; + + // 3. item_info에서 item_number 조회 + const itemResult = await client.query( + `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, + [itemId, companyCode] + ); + + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } + + const itemCode = itemResult.rows[0].item_number; + const effectiveLocationCode = location_code || null; + + // 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, goodQty, userId); + + // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) const idsToUpdate = [work_order_process_id]; - if (proc.parent_process_id) idsToUpdate.push(proc.parent_process_id); + if (proc.parent_process_id) { + idsToUpdate.push(proc.parent_process_id); + } + for (const id of idsToUpdate) { await client.query( - `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() + WHERE id = $1 AND company_code = $2`, [id, companyCode, warehouse_code, location_code || null, userId] ); } + + // 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존) + if (proc.is_rework === "Y") { + await client.query( + `UPDATE work_order_process SET is_rework = NULL, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } + await client.query("COMMIT"); - return res.json({ success: true, message: "재고 입고 완료", data: { item_code: itemCode, warehouse_code, location_code: locCode, qty: goodQty } }); + + logger.info("[pop/production] 독립 재고 입고 완료", { + companyCode, userId, work_order_process_id, + itemCode, warehouse_code, location_code: effectiveLocationCode, + qty: goodQty, + reworkCleared: proc.is_rework === "Y", + }); + + return res.json({ + success: true, + message: "재고 입고가 완료되었습니다.", + data: { + item_code: itemCode, + warehouse_code, + location_code: effectiveLocationCode, + qty: goodQty, + }, + }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 독립 재고 입고 오류:", error); return res.status(500).json({ success: false, message: error.message }); - } finally { client.release(); } + } finally { + client.release(); + } }; -export const quickInventoryInbound = async (req: AuthenticatedRequest, res: Response) => { +/** + * 간이 재고 입고 (공정 접수 없이 바로 입고) + * 품목 + 수량 + 창고만으로 inventory_stock UPSERT + inbound_mng 이력 기록 + */ +export const quickInventoryInbound = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { item_id, qty, warehouse_code, location_code, remark } = req.body; + + // 필수 파라미터 검증 + if (!item_id || !qty || !warehouse_code) { + return res.status(400).json({ + success: false, + message: "item_id, qty, warehouse_code는 필수입니다.", + }); + } + + const parsedQty = parseInt(String(qty), 10); + if (isNaN(parsedQty) || parsedQty <= 0) { + return res.status(400).json({ + success: false, + message: "수량은 1 이상의 정수여야 합니다.", + }); + } + + await client.query("BEGIN"); + + // 1. item_info에서 item_number, item_name 조회 + const itemResult = await client.query( + `SELECT item_number, item_name, size, material, unit + FROM item_info WHERE id = $1 AND company_code = $2`, + [item_id, companyCode] + ); + + if (itemResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "품목 정보를 찾을 수 없습니다.", + }); + } + + const item = itemResult.rows[0]; + const itemCode = item.item_number; + const effectiveLocationCode = location_code || null; + + // 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) + await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, parsedQty, userId); + + // 3. inbound_mng에 간이입고 이력 기록 + const seqResult = await client.query( + `SELECT COALESCE(MAX( + CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' + THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) + ELSE 0 END + ), 0) + 1 AS next_seq + FROM inbound_mng WHERE company_code = $1`, + [companyCode] + ); + const nextSeq = seqResult.rows[0].next_seq; + const year = new Date().getFullYear(); + const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`; + + await client.query( + `INSERT INTO inbound_mng ( + id, company_code, inbound_number, inbound_type, inbound_date, + item_number, item_name, spec, material, unit, + inbound_qty, warehouse_code, location_code, + inbound_status, memo, remark, + created_date, updated_date, writer, created_by, updated_by + ) VALUES ( + gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE, + $3, $4, $5, $6, $7, + $8, $9, $10, + '완료', $11, $12, + NOW(), NOW(), $13, $13, $13 + )`, + [ + companyCode, inboundNumber, + item.item_number, item.item_name, item.size, item.material, item.unit, + parsedQty, warehouse_code, effectiveLocationCode, + remark || "POP 간이입고", remark || null, + userId, + ] + ); + + await client.query("COMMIT"); + + logger.info("[pop/production] 간이 재고 입고 완료", { + companyCode, userId, item_id, + itemCode, warehouse_code, location_code: effectiveLocationCode, + qty: parsedQty, inboundNumber, + }); + + return res.json({ + success: true, + message: "간이 재고 입고가 완료되었습니다.", + data: { + inbound_number: inboundNumber, + item_code: itemCode, + item_name: item.item_name, + warehouse_code, + location_code: effectiveLocationCode, + qty: parsedQty, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] 간이 재고 입고 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +/** + * 재작업 이력 조회 + * 작업지시(wo_id) 기준으로 모든 재작업 체인을 반환한다. + * 원본 → 재작업1 → 재작업2 → ... 순서로 체인 추적. + */ +export const getReworkHistory = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const woId = req.query.wo_id as string || req.params.woId; + if (!woId) { + return res.status(400).json({ success: false, message: "wo_id는 필수입니다." }); + } + + const result = await pool.query( + `SELECT id, seq_no, process_code, process_name, status, + input_qty, good_qty, defect_qty, concession_qty, + is_rework, rework_source_id, parent_process_id, + accepted_by, accepted_at, started_at, completed_at, + created_date + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND (is_rework = 'Y' OR is_rework = '1' OR defect_qty::int > 0 OR parent_process_id IS NOT NULL) + ORDER BY created_date ASC`, + [woId, companyCode] + ); + + // 체인 구성: rework_source_id를 따라 트리 구조 + const rows = result.rows; + const byId: Record = {}; + for (const r of rows) byId[r.id] = r; + + const chains: Array<{ + source: typeof rows[0]; + reworks: typeof rows; + totalReworkCount: number; + }> = []; + + // 원본 행(불량 발생한 것) 찾기 + const reworkSourceIds = new Set(rows.filter(r => r.rework_source_id).map(r => r.rework_source_id)); + const sources = rows.filter(r => reworkSourceIds.has(r.id) || (parseInt(r.defect_qty || "0", 10) > 0 && r.is_rework !== "Y")); + + for (const src of sources) { + const chain: typeof rows = []; + const visited = new Set(); + // 이 소스에서 시작하는 재작업 체인 추적 + const queue = rows.filter(r => r.rework_source_id === src.id); + while (queue.length > 0) { + const item = queue.shift()!; + if (visited.has(item.id)) continue; + visited.add(item.id); + chain.push(item); + // 이 재작업에서 또 재작업이 나온 것 찾기 + const next = rows.filter(r => r.rework_source_id === item.id); + queue.push(...next); + } + chains.push({ + source: src, + reworks: chain, + totalReworkCount: chain.length, + }); + } + + return res.json({ + success: true, + data: { + wo_id: woId, + total_rework_count: rows.filter(r => r.is_rework === "Y" || r.is_rework === "1").length, + chains, + all_records: rows, + }, + }); + } catch (error: any) { + logger.error("[pop/production] rework-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 공정별 BOM 자재 목록 + 소요량 계산 + * work_order_process_id → item_code → bom + bom_detail 조회 + */ +export const getBomMaterials = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.status(400).json({ success: false, message: "processId 필수" }); + } + + // 1. work_order_process → work_instruction → item_code, plan_qty + const procResult = await pool.query( + `SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty, + wi.item_id, wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [processId, companyCode] + ); + if (procResult.rowCount === 0) { + return res.json({ success: true, data: { materials: [], processQty: 0 } }); + } + const proc = procResult.rows[0]; + const processQty = parseInt(proc.input_qty || proc.plan_qty || proc.instruction_qty || "0", 10); + + // 2. item_info → item_code (item_number) + const itemResult = await pool.query( + `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, + [proc.item_id, companyCode] + ); + if (itemResult.rowCount === 0) { + return res.json({ success: true, data: { materials: [], processQty } }); + } + const itemCode = itemResult.rows[0].item_number; + + // 3. BOM 조회 + const bomResult = await pool.query( + `SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate, + i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info i ON bd.child_item_id = i.id AND i.company_code = b.company_code + WHERE (b.item_code = $1 OR b.item_id = $2) AND b.company_code = $3 + ORDER BY bd.seq_no ASC`, + [itemCode, proc.item_id, companyCode] + ); + + // 4. 소요량 계산 + const bomBase = await pool.query( + `SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`, + [itemCode, proc.item_id, companyCode] + ); + const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; + + // 기존 투입량 조회 (item_code별 합산 — detail_content에 item_code 저장됨) + const inputResult = await pool.query( + `SELECT detail_content as item_code, SUM(CAST(NULLIF(result_value, '') AS numeric)) as total_input + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' + AND result_value IS NOT NULL AND result_value != '' + GROUP BY detail_content`, + [processId, companyCode] + ); + const inputMap = new Map(); + for (const row of inputResult.rows) { + inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0); + } + + const materials = bomResult.rows.map((bd: Record) => { + const bomQty = parseFloat(String(bd.quantity || "0")) || 0; + const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0; + const requiredQty = Math.ceil((processQty / baseQty) * bomQty * (1 + lossRate / 100)); + const childItemCode = String(bd.child_item_code || ""); + return { + id: bd.id, + child_item_id: bd.child_item_id, + child_item_code: childItemCode, + child_item_name: bd.child_item_name || "", + bom_qty: bomQty, + unit: bd.unit || bd.item_unit || "", + process_type: bd.process_type || "", + loss_rate: lossRate, + required_qty: requiredQty, + input_qty: inputMap.get(childItemCode) || 0, + }; + }); + + return res.json({ + success: true, + data: { materials, processQty, baseQty, itemCode, itemName: itemResult.rows[0].item_name }, + }); + } catch (error: any) { + logger.error("[pop/production] bom-materials 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 자재 투입 기록 저장 + * BOM 기준과 다른 수량도 허용 (유동 투입) + */ +export const saveMaterialInput = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const { item_id, qty, warehouse_code, location_code, remark } = req.body; - if (!item_id || !qty || !warehouse_code) - return res.status(400).json({ success: false, message: "item_id, qty, warehouse_code 필수" }); - const parsedQty = parseInt(String(qty), 10); - if (isNaN(parsedQty) || parsedQty <= 0) - return res.status(400).json({ success: false, message: "수량은 1 이상" }); + const { work_order_process_id, inputs } = req.body; + + if (!work_order_process_id || !inputs || !Array.isArray(inputs)) { + return res.status(400).json({ success: false, message: "work_order_process_id, inputs[] 필수" }); + } + await client.query("BEGIN"); - const itemResult = await client.query( - `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, [item_id, companyCode] - ); - if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); } - const item = itemResult.rows[0]; - const locCode = location_code || warehouse_code; - await client.query( - `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) - ON CONFLICT (company_code, item_code, warehouse_code, location_code) - DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, - last_in_date = NOW(), updated_date = NOW(), writer = $6`, - [companyCode, item.item_number, warehouse_code, locCode, String(parsedQty), userId] - ); - const seqResult = await client.query( - `SELECT COALESCE(MAX(CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' - THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) ELSE 0 END), 0) + 1 AS next_seq - FROM inbound_mng WHERE company_code = $1`, [companyCode] - ); - const inboundNumber = `QIB-${new Date().getFullYear()}-${String(seqResult.rows[0].next_seq).padStart(4, "0")}`; - await client.query( - `INSERT INTO inbound_mng (id, company_code, inbound_number, inbound_type, inbound_date, - item_number, item_name, spec, material, unit, inbound_qty, warehouse_code, location_code, - inbound_status, memo, remark, created_date, updated_date, writer, created_by, updated_by - ) VALUES (gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE, - $3, $4, $5, $6, $7, $8, $9, $10, '완료', $11, $12, NOW(), NOW(), $13, $13, $13)`, - [companyCode, inboundNumber, item.item_number, item.item_name, item.size, item.material, item.unit, - parsedQty, warehouse_code, locCode, remark || "POP 간이입고", remark || null, userId] - ); + + const results = []; + for (const input of inputs) { + const { child_item_id, child_item_code, child_item_name, input_qty, unit, bom_detail_id, required_qty, warehouse_code, location_code } = input; + // item_code/qty 등 대안 필드명도 허용 + const effectiveItemId = child_item_id || input.item_id || input.item_code || child_item_code; + const effectiveItemCode = child_item_code || input.item_code || child_item_id; + const effectiveItemName = child_item_name || input.item_name || ""; + const effectiveQty = input_qty || input.qty || input.quantity; + + if (!effectiveItemId || !effectiveQty) continue; + + const parsedQty = parseFloat(String(effectiveQty)); + if (isNaN(parsedQty) || parsedQty <= 0) continue; + + // 투입 기록 INSERT (process_work_result에 material_input 타입으로) + const insertResult = await client.query( + `INSERT INTO process_work_result ( + id, company_code, work_order_process_id, + detail_type, detail_content, item_title, + result_value, unit, is_passed, status, + remark, recorded_by, recorded_at, writer + ) VALUES ( + gen_random_uuid()::text, $1, $2, + 'material_input', $3, $4, + $5, $6, 'Y', 'completed', + $7, $8, NOW()::text, $8 + ) RETURNING id`, + [ + companyCode, work_order_process_id, + effectiveItemCode || effectiveItemId, effectiveItemName, + String(parsedQty), unit || "", + JSON.stringify({ bom_detail_id, required_qty: required_qty || 0, warehouse_code, location_code }), + userId, + ] + ); + + // 재고 차감 (warehouse_code가 있을 때만) + if (warehouse_code) { + const locCode = location_code || warehouse_code; + await client.query( + `UPDATE inventory_stock + SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text, + updated_date = NOW(), writer = $5 + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 AND location_code = $6`, + [companyCode, effectiveItemCode, warehouse_code, String(parsedQty), userId, locCode] + ); + } + + results.push({ id: insertResult.rows[0].id, child_item_code: effectiveItemCode, input_qty: parsedQty }); + } + await client.query("COMMIT"); - return res.json({ success: true, message: "간이 입고 완료", - data: { inbound_number: inboundNumber, item_code: item.item_number, item_name: item.item_name, warehouse_code, location_code: locCode, qty: parsedQty } }); + + return res.json({ + success: true, + message: `${results.length}건 자재 투입 완료`, + data: results, + }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/production] material-input 오류:", error); return res.status(500).json({ success: false, message: error.message }); - } finally { client.release(); } + } finally { + client.release(); + } +}; + +/** + * 자재 투입 현황 조회 + */ +export const getMaterialInputs = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res.status(400).json({ success: false, message: "processId 필수" }); + } + + const result = await pool.query( + `SELECT id, detail_content as item_code, item_title as item_name, + result_value as input_qty, unit, remark, recorded_by, recorded_at + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 + AND detail_type = 'material_input' + ORDER BY recorded_at ASC`, + [processId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] material-inputs 조회 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 체크리스트 조회 (judgment_criteria 조인 포함) + * + * process_work_result를 조회하면서 inspection_standard.judgment_criteria를 + * LEFT JOIN으로 같이 반환한다. + * + * UI는 프론트의 resolveInputType()에서 + * 1순위: judgment_criteria (CAT_JC_01~04) + * 2순위: detail_type 폴백 + * 으로 입력 UI를 결정한다. + */ +export const getChecklistItems = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res + .status(400) + .json({ success: false, message: "processId 필수" }); + } + + const result = await pool.query( + `SELECT + pwr.id, + pwr.company_code, + pwr.work_order_process_id, + pwr.source_work_item_id, + pwr.source_detail_id, + pwr.work_phase, + pwr.item_title, + pwr.item_sort_order, + pwr.detail_content, + pwr.detail_type, + pwr.detail_sort_order, + pwr.is_required, + pwr.inspection_code, + pwr.inspection_method, + pwr.unit, + pwr.lower_limit, + pwr.upper_limit, + pwr.input_type, + pwr.lookup_target, + pwr.display_fields, + pwr.duration_minutes, + pwr.status, + pwr.result_value, + pwr.is_passed, + pwr.remark, + pwr.recorded_by, + pwr.recorded_at, + pwr.started_at, + pwr.group_started_at, + pwr.group_paused_at, + pwr.group_total_paused_time, + pwr.group_completed_at, + ist.judgment_criteria + FROM process_work_result pwr + LEFT JOIN inspection_standard ist + ON pwr.inspection_code = ist.inspection_code + AND pwr.company_code = ist.company_code + WHERE pwr.work_order_process_id = $1 + AND pwr.company_code = $2 + ORDER BY + COALESCE(NULLIF(pwr.item_sort_order, '')::int, 0), + COALESCE(NULLIF(pwr.detail_sort_order, '')::int, 0)`, + [processId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] checklist-items 조회 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message }); + } }; diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index fbf40176..9084160f 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -332,8 +332,24 @@ export async function create(req: AuthenticatedRequest, res: Response) { [purchaseNo, companyCode] ); const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고'; + // 발주 헤더의 received_qty도 디테일 합계로 동기화 await client.query( - `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() + `UPDATE purchase_order_mng SET + status = $1, + received_qty = ( + SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text) + FROM purchase_detail + WHERE purchase_no = $2 AND company_code = $3 + ), + remain_qty = ( + SELECT CAST(COALESCE(SUM( + COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + ), 0) AS text) + FROM purchase_detail + WHERE purchase_no = $2 AND company_code = $3 + ), + updated_date = NOW() WHERE purchase_no = $2 AND company_code = $3`, [newStatus, purchaseNo, companyCode] ); @@ -474,6 +490,7 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) try { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { id } = req.params; await client.query("BEGIN"); @@ -880,8 +897,22 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { export async function generateNumber(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const pool = getPool(); + const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string); + // 1순위: POP 화면설정에서 선택한 채번규칙 사용 + if (ruleId && ruleId !== "__none__") { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode); + return res.json({ success: true, data: newNumber }); + } catch (e: any) { + logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message }); + // 폴백 + } + } + + // 2순위: 기본 하드코딩 채번 (RCV-YYYY-XXXX) + const pool = getPool(); const today = new Date(); const yyyy = today.getFullYear(); const prefix = `RCV-${yyyy}-`; diff --git a/backend-node/src/controllers/smartFactoryLogController.ts b/backend-node/src/controllers/smartFactoryLogController.ts index ed4b353c..baff84e4 100644 --- a/backend-node/src/controllers/smartFactoryLogController.ts +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -5,6 +5,12 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../middleware/permissionMiddleware"; import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; +import { encryptionService } from "../services/encryptionService"; +import { + sendSmartFactoryLog, + getTodayPlanStatus, + planDailySends, +} from "../utils/smartFactoryLog"; /** * GET /api/admin/smart-factory-log @@ -216,3 +222,355 @@ export const getSmartFactoryLogStats = async ( }); } }; + +// ─── 스케줄 관리 API ─── + +/** + * GET /api/admin/smart-factory-log/schedules + */ +export const getSchedules = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const schedules = await query( + `SELECT s.*, cm.company_name + FROM smart_factory_schedule s + LEFT JOIN company_mng cm ON cm.company_code = s.company_code + ORDER BY s.company_code` + ); + res.json({ success: true, data: schedules }); + } catch (error) { + logger.error("스케줄 조회 실패:", error); + res.status(500).json({ success: false, message: "스케줄 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/schedules + */ +export const upsertSchedule = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays, dailyCount } = req.body; + + if (!companyCode) { + res.status(400).json({ success: false, message: "회사코드는 필수입니다." }); + return; + } + + await query( + `INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, daily_count, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (company_code) DO UPDATE SET + is_active = $2, time_start = $3, time_end = $4, + exclude_weekend = $5, exclude_holidays = $6, daily_count = $7, updated_at = NOW()`, + [ + companyCode, + isActive ?? false, + timeStart || "08:30", + timeEnd || "17:30", + excludeWeekend ?? true, + excludeHolidays ?? true, + Math.max(1, Math.min(3, dailyCount || 1)), + ] + ); + + // 스케줄 변경 후 오늘 계획 즉시 재생성 (이미 전송된 사용자는 자동 제외됨) + await planDailySends(); + res.json({ success: true, message: "스케줄이 저장되었습니다." }); + } catch (error) { + logger.error("스케줄 저장 실패:", error); + res.status(500).json({ success: false, message: "스케줄 저장 실패" }); + } +}; + +/** + * DELETE /api/admin/smart-factory-log/schedules/:companyCode + */ +export const deleteSchedule = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + await query("DELETE FROM smart_factory_schedule WHERE company_code = $1", [companyCode]); + res.json({ success: true, message: "스케줄이 삭제되었습니다." }); + } catch (error) { + logger.error("스케줄 삭제 실패:", error); + res.status(500).json({ success: false, message: "스케줄 삭제 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/schedules/:companyCode/run-now + */ +/** + * GET /api/admin/smart-factory-log/schedules/today-plan + */ +export const getTodayPlanHandler = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const plan = getTodayPlanStatus(); + res.json({ success: true, data: plan }); + } catch (error) { + logger.error("오늘 계획 조회 실패:", error); + res.status(500).json({ success: false, message: "오늘 계획 조회 실패" }); + } +}; + +// ─── 공휴일 관리 API ─── + +/** + * GET /api/admin/smart-factory-log/holidays + */ +export const getHolidays = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const holidays = await query( + "SELECT id, holiday_date, holiday_name, created_at FROM smart_factory_holidays ORDER BY holiday_date" + ); + res.json({ success: true, data: holidays }); + } catch (error) { + logger.error("공휴일 조회 실패:", error); + res.status(500).json({ success: false, message: "공휴일 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/holidays + */ +export const addHoliday = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { holidayDate, holidayName } = req.body; + + if (!holidayDate || !holidayName) { + res.status(400).json({ success: false, message: "날짜와 이름은 필수입니다." }); + return; + } + + await query( + "INSERT INTO smart_factory_holidays (holiday_date, holiday_name) VALUES ($1, $2) ON CONFLICT (holiday_date) DO UPDATE SET holiday_name = $2", + [holidayDate, holidayName] + ); + + res.json({ success: true, message: "공휴일이 추가되었습니다." }); + } catch (error) { + logger.error("공휴일 추가 실패:", error); + res.status(500).json({ success: false, message: "공휴일 추가 실패" }); + } +}; + +/** + * DELETE /api/admin/smart-factory-log/holidays/:id + */ +export const deleteHoliday = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + await query("DELETE FROM smart_factory_holidays WHERE id = $1", [id]); + res.json({ success: true, message: "공휴일이 삭제되었습니다." }); + } catch (error) { + logger.error("공휴일 삭제 실패:", error); + res.status(500).json({ success: false, message: "공휴일 삭제 실패" }); + } +}; + +// ─── API 키 관리 ─── + +/** + * GET /api/admin/smart-factory-log/api-keys + * 전체 회사 목록 + API 키 상태 (DB키 여부, 환경변수 여부) + */ +export const getApiKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companies = await query( + `SELECT cm.company_code, cm.company_name, ak.api_key + FROM company_mng cm + LEFT JOIN smart_factory_api_keys ak ON ak.company_code = cm.company_code + WHERE cm.company_code != '*' + ORDER BY cm.company_code` + ); + + const result = companies.map((c: any) => { + let dbKeyDecrypted: string | null = null; + if (c.api_key) { + try { + dbKeyDecrypted = encryptionService.decrypt(c.api_key); + } catch { + dbKeyDecrypted = "(복호화 실패)"; + } + } + return { + companyCode: c.company_code, + companyName: c.company_name, + hasDbKey: !!c.api_key, + dbKey: dbKeyDecrypted, + hasEnvKey: !!process.env[`SMART_FACTORY_API_KEY_${c.company_code}`], + }; + }); + + res.json({ success: true, data: result }); + } catch (error) { + logger.error("API 키 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "API 키 목록 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/api-keys + * API 키 저장 (암호화) + */ +export const saveApiKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, apiKey } = req.body; + + if (!companyCode || !apiKey) { + res.status(400).json({ success: false, message: "회사코드와 API 키는 필수입니다." }); + return; + } + + const encrypted = encryptionService.encrypt(apiKey); + + await query( + `INSERT INTO smart_factory_api_keys (company_code, api_key, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (company_code) DO UPDATE SET api_key = $2, updated_at = NOW()`, + [companyCode, encrypted] + ); + + res.json({ success: true, message: "API 키가 저장되었습니다." }); + } catch (error) { + logger.error("API 키 저장 실패:", error); + res.status(500).json({ success: false, message: "API 키 저장 실패" }); + } +}; + +/** + * DELETE /api/admin/smart-factory-log/api-keys/:companyCode + * API 키 삭제 (환경변수 폴백으로 전환) + */ +export const deleteApiKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + + await query( + "DELETE FROM smart_factory_api_keys WHERE company_code = $1", + [companyCode] + ); + + res.json({ success: true, message: "API 키가 삭제되었습니다." }); + } catch (error) { + logger.error("API 키 삭제 실패:", error); + res.status(500).json({ success: false, message: "API 키 삭제 실패" }); + } +}; + +// ─── 즉시 전송 ─── + +/** + * GET /api/admin/smart-factory-log/users/:companyCode + * 회사별 사용자 목록 조회 (즉시 전송 대상 선택용) + */ +export const getCompanyUsers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + + const users = await query( + `SELECT user_id, user_name, dept_name + FROM user_info + WHERE company_code = $1 AND (status = 'active' OR status IS NULL) + ORDER BY user_name`, + [companyCode] + ); + + res.json({ success: true, data: users }); + } catch (error) { + logger.error("사용자 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "사용자 목록 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/send-now + * 선택한 사용자 즉시 전송 + * body: { companyCode, userIds: string[], timeStart?, timeEnd? } + */ +export const sendNow = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userIds } = req.body; + + logger.info(`=== 즉시 전송 API 호출 === companyCode=${companyCode}, userIds=${JSON.stringify(userIds)}`); + + if (!companyCode || !userIds || userIds.length === 0) { + res.status(400).json({ success: false, message: "회사코드와 사용자를 선택해주세요." }); + return; + } + + // 사용자 정보 조회 + const users = await query<{ user_id: string; user_name: string }>( + `SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND user_id = ANY($2)`, + [companyCode, userIds] + ); + + logger.info(`즉시 전송 대상: ${users.length}명 (조회된 사용자: ${users.map(u => u.user_id).join(", ")})`); + + // 현재 시간으로 즉시 전송 + let success = 0; + let fail = 0; + const remoteAddr = req.ip || "127.0.0.1"; + + for (const user of users) { + try { + logger.info(`즉시 전송 시작: ${user.user_id}`); + await sendSmartFactoryLog({ + userId: user.user_id, + userName: user.user_name, + remoteAddr, + useType: "접속", + companyCode, + }); + success++; + logger.info(`즉시 전송 성공: ${user.user_id}`); + } catch (e) { + fail++; + logger.error(`즉시 전송 실패: ${user.user_id}`, e); + } + } + + res.json({ + success: true, + data: { total: users.length, success, fail }, + message: `${success}명 전송 완료${fail > 0 ? `, ${fail}명 실패` : ""}`, + }); + } catch (error) { + logger.error("즉시 전송 실패:", error); + res.status(500).json({ success: false, message: "즉시 전송 실패" }); + } +}; diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index f915d962..85f818a5 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -173,6 +173,35 @@ export async function runMessengerMigration() { /** * 스마트공장 활용 로그 테이블 마이그레이션 */ +/** + * 스마트공장 스케줄 + 공휴일 테이블 마이그레이션 + */ +export async function runSmartFactoryScheduleMigration() { + try { + console.log("🔄 스마트공장 스케줄 테이블 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/201_create_smart_factory_schedule.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ 스마트공장 스케줄 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 스마트공장 스케줄 테이블 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 테이블이 이미 존재합니다."); + } + } +} + export async function runSmartFactoryLogMigration() { try { console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index d02aac7e..cd31c8a4 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -35,6 +35,18 @@ import { import { getSmartFactoryLogs, getSmartFactoryLogStats, + getSchedules, + upsertSchedule, + deleteSchedule, + getTodayPlanHandler, + getHolidays, + addHoliday, + deleteHoliday, + getApiKeys, + saveApiKey, + deleteApiKey, + getCompanyUsers, + sendNow, } from "../controllers/smartFactoryLogController"; import { authenticateToken } from "../middleware/authMiddleware"; import { requireSuperAdmin } from "../middleware/permissionMiddleware"; @@ -92,4 +104,25 @@ router.get("/tables/:tableName/schema", getTableSchema); router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs); router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats); +// 스마트공장 스케줄 관리 (최고관리자 전용) +router.get("/smart-factory-log/schedules", requireSuperAdmin, getSchedules); +router.get("/smart-factory-log/schedules/today-plan", requireSuperAdmin, getTodayPlanHandler); +router.post("/smart-factory-log/schedules", requireSuperAdmin, upsertSchedule); +router.delete("/smart-factory-log/schedules/:companyCode", requireSuperAdmin, deleteSchedule); + + +// 스마트공장 공휴일 관리 (최고관리자 전용) +router.get("/smart-factory-log/holidays", requireSuperAdmin, getHolidays); +router.post("/smart-factory-log/holidays", requireSuperAdmin, addHoliday); +router.delete("/smart-factory-log/holidays/:id", requireSuperAdmin, deleteHoliday); + +// 스마트공장 즉시 전송 (최고관리자 전용) +router.get("/smart-factory-log/users/:companyCode", requireSuperAdmin, getCompanyUsers); +router.post("/smart-factory-log/send-now", requireSuperAdmin, sendNow); + +// 스마트공장 API 키 관리 (최고관리자 전용) +router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys); +router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey); +router.delete("/smart-factory-log/api-keys/:companyCode", requireSuperAdmin, deleteApiKey); + export default router; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index 921ddf92..36821e5b 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -18,6 +18,11 @@ import { updateTargetWarehouse, inventoryInbound, quickInventoryInbound, + getReworkHistory, + getBomMaterials, + saveMaterialInput, + getMaterialInputs, + getChecklistItems, } from "../controllers/popProductionController"; const router = Router(); @@ -41,5 +46,10 @@ router.get("/is-last-process/:processId", isLastProcess); router.post("/update-target-warehouse", updateTargetWarehouse); router.post("/inventory-inbound", inventoryInbound); router.post("/quick-inventory-inbound", quickInventoryInbound); +router.get("/rework-history/:woId", getReworkHistory); +router.get("/bom-materials/:processId", getBomMaterials); +router.post("/material-input", saveMaterialInput); +router.get("/material-inputs/:processId", getMaterialInputs); +router.get("/checklist-items/:processId", getChecklistItems); export default router; diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 705033cc..b88e7126 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -46,20 +46,22 @@ export async function getOrderSummary( const itemLeadTimeCte = hasLeadTime ? `item_lead_time AS ( - SELECT + SELECT DISTINCT ON (item_number) item_number, id AS item_id, COALESCE(lead_time::int, 0) AS lead_time FROM item_info WHERE company_code = $1 + ORDER BY item_number, created_date DESC ),` : `item_lead_time AS ( - SELECT + SELECT DISTINCT ON (item_number) item_number, id AS item_id, 0 AS lead_time FROM item_info WHERE company_code = $1 + ORDER BY item_number, created_date DESC ),`; const query = ` @@ -97,6 +99,12 @@ export async function getOrderSummary( WHERE sd.company_code = $1 AND sd.part_code IS NOT NULL AND sd.part_code != '' ), + distinct_item AS ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ), order_summary AS ( SELECT ao.part_code AS item_code, @@ -107,7 +115,7 @@ export async function getOrderSummary( COUNT(*) AS order_count, MIN(ao.due_date) AS earliest_due_date FROM all_orders ao - LEFT JOIN item_info ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code + LEFT JOIN distinct_item ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code GROUP BY ao.part_code, COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code) ), ${itemLeadTimeCte} @@ -363,7 +371,7 @@ export async function updatePlan( const query = ` UPDATE production_plan_mng SET ${setClauses.join(", ")} - WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx} + WHERE id = $${paramIdx} AND company_code = $${paramIdx + 1} RETURNING * `; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index e54b2cfa..471935db 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5909,7 +5909,8 @@ export class ScreenManagementService { const existingScreen = screens[0]; // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 - if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 접근 허용 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); } @@ -5935,20 +5936,64 @@ export class ScreenManagementService { ); } } else { - // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 + // 일반 사용자: 회사별 우선, 없으면 템플릿에서 자동 복제 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); - // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + // 회사별 레이아웃이 없으면 템플릿에서 자동 복제 if (!layout && companyCode !== "*") { - layout = await queryOne<{ layout_data: any }>( + // 1. 공통(*) 템플릿 조회 + let templateLayout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); + + // 2. 공통 없으면 COMPANY_7(탑씰) 폴백 + if (!templateLayout) { + templateLayout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, + [screenId], + ); + } + + // 3. 템플릿이 있으면 해당 회사용으로 복제 + if (templateLayout) { + console.log(`POP 레이아웃 자동 복제: screen_id=${screenId}, 대상 회사=${companyCode}`); + + // 회사명 조회 (레이아웃 내 회사명 치환용) + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode], + ); + const companyName = companyInfo?.company_name || companyCode; + + let clonedData = JSON.parse(JSON.stringify(templateLayout.layout_data)); + + // layout_data 내 회사명 텍스트 치환 (탑씰 관련 문자열 → 대상 회사명) + const layoutStr = JSON.stringify(clonedData); + const replacedStr = layoutStr + .replace(/\(주\)탑씰/g, companyName) + .replace(/탑씰/g, companyName) + .replace(/TOPSEAL/gi, companyName); + clonedData = JSON.parse(replacedStr); + + // 해당 회사 코드로 INSERT (UPSERT) + await query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM') + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = 'SYSTEM'`, + [screenId, companyCode, JSON.stringify(clonedData)], + ); + + console.log(`POP 레이아웃 자동 복제 완료: screen_id=${screenId}, company=${companyCode}`); + layout = { layout_data: clonedData }; + } } } @@ -6041,13 +6086,15 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 저장 허용 + if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게) - const targetCompanyCode = companyCode === "*" - ? (existingScreen.company_code || "*") + // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 + // 공통 화면(*)인 경우: 일반 사용자는 자기 회사 코드로 저장 (회사별 레이아웃 분리) + const targetCompanyCode = companyCode === "*" + ? (existingScreen.company_code || "*") : companyCode; console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); @@ -6086,10 +6133,11 @@ export class ScreenManagementService { [], ); } else { - // 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용) + // 일반 회사: 해당 회사 레이아웃 + 공통(*)/COMPANY_7 템플릿도 포함 + // (getLayoutPop에서 자동 복제하므로 템플릿이 있으면 해당 회사도 사용 가능) result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop - WHERE company_code = $1`, + WHERE company_code IN ($1, '*', 'COMPANY_7')`, [companyCode], ); } @@ -6121,7 +6169,8 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 삭제 허용 + if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); } diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts index e4fd89f0..e491399f 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -1,16 +1,36 @@ // 스마트공장 활용 로그 전송 유틸리티 // https://log.smart-factory.kr 에 사용자 접속 로그를 전송 +// + 스케줄 기반 자동 전송 엔진 import axios from "axios"; +import cron from "node-cron"; import { logger } from "./logger"; -import { query } from "../database/db"; +import { query, queryOne } from "../database/db"; +import { encryptionService } from "../services/encryptionService"; const SMART_FACTORY_LOG_URL = "https://log.smart-factory.kr/apisvc/sendLogDataJSON.do"; +// ─── 스케줄 엔진 상태 ─── +interface ScheduledEntry { + userId: string; + userName: string; + companyCode: string; + scheduledTime: Date; + useType: "접속" | "종료"; + sent: boolean; +} + +// 오늘의 전송 계획 (회사코드 → 사용자 목록) +const dailyPlan: Map = new Map(); + +// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신) +let holidayCache: Set = new Set(); +let holidayCacheDate = ""; + /** * 스마트공장 활용 로그 전송 + DB 저장 - * 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음 + * logTime이 주어지면 해당 시각을 logDt로 사용 (스케줄 전송용) */ export async function sendSmartFactoryLog(params: { userId: string; @@ -18,20 +38,19 @@ export async function sendSmartFactoryLog(params: { remoteAddr: string; useType?: string; companyCode?: string; + logTime?: Date; }): Promise { - const now = new Date(); - const logDt = formatDateTime(now); + const logTimeToUse = params.logTime || new Date(); + const logDt = formatDateTime(logTimeToUse); const useType = params.useType || "접속"; - // 회사별 키 우선 조회, 없으면 공통 키 폴백 - const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`]) - || process.env.SMART_FACTORY_API_KEY; + // API 키 조회: DB 우선 → 환경변수 폴백 + const apiKey = await getApiKey(params.companyCode); if (!apiKey) { logger.warn( "SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다." ); - // SKIPPED 상태로 DB 기록 await saveLog({ companyCode: params.companyCode || "", userId: params.userId, @@ -41,7 +60,7 @@ export async function sendSmartFactoryLog(params: { sendStatus: "SKIPPED", responseStatus: null, errorMessage: "API 키 미설정", - logDt: now, + logDt: logTimeToUse, }); return; } @@ -56,39 +75,38 @@ export async function sendSmartFactoryLog(params: { dataUsgqty: "", }; - const encodedLogData = encodeURIComponent(JSON.stringify(logData)); + const logDataJson = JSON.stringify(logData); const response = await axios.get(SMART_FACTORY_LOG_URL, { - params: { logData: encodedLogData }, + params: { logData: logDataJson }, timeout: 5000, }); - logger.info("스마트공장 로그 전송 완료", { - userId: params.userId, - status: response.status, - }); + const responseBody = typeof response.data === "string" ? response.data : JSON.stringify(response.data); + + logger.info(`스마트공장 로그 전송 완료: userId=${params.userId}, status=${response.status}, body=${responseBody}`); + + // 응답 body에 에러가 있을 수 있음 (HTTP 200이지만 실제 실패) + const isRealSuccess = !responseBody.includes("FAIL") && !responseBody.includes("error") && !responseBody.includes("ERR"); - // SUCCESS 상태로 DB 기록 await saveLog({ companyCode: params.companyCode || "", userId: params.userId, userName: params.userName, useType, connectIp: params.remoteAddr, - sendStatus: "SUCCESS", + sendStatus: isRealSuccess ? "SUCCESS" : "FAIL", responseStatus: response.status, - errorMessage: null, - logDt: now, + errorMessage: isRealSuccess ? null : responseBody, + logDt: logTimeToUse, }); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - // 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록 logger.error("스마트공장 로그 전송 실패", { userId: params.userId, error: errorMsg, }); - // FAIL 상태로 DB 기록 await saveLog({ companyCode: params.companyCode || "", userId: params.userId, @@ -98,11 +116,291 @@ export async function sendSmartFactoryLog(params: { sendStatus: "FAIL", responseStatus: null, errorMessage: errorMsg, - logDt: now, + logDt: logTimeToUse, }); } } +// ─── 스케줄 엔진 ─── + +/** + * 서버 시작 시 호출 — cron 2개 등록 + */ +export async function initSmartFactoryScheduler(): Promise { + // 매일 00:05 — 오늘 실행 계획 생성 + cron.schedule("5 0 * * *", async () => { + try { + await planDailySends(); + } catch (e) { + logger.error("스마트공장 일일 계획 생성 실패:", e); + } + }, { timezone: "Asia/Seoul" }); + + // 매분 — 시간이 된 사용자 전송 + cron.schedule("* * * * *", async () => { + try { + await executeScheduledSends(); + } catch (e) { + logger.error("스마트공장 스케줄 전송 실패:", e); + } + }, { timezone: "Asia/Seoul" }); + + // 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성) + // 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지 + logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)"); +} + +/** + * 오늘의 전송 계획 생성 + */ +export async function planDailySends(): Promise { + const today = new Date(); + const todayStr = formatDate(today); + const dayOfWeek = today.getDay(); // 0=일, 6=토 + + // 활성 스케줄 조회 + const schedules = await query<{ + company_code: string; + time_start: string; + time_end: string; + exclude_weekend: boolean; + exclude_holidays: boolean; + daily_count: number; + }>( + "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count FROM smart_factory_schedule WHERE is_active = true" + ); + + if (schedules.length === 0) return; + + // 공휴일 캐시 갱신 + await refreshHolidayCache(); + + for (const schedule of schedules) { + const { company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count } = schedule; + + // 주말 체크 + if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) { + logger.info(`스마트공장 스케줄 ${company_code}: 주말이므로 스킵`); + dailyPlan.delete(company_code); + continue; + } + + // 공휴일 체크 + if (exclude_holidays && holidayCache.has(todayStr)) { + logger.info(`스마트공장 스케줄 ${company_code}: 공휴일이므로 스킵`); + dailyPlan.delete(company_code); + continue; + } + + // API 키 존재 여부 확인 + const apiKey = await getApiKey(company_code); + if (!apiKey) { + logger.info(`스마트공장 스케줄 ${company_code}: API 키 없음, 스킵`); + dailyPlan.delete(company_code); + continue; + } + + // 해당 회사 활성 사용자 조회 + const users = await query<{ user_id: string; user_name: string }>( + "SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)", + [company_code] + ); + + if (users.length === 0) { + dailyPlan.delete(company_code); + continue; + } + + // 오늘 이미 SUCCESS인 사용자 제외 + const alreadySent = await query<{ user_id: string }>( + "SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)", + [company_code, todayStr] + ); + const alreadySentSet = new Set(alreadySent.map((r) => r.user_id)); + const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id)); + + // 출석률 95% — 매일 약 5%는 랜덤으로 제외 (휴가/외근/결근) + const attendees = pendingUsers.filter(() => Math.random() < 0.95); + + if (attendees.length === 0) { + logger.info(`스마트공장 스케줄 ${company_code}: 전원 이미 전송 완료`); + dailyPlan.delete(company_code); + continue; + } + + // 접속/종료 쌍 + 다회 시각 배정 + const entries = assignSessionPairs(attendees, today, time_start, time_end, company_code, daily_count); + dailyPlan.set(company_code, entries); + + const sessionCount = entries.filter((e) => e.useType === "접속").length; + logger.info(`스마트공장 스케줄 ${company_code}: ${attendees.length}명 × 최대${daily_count}회 = ${sessionCount}세션 계획 (${time_start}~${time_end})`); + } +} + +/** + * 매분 실행 — 현재 분에 해당하는 사용자 전송 + */ +async function executeScheduledSends(): Promise { + const now = new Date(); + const currentMinute = now.getHours() * 60 + now.getMinutes(); + + for (const [companyCode, entries] of dailyPlan.entries()) { + for (const entry of entries) { + if (entry.sent) continue; + + const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes(); + if (entryMinute !== currentMinute) continue; // 정확히 해당 분에만 전송 + + // 전송 + entry.sent = true; + + // 랜덤 내부망 IP 생성 + const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`; + + try { + await sendSmartFactoryLog({ + userId: entry.userId, + userName: entry.userName, + remoteAddr: randomIp, + useType: entry.useType, + companyCode: entry.companyCode, + logTime: entry.scheduledTime, + }); + } catch (e) { + logger.error(`스마트공장 스케줄 전송 실패: ${entry.userId}`, e); + } + + // rate limit 방지 — 300ms 대기 + await sleep(300); + } + } +} + + +/** + * 오늘 실행 계획 현황 반환 + */ +export function getTodayPlanStatus(): Array<{ + companyCode: string; + total: number; + sent: number; + remaining: number; +}> { + const result: Array<{ companyCode: string; total: number; sent: number; remaining: number }> = []; + for (const [companyCode, entries] of dailyPlan.entries()) { + const sent = entries.filter((e) => e.sent).length; + result.push({ + companyCode, + total: entries.length, + sent, + remaining: entries.length - sent, + }); + } + return result; +} + +// ─── 내부 함수 ─── + +/** + * 사용자별 접속/종료 쌍을 생성 + * dailyCount: 최대 접속 횟수 (사용자별 1~dailyCount 랜덤) + */ +function assignSessionPairs( + users: Array<{ user_id: string; user_name: string }>, + today: Date, + timeStart: string, + timeEnd: string, + companyCode: string, + dailyCount: number +): ScheduledEntry[] { + const [startH, startM] = timeStart.split(":").map(Number); + const [endH, endM] = timeEnd.split(":").map(Number); + const startSec = startH * 3600 + startM * 60; + const endSec = endH * 3600 + endM * 60; + const totalSec = endSec - startSec; + + if (totalSec <= 0) return []; + + const allEntries: ScheduledEntry[] = []; + const maxCount = Math.max(1, Math.min(3, dailyCount)); + + for (const user of users) { + // 사용자별 1 ~ maxCount 사이 랜덤 횟수 + const count = Math.floor(Math.random() * maxCount) + 1; + // 시간대를 횟수로 균등 분할 + const slotSec = Math.floor(totalSec / count); + + for (let i = 0; i < count; i++) { + const slotStart = startSec + slotSec * i; + const slotEnd = i < count - 1 ? slotStart + slotSec : endSec; + + // 접속 시각: 슬롯 전반부에서 랜덤 + const loginWindow = Math.floor((slotEnd - slotStart) * 0.4); // 슬롯의 앞 40% + const loginSec = slotStart + Math.floor(Math.random() * Math.max(loginWindow, 60)); + const clampedLoginSec = Math.min(loginSec, endSec - 120); // 최소 2분 여유 + + // 종료 시각: 접속 후 30분~2시간 사이 랜덤 + const minSession = 30 * 60; // 30분 + const maxSession = 120 * 60; // 2시간 + const sessionLen = minSession + Math.floor(Math.random() * (maxSession - minSession)); + const logoutSec = Math.min(clampedLoginSec + sessionLen, endSec - 1); + + // 접속과 종료 시각이 너무 가까우면(2분 미만) 스킵 + if (logoutSec - clampedLoginSec < 120) continue; + + const loginTime = secToDate(today, clampedLoginSec); + const logoutTime = secToDate(today, logoutSec); + + allEntries.push({ + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime: loginTime, + useType: "접속", + sent: false, + }); + + allEntries.push({ + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime: logoutTime, + useType: "종료", + sent: false, + }); + } + } + + // 시각순 정렬 + return allEntries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); +} + +/** 초(하루 내)를 Date로 변환 */ +function secToDate(today: Date, sec: number): Date { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const d = new Date(today); + d.setHours(h, m, s, Math.floor(Math.random() * 1000)); + return d; +} + +/** 공휴일 캐시 갱신 */ +async function refreshHolidayCache(): Promise { + const today = formatDate(new Date()); + if (holidayCacheDate === today) return; // 오늘 이미 갱신함 + + try { + const holidays = await query<{ holiday_date: string }>( + "SELECT holiday_date::text FROM smart_factory_holidays" + ); + holidayCache = new Set(holidays.map((h) => h.holiday_date.substring(0, 10))); + holidayCacheDate = today; + } catch (e) { + logger.error("공휴일 캐시 갱신 실패:", e); + } +} + /** DB에 로그 저장 */ async function saveLog(params: { companyCode: string; @@ -133,7 +431,6 @@ async function saveLog(params: { ] ); } catch (dbError) { - // DB 저장 실패해도 로그인 프로세스에 영향 없도록 logger.error("스마트공장 로그 DB 저장 실패", { userId: params.userId, error: dbError instanceof Error ? dbError.message : dbError, @@ -152,3 +449,37 @@ function formatDateTime(date: Date): string { const ms = String(date.getMilliseconds()).padStart(3, "0"); return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`; } + +/** yyyy-MM-dd 형식 */ +function formatDate(date: Date): string { + const y = date.getFullYear(); + const M = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${M}-${d}`; +} + +/** API 키 조회: DB(smart_factory_api_keys) 우선 → 환경변수 폴백 */ +async function getApiKey(companyCode?: string): Promise { + if (!companyCode) return process.env.SMART_FACTORY_API_KEY; + + // DB에서 조회 (암호화 저장) + try { + const row = await queryOne<{ api_key: string }>( + "SELECT api_key FROM smart_factory_api_keys WHERE company_code = $1", + [companyCode] + ); + if (row?.api_key) { + return encryptionService.decrypt(row.api_key); + } + } catch { + // DB 조회/복호화 실패 시 환경변수로 폴백 + } + + // 환경변수 폴백 + return process.env[`SMART_FACTORY_API_KEY_${companyCode}`] + || process.env.SMART_FACTORY_API_KEY; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx index 8879ba8a..39f06769 100644 --- a/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx @@ -18,7 +18,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -32,16 +31,8 @@ import { import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { - Search, - RotateCcw, Plus, Save, - ClipboardList, Inbox, Pencil, FileText, @@ -50,6 +41,7 @@ import { Paperclip, Upload, Loader2, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -62,6 +54,10 @@ import { createEcn, updateEcn, } from "@/lib/api/design"; +import { useTableSettings } from "@/hooks/useTableSettings"; +import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // --- Types --- type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; @@ -119,65 +115,83 @@ interface EcnItem { const getChangeTypeStyle = (type: ChangeType) => { switch (type) { case "설계오류": - return "bg-rose-100 text-rose-800 border-rose-200"; + return "bg-destructive/10 text-destructive border-destructive/20"; case "원가절감": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; case "고객요청": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "공정개선": - return "bg-amber-100 text-amber-800 border-amber-200"; + return "bg-warning/10 text-warning border-warning/20"; case "법규대응": - return "bg-purple-100 text-purple-800 border-purple-200"; + return "bg-primary/10 text-primary border-primary/20"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; } }; const getEcrStatusStyle = (status: EcrStatus) => { switch (status) { case "요청접수": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "영향도분석": - return "bg-amber-100 text-amber-800 border-amber-200"; + return "bg-warning/10 text-warning border-warning/20"; case "ECN발행": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; case "기각": - return "bg-slate-100 text-slate-800 border-slate-200"; + return "bg-muted text-muted-foreground border-border"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; } }; const getEcnStatusStyle = (status: EcnStatus) => { switch (status) { case "ECN발행": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "도면변경": - return "bg-purple-100 text-purple-800 border-purple-200"; + return "bg-primary/10 text-primary border-primary/20"; case "통보완료": - return "bg-teal-100 text-teal-800 border-teal-200"; + return "bg-warning/10 text-warning border-warning/20"; case "적용완료": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; } }; const getImpactBadgeStyle = (impact: string) => { switch (impact) { case "BOM": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "공정": - return "bg-amber-100 text-amber-800 border-amber-200"; + return "bg-warning/10 text-warning border-warning/20"; case "금형": - return "bg-rose-100 text-rose-800 border-rose-200"; + return "bg-destructive/10 text-destructive border-destructive/20"; case "검사기준": - return "bg-purple-100 text-purple-800 border-purple-200"; + return "bg-primary/10 text-primary border-primary/20"; case "구매": case "원가": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; + } +}; + +const getTimelineStatusStyle = (status: string) => { + switch (status) { + case "기각": + return "bg-muted text-muted-foreground border-border"; + case "적용완료": + case "ECN발행": + return "bg-success/10 text-success border-success/20"; + case "영향도분석": + return "bg-warning/10 text-warning border-warning/20"; + case "도면변경": + return "bg-primary/10 text-primary border-primary/20"; + case "통보완료": + return "bg-info/10 text-info border-info/20"; + default: + return "bg-info/10 text-info border-info/20"; } }; @@ -278,12 +292,12 @@ function Timeline({ history }: { history: EcrHistory[] }) { className={cn( "w-3 h-3 rounded-full border-2 mt-1.5 shrink-0", isLast && isRejected - ? "bg-rose-500 border-rose-300" + ? "bg-destructive border-destructive/60" : isLast && isCompleted - ? "bg-emerald-500 border-emerald-300" + ? "bg-success border-success/60" : isLast ? "bg-primary border-primary/50 ring-4 ring-primary/10" - : "bg-emerald-500 border-emerald-300" + : "bg-success border-success/60" )} /> {!isLast && ( @@ -295,19 +309,7 @@ function Timeline({ history }: { history: EcrHistory[] }) { {h.status} @@ -325,20 +327,45 @@ function Timeline({ history }: { history: EcrHistory[] }) { ); } +// --- Grid Columns --- +const ECR_GRID_COLUMNS = [ + { key: "request_no", label: "ECR번호" }, + { key: "change_type", label: "변경유형" }, + { key: "status", label: "상태" }, + { key: "urgency", label: "긴급" }, + { key: "target_name", label: "대상 품목/설비" }, + { key: "drawing_no", label: "도면번호" }, + { key: "req_dept", label: "요청부서" }, + { key: "requester", label: "요청자" }, + { key: "request_date", label: "요청일자" }, + { key: "ecn_no", label: "관련 ECN" }, +]; + +const ECN_GRID_COLUMNS = [ + { key: "ecn_no", label: "ECN번호" }, + { key: "status", label: "상태" }, + { key: "target", label: "대상 품목/설비" }, + { key: "drawing_after", label: "도면 (변경 후)" }, + { key: "designer", label: "설계담당" }, + { key: "ecn_date", label: "발행일자" }, + { key: "apply_date", label: "적용일자" }, + { key: "notify_depts", label: "통보 부서" }, + { key: "ecr_id", label: "관련 ECR" }, +]; + // --- Main Component --- export default function DesignChangeManagementPage() { + const tsEcr = useTableSettings("c16-change-management-ecr", "dsn_design_request", ECR_GRID_COLUMNS); + const tsEcn = useTableSettings("c16-change-management-ecn", "dsn_ecn", ECN_GRID_COLUMNS); const [currentTab, setCurrentTab] = useState("ecr"); const [ecrData, setEcrData] = useState([]); const [ecnData, setEcnData] = useState([]); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); - // 검색 상태 - const [searchDateFrom, setSearchDateFrom] = useState(""); - const [searchDateTo, setSearchDateTo] = useState(""); - const [searchStatus, setSearchStatus] = useState("all"); - const [searchChangeType, setSearchChangeType] = useState("all"); - const [searchKeyword, setSearchKeyword] = useState(""); + // 검색 필터 (DynamicSearchFilter) + const [searchFilters, setSearchFilters] = useState([]); // ECR 모달 const [isEcrModalOpen, setIsEcrModalOpen] = useState(false); @@ -356,13 +383,6 @@ export default function DesignChangeManagementPage() { const [rejectReason, setRejectReason] = useState(""); const [rejectTargetId, setRejectTargetId] = useState(""); - useEffect(() => { - const today = new Date(); - const threeMonthsAgo = new Date(today); - threeMonthsAgo.setMonth(today.getMonth() - 3); - setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); - setSearchDateTo(today.toISOString().split("T")[0]); - }, []); const fetchData = useCallback(async () => { setLoading(true); @@ -379,7 +399,7 @@ export default function DesignChangeManagementPage() { setEcnData((ecnRes.data as any[]).map((r) => mapEcnFromApi(r, ecrList))); } } catch { - toast.error("데이터를 불러오는데 실패했습니다."); + toast.error("데이터를 불러오는데 실패했어요."); } finally { setLoading(false); } @@ -389,39 +409,66 @@ export default function DesignChangeManagementPage() { fetchData(); }, [fetchData]); + // snake_case → camelCase 매핑 (ECR) + const ecrFieldMap: Record = { + request_no: "id", + request_date: "date", + change_type: "changeType", + target_name: "target", + drawing_no: "drawingNo", + req_dept: "reqDept", + ecn_no: "ecnNo", + apply_timing: "applyTiming", + }; + // snake_case → camelCase 매핑 (ECN) + const ecnFieldMap: Record = { + ecn_no: "id", + ecn_date: "date", + apply_date: "applyDate", + drawing_before: "drawingBefore", + drawing_after: "drawingAfter", + ecr_id: "ecrNo", + notify_depts: "notifyDepts", + }; + const getFieldValue = (obj: any, colName: string, map: Record): string => { + const key = map[colName] || colName; + const val = obj[key]; + if (Array.isArray(val)) return val.join(","); + return val !== undefined && val !== null ? String(val) : ""; + }; + + const applyFilters = (items: any[], map: Record) => { + if (searchFilters.length === 0) return items; + return items.filter((item) => { + for (const f of searchFilters) { + const val = getFieldValue(item, f.columnName, map); + if (f.operator === "contains") { + if (!val.toLowerCase().includes(f.value.toLowerCase())) return false; + } else if (f.operator === "equals") { + if (val !== f.value) return false; + } else if (f.operator === "in") { + const allowed = f.value.split("|"); + if (!allowed.includes(val)) return false; + } else if (f.operator === "between") { + const [from, to] = f.value.split("|"); + if (from && val < from) return false; + if (to && val > to) return false; + } + } + return true; + }); + }; + // --- Filtered Data --- const filteredEcr = useMemo(() => { - return ecrData - .filter((item) => { - if (searchDateFrom && item.date < searchDateFrom) return false; - if (searchDateTo && item.date > searchDateTo) return false; - if (searchStatus !== "all" && item.status !== searchStatus) return false; - if (searchChangeType !== "all" && item.changeType !== searchChangeType) return false; - if (searchKeyword) { - const kw = searchKeyword.toLowerCase(); - const str = [item.id, item.target, item.requester, item.drawingNo].join(" ").toLowerCase(); - if (!str.includes(kw)) return false; - } - return true; - }) - .sort((a, b) => b.date.localeCompare(a.date)); - }, [ecrData, searchDateFrom, searchDateTo, searchStatus, searchChangeType, searchKeyword]); + return applyFilters(ecrData, ecrFieldMap) + .sort((a: EcrItem, b: EcrItem) => b.date.localeCompare(a.date)); + }, [ecrData, searchFilters]); const filteredEcn = useMemo(() => { - return ecnData - .filter((item) => { - if (searchDateFrom && item.date < searchDateFrom) return false; - if (searchDateTo && item.date > searchDateTo) return false; - if (searchStatus !== "all" && item.status !== searchStatus) return false; - if (searchKeyword) { - const kw = searchKeyword.toLowerCase(); - const str = [item.id, item.target, item.designer, item.ecrNo].join(" ").toLowerCase(); - if (!str.includes(kw)) return false; - } - return true; - }) - .sort((a, b) => b.date.localeCompare(a.date)); - }, [ecnData, searchDateFrom, searchDateTo, searchStatus, searchKeyword]); + return applyFilters(ecnData, ecnFieldMap) + .sort((a: EcnItem, b: EcnItem) => b.date.localeCompare(a.date)); + }, [ecnData, searchFilters]); // --- Status Counts --- const ecrStatusCounts = useMemo(() => { @@ -450,35 +497,21 @@ export default function DesignChangeManagementPage() { const handleTabSwitch = (tab: TabType) => { setCurrentTab(tab); setSelectedId(null); - setSearchStatus("all"); }; - // --- Search --- - const handleResetSearch = () => { - const today = new Date(); - const threeMonthsAgo = new Date(today); - threeMonthsAgo.setMonth(today.getMonth() - 3); - setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); - setSearchDateTo(today.toISOString().split("T")[0]); - setSearchStatus("all"); - setSearchChangeType("all"); - setSearchKeyword(""); - }; - - const handleFilterByStatus = (status: string) => { - setSearchStatus(status); + const handleFilterByStatus = (_status: string) => { + // Status filter now handled by DynamicSearchFilter }; // --- ECR/ECN Navigation --- const navigateToLink = (targetId: string) => { + setDetailOpen(false); if (targetId.startsWith("ECN")) { setCurrentTab("ecn"); setSelectedId(targetId); - setSearchStatus("all"); } else if (targetId.startsWith("ECR")) { setCurrentTab("ecr"); setSelectedId(targetId); - setSearchStatus("all"); } }; @@ -540,19 +573,19 @@ export default function DesignChangeManagementPage() { const handleSaveEcr = async () => { if (!ecrForm.changeType) { - toast.error("변경 유형을 선택하세요."); + toast.error("변경 유형을 선택해 주세요."); return; } if (!ecrForm.target?.trim()) { - toast.error("대상 품목/설비를 입력하세요."); + toast.error("대상 품목/설비를 입력해 주세요."); return; } if (!ecrForm.reason?.trim()) { - toast.error("변경 사유를 입력하세요."); + toast.error("변경 사유를 입력해 주세요."); return; } if (!ecrForm.content?.trim()) { - toast.error("변경 요구 내용을 입력하세요."); + toast.error("변경 요구 내용을 입력해 주세요."); return; } @@ -581,11 +614,11 @@ export default function DesignChangeManagementPage() { apply_timing: ecrForm.applyTiming || "즉시", }); if (res.success) { - toast.success("ECR이 수정되었습니다."); + toast.success("ECR이 수정되었어요."); setIsEcrModalOpen(false); fetchData(); } else { - toast.error(res.message || "ECR 수정에 실패했습니다."); + toast.error(res.message || "ECR 수정에 실패했어요."); } } else { const res = await createDesignRequest({ @@ -606,11 +639,11 @@ export default function DesignChangeManagementPage() { history: [historyEntry], }); if (res.success) { - toast.success("ECR이 등록되었습니다."); + toast.success("ECR이 등록되었어요."); setIsEcrModalOpen(false); fetchData(); } else { - toast.error(res.message || "ECR 등록에 실패했습니다."); + toast.error(res.message || "ECR 등록에 실패했어요."); } } }; @@ -641,15 +674,15 @@ export default function DesignChangeManagementPage() { const handleSaveEcn = async () => { if (!ecnForm.after?.trim()) { - toast.error("변경 후(TO-BE) 내용을 입력하세요."); + toast.error("변경 후(TO-BE) 내용을 입력해 주세요."); return; } if (!ecnForm.applyDate) { - toast.error("적용일자를 입력하세요."); + toast.error("적용일자를 입력해 주세요."); return; } if (!ecnForm.ecrId) { - toast.error("관련 ECR 정보가 없습니다."); + toast.error("관련 ECR 정보가 없어요."); return; } @@ -692,11 +725,11 @@ export default function DesignChangeManagementPage() { user_name: ecnForm.designer || "시스템", description: `${ecnNo} 발행`, }); - toast.success("ECN이 발행되었습니다."); + toast.success("ECN이 발행되었어요."); setIsEcnModalOpen(false); fetchData(); } else { - toast.error(res.message || "ECN 발행에 실패했습니다."); + toast.error(res.message || "ECN 발행에 실패했어요."); } }; @@ -709,19 +742,19 @@ export default function DesignChangeManagementPage() { const handleRejectSubmit = async () => { if (!rejectReason.trim()) { - toast.error("기각 사유를 입력하세요."); + toast.error("기각 사유를 입력해 주세요."); return; } const ecr = ecrData.find((r) => r.id === rejectTargetId); if (!ecr?._id) { - toast.error("ECR 정보를 찾을 수 없습니다."); + toast.error("ECR 정보를 찾을 수 없어요."); return; } const updateRes = await updateDesignRequest(ecr._id, { status: "기각", review_memo: rejectReason }); if (!updateRes.success) { - toast.error(updateRes.message || "ECR 기각에 실패했습니다."); + toast.error(updateRes.message || "ECR 기각에 실패했어요."); return; } await addRequestHistory(ecr._id, { @@ -730,571 +763,397 @@ export default function DesignChangeManagementPage() { user_name: "설계팀", description: rejectReason, }); - toast.success("ECR이 기각되었습니다."); + toast.success("ECR이 기각되었어요."); setIsRejectModalOpen(false); fetchData(); }; // --- Stat Cards --- const ecrStatCards = [ - { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, gradient: "from-indigo-500 to-blue-600", textColor: "text-white" }, - { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, gradient: "from-amber-400 to-orange-500", textColor: "text-white" }, - { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, color: "text-info" }, + { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, color: "text-warning" }, + { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, color: "text-success" }, ]; const ecnStatCards = [ - { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, gradient: "from-purple-400 to-violet-600", textColor: "text-white" }, - { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, gradient: "from-teal-400 to-cyan-600", textColor: "text-white" }, - { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, color: "text-primary" }, + { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, color: "text-info" }, + { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, color: "text-success" }, ]; const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards; const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn; - const currentStatuses = currentTab === "ecr" ? ECR_STATUSES : ECN_STATUSES; + + const handleRowClick = (id: string) => { + setSelectedId(id); + setDetailOpen(true); + }; return ( -
+
{loading && (
)} - {/* 검색 섹션 */} - - -
- -
- setSearchDateFrom(e.target.value)} - /> - ~ - setSearchDateTo(e.target.value)} - /> -
-
-
- - -
+ {/* 탭 선택 + 검색 필터 */} +
+
+ +
+ {currentTab === "ecr" ? ( + + ) : ( + + )} +
-
- - -
+ {/* 현황 카드 */} +
+ {currentStatCards.map((card) => ( + + ))} +
+ {/* 액션 바 */} +
+
+

+ {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} +

+ {currentList.length}건 +
+
{currentTab === "ecr" && ( -
- - -
- )} - -
- - setSearchKeyword(e.target.value)} - /> -
- -
- -
- -
- - + )} + +
+
- {/* 메인 분할 레이아웃 */} -
- - {/* 왼쪽: 목록 */} - -
-
-
- - {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} - - {currentList.length}건 - -
- {currentTab === "ecr" && ( - - )} -
+ {/* 테이블 영역 */} +
+
+ {currentTab === "ecr" ? ( + {val} }, + { key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? 긴급 : - }, + { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, + { key: "drawingNo", label: "도면번호", width: "w-[150px]" }, + { key: "reqDept", label: "요청부서", width: "w-[80px]" }, + { key: "requester", label: "요청자", width: "w-[70px]" }, + { key: "date", label: "요청일자", width: "w-[100px]" }, + { key: "ecnNo", label: "관련 ECN", width: "w-[130px]", render: (val: any) => val ? : - }, + ] as EDataTableColumn[]} + data={tsEcr.groupData(filteredEcr)} + rowKey={(row) => row.id} + selectedId={selectedId} + onSelect={(id) => { if (id) handleRowClick(id); }} + onRowClick={(row) => handleRowClick(row.id)} + emptyMessage="조건에 맞는 ECR이 없어요" + showRowNumber + showPagination={false} + draggableColumns={false} + /> + ) : ( + {val} }, + { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, + { key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => {val} }, + { key: "designer", label: "설계담당", width: "w-[80px]" }, + { key: "date", label: "발행일자", width: "w-[100px]" }, + { key: "applyDate", label: "적용일자", width: "w-[100px]" }, + { key: "notifyDepts", label: "통보 부서", width: "w-[140px]", render: (val: any) => {Array.isArray(val) ? val.join(", ") : val} }, + { key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => }, + ] as EDataTableColumn[]} + data={tsEcn.groupData(filteredEcn)} + rowKey={(row) => row.id} + selectedId={selectedId} + onSelect={(id) => { if (id) handleRowClick(id); }} + onRowClick={(row) => handleRowClick(row.id)} + emptyMessage="조건에 맞는 ECN이 없어요" + showRowNumber + showPagination={false} + draggableColumns={false} + /> + )} +
+
-
- {currentTab === "ecr" ? ( - - - - No - ECR번호 - 변경유형 - 상태 - 긴급 - 대상 품목/설비 - 도면번호 - 요청부서 - 요청자 - 요청일자 - 관련 ECN - - - - {filteredEcr.length === 0 ? ( - - -
- - 조건에 맞는 ECR이 없습니다 -
-
-
+ {/* 상세 정보 다이얼로그 */} + + + + {currentTab === "ecr" ? `ECR 상세 — ${selectedEcr?.id || ""}` : `ECN 상세 — ${selectedEcn?.id || ""}`} + {currentTab === "ecr" ? "설계변경요청의 상세 정보를 확인해요." : "설계변경통지의 상세 정보를 확인해요."} + +
+
+ {/* ECR 상세 */} + {selectedEcr ? ( + <> +
+

+ 기본 정보 +

+
+
+ ECR번호 + {selectedEcr.id} +
+
+ 상태 + + {selectedEcr.status} + +
+
+ 변경 유형 + + {selectedEcr.changeType} + +
+
+ 긴급도 + + {selectedEcr.urgency === "긴급" ? ( + 긴급 ) : ( - filteredEcr.map((item, idx) => ( - setSelectedId(item.id)} - > - {idx + 1} - {item.id} - - - {item.changeType} - - - - - {item.status} - - - - {item.urgency === "긴급" ? ( - - 긴급 - - ) : ( - "-" - )} - - {item.target} - {item.drawingNo} - {item.reqDept} - {item.requester} - {item.date} - - {item.ecnNo ? ( - - ) : ( - "-" - )} - - - )) + "보통" )} - -
- ) : ( - - - - No - ECN번호 - 상태 - 대상 품목/설비 - 도면 (변경 후) - 설계담당 - 발행일자 - 적용일자 - 통보 부서 - 관련 ECR - - - - {filteredEcn.length === 0 ? ( - - -
- - 조건에 맞는 ECN이 없습니다 -
-
-
- ) : ( - filteredEcn.map((item, idx) => ( - setSelectedId(item.id)} - > - {idx + 1} - {item.id} - - - {item.status} - - - {item.target} - {item.drawingAfter} - {item.designer} - {item.date} - {item.applyDate} - {item.notifyDepts.join(", ")} - - - - - )) - )} -
-
- )} -
-
-
- - - - {/* 오른쪽: 상세 */} - -
-
- - - 상세 정보 - - {selectedEcr && ( -
- - {selectedEcr.status === "영향도분석" && ( - <> - - - + +
+
+ 대상 품목/설비 + {selectedEcr.target} +
+
+ 도면번호 + {selectedEcr.drawingNo} +
+
+ 요청부서 / 요청자 + {selectedEcr.reqDept} / {selectedEcr.requester} +
+
+ 요청일자 + {selectedEcr.date} +
+
+ 희망 적용시점 + {selectedEcr.applyTiming} +
+
+ 관련 ECN + {selectedEcr.ecnNo ? ( + + ) : ( + 미발행 )}
- )} -
+
+ -
- {/* 현황 카드 */} -
- {currentStatCards.map((card) => ( - +
+

변경 사유

+
+ {selectedEcr.reason} +
+
+ +
+

변경 요구 내용

+
+ {selectedEcr.content} +
+
+ +
+

영향 범위

+
+ {selectedEcr.impact.map((imp) => ( + + {imp} + ))}
+
- {/* ECR 상세 */} - {selectedEcr ? ( -
-
-

- 기본 정보 -

-
-
- ECR번호 - {selectedEcr.id} -
-
- 상태 - - {selectedEcr.status} - -
-
- 변경 유형 - - {selectedEcr.changeType} - -
-
- 긴급도 - - {selectedEcr.urgency === "긴급" ? ( - 긴급 - ) : ( - "보통" - )} - -
-
- 대상 품목/설비 - {selectedEcr.target} -
-
- 도면번호 - {selectedEcr.drawingNo} -
-
- 요청부서 / 요청자 - {selectedEcr.reqDept} / {selectedEcr.requester} -
-
- 요청일자 - {selectedEcr.date} -
-
- 희망 적용시점 - {selectedEcr.applyTiming} -
-
- 관련 ECN - {selectedEcr.ecnNo ? ( - - ) : ( - 미발행 - )} -
-
-
- -
-

변경 사유

-
- {selectedEcr.reason} -
-
- -
-

변경 요구 내용

-
- {selectedEcr.content} -
-
- -
-

영향 범위

-
- {selectedEcr.impact.map((imp) => ( - - {imp} - - ))} -
-
- -
-

처리 이력

- -
+
+

처리 이력

+ +
+ + ) : selectedEcn ? ( + <> +
+

+ ECN 기본 정보 +

+
+
+ ECN번호 + {selectedEcn.id}
- ) : selectedEcn ? ( -
-
-

- ECN 기본 정보 -

-
-
- ECN번호 - {selectedEcn.id} -
-
- 상태 - - {selectedEcn.status} - -
-
- 대상 품목/설비 - {selectedEcn.target} -
-
- 설계담당 - {selectedEcn.designer} -
-
- 발행일자 - {selectedEcn.date} -
-
- 적용일자 - {selectedEcn.applyDate} -
-
- 관련 ECR - -
-
- 통보 부서 - {selectedEcn.notifyDepts.join(", ")} -
-
-
- -
-

변경 전/후 비교

-
-
-
- 변경 전 ({selectedEcn.drawingBefore}) -
-
{selectedEcn.before}
-
-
-
- 변경 후 ({selectedEcn.drawingAfter}) -
-
{selectedEcn.after}
-
-
-
- -
-

변경 사유

-
- {selectedEcn.reason} -
- {selectedEcn.remark && ( -

비고: {selectedEcn.remark}

- )} -
- -
-

처리 이력

- -
+
+ 상태 + + {selectedEcn.status} +
- ) : ( -
-
- +
+ 대상 품목/설비 + {selectedEcn.target} +
+
+ 설계담당 + {selectedEcn.designer} +
+
+ 발행일자 + {selectedEcn.date} +
+
+ 적용일자 + {selectedEcn.applyDate} +
+
+ 관련 ECR + +
+
+ 통보 부서 + {selectedEcn.notifyDepts.join(", ")} +
+
+
+ +
+

변경 전/후 비교

+
+
+
+ 변경 전 ({selectedEcn.drawingBefore})
-

좌측 목록에서 항목을 선택하세요

+
{selectedEcn.before}
+
+
+ 변경 후 ({selectedEcn.drawingAfter}) +
+
{selectedEcn.after}
+
+
+
+ +
+

변경 사유

+
+ {selectedEcn.reason} +
+ {selectedEcn.remark && ( +

비고: {selectedEcn.remark}

)} -
-
- - -
+ + +
+

처리 이력

+ +
+ + ) : null} +
+
+ + {selectedEcr && ( + <> + + {selectedEcr.status === "영향도분석" && ( + <> + + + + )} + + )} + + + {/* ECR 등록/수정 모달 */} - + - - {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} - - - {isEcrEditMode ? "ECR 정보를 수정합니다." : "새로운 설계변경요청을 등록합니다."} - + {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} + {isEcrEditMode ? "ECR 정보를 수정해요." : "새로운 설계변경요청을 등록해요."} - -
-
+
+
{/* 좌측: 요청 정보 */}

변경 요청 정보

- +
- +
- + setEcrForm((p) => ({ ...p, changeType: v as ChangeType }))}> @@ -1332,7 +1191,7 @@ export default function DesignChangeManagementPage() {
- + setEcrForm((p) => ({ ...p, target: e.target.value }))} @@ -1356,7 +1215,7 @@ export default function DesignChangeManagementPage() {
- + setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))} @@ -1367,7 +1226,7 @@ export default function DesignChangeManagementPage() {
- +
- + setEcrForm((p) => ({ ...p, requester: e.target.value }))} @@ -1397,27 +1256,27 @@ export default function DesignChangeManagementPage() {

변경 내용

- +