diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 8fba4591..60c4615f 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", @@ -1051,6 +1050,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2384,6 +2384,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3501,6 +3502,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3746,6 +3748,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3974,6 +3977,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4523,6 +4527,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5898,6 +5903,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6176,6 +6182,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7762,6 +7769,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8731,7 +8739,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9701,6 +9708,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -9920,50 +9928,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", @@ -10668,7 +10632,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -11562,6 +11525,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11667,6 +11631,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" 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/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 4dcdc275..b526bc0f 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -7,6 +7,7 @@ import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; +import { query, queryOne } from "../database/db"; export class AuthController { /** @@ -105,6 +106,14 @@ export class AuthController { popLandingPath = "/pop"; } logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + + // POP 메뉴가 존재하면 해당 회사의 POP 레이아웃 자동 초기화 (비동기) + if (popLandingPath) { + const companyCode = loginResult.userInfo.companyCode || "ILSHIN"; + AuthController.initPopLayoutsForCompany(companyCode).catch((err) => { + 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/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/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/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/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx deleted file mode 100644 index cacd9d02..00000000 --- a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx +++ /dev/null @@ -1,1703 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { - Plus, - Trash2, - Save, - Loader2, - Pencil, - ClipboardCheck, - AlertTriangle, - Wrench, - Search, - Inbox, - Settings2, -} from "lucide-react"; -import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { cn } from "@/lib/utils"; -import { apiClient } from "@/lib/api/client"; -import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; -import { useAuth } from "@/hooks/useAuth"; -import { toast } from "sonner"; -import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { useTableSettings } from "@/hooks/useTableSettings"; -import { TableSettingsModal } from "@/components/common/TableSettingsModal"; -import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; - -/* ───── 테이블명 ───── */ -const INSPECTION_TABLE = "inspection_standard"; - -const INSPECTION_COLUMNS = [ - { key: "inspection_code", label: "검사코드" }, - { key: "inspection_type", label: "검사유형" }, - { key: "inspection_criteria", label: "검사기준" }, - { key: "inspection_item", label: "검사항목" }, - { key: "inspection_method", label: "검사방법" }, - { key: "judgment_criteria", label: "판단기준" }, - { key: "unit", label: "단위" }, - { key: "apply_type", label: "적용구분" }, - { key: "manager", label: "관리자" }, -]; -const DEFECT_TABLE = "defect_standard_mng"; -const EQUIPMENT_TABLE = "inspection_equipment_mng"; - -/* ───── 카테고리 flatten ───── */ -const flattenCategories = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flattenCategories(v.children)); - } - return result; -}; - -export default function InspectionManagementPage() { - const { user } = useAuth(); - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - const ts = useTableSettings("c16-inspection", INSPECTION_TABLE, INSPECTION_COLUMNS); - - const [activeTab, setActiveTab] = useState("inspection"); - - /* ───── 검사기준 ───── */ - const [inspections, setInspections] = useState([]); - const [inspLoading, setInspLoading] = useState(false); - const [inspCount, setInspCount] = useState(0); - const [inspChecked, setInspChecked] = useState([]); - const [inspModalOpen, setInspModalOpen] = useState(false); - const [inspEditMode, setInspEditMode] = useState(false); - const [inspForm, setInspForm] = useState>({}); - const [inspSaving, setInspSaving] = useState(false); - const [searchFilters, setSearchFilters] = useState([]); - - /* ───── 불량관리 ───── */ - const [defects, setDefects] = useState([]); - const [defLoading, setDefLoading] = useState(false); - const [defCount, setDefCount] = useState(0); - const [defChecked, setDefChecked] = useState([]); - const [defModalOpen, setDefModalOpen] = useState(false); - const [defEditMode, setDefEditMode] = useState(false); - const [defForm, setDefForm] = useState>({}); - const [defSaving, setDefSaving] = useState(false); - const [defKeyword, setDefKeyword] = useState(""); - - /* ───── 검사장비 ───── */ - const [equipments, setEquipments] = useState([]); - const [eqLoading, setEqLoading] = useState(false); - const [eqCount, setEqCount] = useState(0); - const [eqChecked, setEqChecked] = useState([]); - const [eqModalOpen, setEqModalOpen] = useState(false); - const [eqEditMode, setEqEditMode] = useState(false); - const [eqForm, setEqForm] = useState>({}); - const [eqSaving, setEqSaving] = useState(false); - const [eqKeyword, setEqKeyword] = useState(""); - - /* ───── 채번 ───── */ - const [numberingRuleId, setNumberingRuleId] = useState(null); - const [previewCode, setPreviewCode] = useState(null); - - /* ───── 카테고리 옵션 ───── */ - const [catOptions, setCatOptions] = useState>({}); - const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); - - /* ═══════════════════ 카테고리 로드 ═══════════════════ */ - useEffect(() => { - const load = async () => { - const optMap: Record = {}; - const catList = [ - { table: INSPECTION_TABLE, col: "inspection_type" }, - { table: INSPECTION_TABLE, col: "apply_type" }, - { table: INSPECTION_TABLE, col: "inspection_method" }, - { table: INSPECTION_TABLE, col: "judgment_criteria" }, - { table: INSPECTION_TABLE, col: "unit" }, - { table: DEFECT_TABLE, col: "defect_type" }, - { table: DEFECT_TABLE, col: "severity" }, - { table: DEFECT_TABLE, col: "inspection_type" }, - { table: DEFECT_TABLE, col: "is_active" }, - { table: EQUIPMENT_TABLE, col: "equipment_type" }, - { table: EQUIPMENT_TABLE, col: "equipment_status" }, - ]; - await Promise.all( - catList.map(async ({ table, col }) => { - try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values`); - if (res.data?.data?.length > 0) { - optMap[`${table}.${col}`] = flattenCategories(res.data.data); - } - } catch { - /* skip */ - } - }), - ); - setCatOptions(optMap); - // 사용자 목록 로드 - try { - const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, - size: 500, - autoFilter: true, - }); - const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; - setUserOptions( - users.map((u: any) => ({ - code: u.user_id || u.id, - label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`, - })), - ); - } catch { - /* skip */ - } - }; - load(); - }, []); - - const getCatLabel = (table: string, col: string, code: string) => { - if (!code) return ""; - const opts = catOptions[`${table}.${col}`]; - if (!opts) return code; - // 쉼표 구분 다중 코드 지원 - if (code.includes(",")) { - return code - .split(",") - .filter(Boolean) - .map((c) => opts.find((o) => o.code === c)?.label || c) - .join(", "); - } - return opts.find((o) => o.code === code)?.label || code; - }; - - const inspTableColumns = useMemo(() => { - return ts.visibleColumns.map((col) => { - const base: EDataTableColumn = { key: col.key, label: col.label }; - if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) { - base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]); - } - return base; - }); - }, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps - - /* ═══════════════════ 데이터 조회 ═══════════════════ */ - // 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용 - const MULTI_VALUE_COLUMNS = ["inspection_type"]; - - const fetchInspections = useCallback(async () => { - setInspLoading(true); - try { - const filters = searchFilters.map((f) => ({ - columnName: f.columnName, - operator: MULTI_VALUE_COLUMNS.includes(f.columnName) ? "contains" : f.operator, - value: f.value, - })); - const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { - page: 1, - size: 500, - dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, - autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setInspections(rows); - setInspCount(rows.length); - } catch { - toast.error("검사기준 조회에 실패했어요"); - } finally { - setInspLoading(false); - } - }, [searchFilters]); - - const fetchDefects = useCallback(async () => { - setDefLoading(true); - try { - const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, { - page: 1, - size: 500, - autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setDefects(rows); - setDefCount(rows.length); - } catch { - toast.error("불량관리 조회에 실패했어요"); - } finally { - setDefLoading(false); - } - }, []); - - const fetchEquipments = useCallback(async () => { - setEqLoading(true); - try { - const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { - page: 1, - size: 500, - autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setEquipments(rows); - setEqCount(rows.length); - } catch { - toast.error("검사장비 조회에 실패했어요"); - } finally { - setEqLoading(false); - } - }, []); - - useEffect(() => { - fetchInspections(); - }, [fetchInspections]); - useEffect(() => { - fetchDefects(); - fetchEquipments(); - }, []); - - /* ───── 클라이언트 필터 ───── */ - const filteredDefects = defKeyword.trim() - ? defects.filter( - (r) => - (r.defect_name || "").toLowerCase().includes(defKeyword.toLowerCase()) || - (r.defect_type || "").toLowerCase().includes(defKeyword.toLowerCase()), - ) - : defects; - - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; - - /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ - const openInspCreate = async () => { - setInspForm({}); - setInspEditMode(false); - setNumberingRuleId(null); - setPreviewCode(null); - setInspModalOpen(true); - try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`); - const ruleData = ruleRes.data; - if (ruleData?.success && ruleData?.data?.ruleId) { - const ruleId = ruleData.data.ruleId; - setNumberingRuleId(ruleId); - const prev = await previewNumberingCode(ruleId); - if (prev.success && prev.data?.generatedCode) { - setPreviewCode(prev.data.generatedCode); - } - } - } catch { /* 채번 규칙 없으면 무시 */ } - }; - const openInspEdit = (row: any) => { - setInspForm({ ...row }); - setInspEditMode(true); - setInspModalOpen(true); - }; - const saveInspection = async () => { - if (!numberingRuleId && !inspForm.inspection_code) { - toast.error("검사코드는 필수예요"); - return; - } - if (!inspForm.inspection_type) { - toast.error("유형을 1개 이상 선택해주세요"); - return; - } - if (!inspForm.inspection_criteria) { - toast.error("검사기준은 필수예요"); - return; - } - if (!inspForm.inspection_item) { - toast.error("검사항목은 필수예요"); - return; - } - if (!inspForm.judgment_criteria) { - toast.error("판단기준은 필수예요"); - return; - } - setInspSaving(true); - try { - let finalCode = inspForm.inspection_code || ""; - if (!inspEditMode && numberingRuleId) { - const allocRes = await allocateNumberingCode(numberingRuleId); - if (allocRes.success && allocRes.data?.generatedCode) { - finalCode = allocRes.data.generatedCode; - } else { - toast.error("채번 코드 할당에 실패했습니다."); - setInspSaving(false); - return; - } - } - if (inspEditMode) { - await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, { - originalData: { id: inspForm.id }, - updatedData: inspForm, - }); - toast.success("검사기준을 수정했어요"); - } else { - await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { - id: crypto.randomUUID(), - ...inspForm, - inspection_code: finalCode, - }); - toast.success("검사기준을 등록했어요"); - } - setInspModalOpen(false); - fetchInspections(); - } catch { - toast.error("저장에 실패했어요"); - } finally { - setInspSaving(false); - } - }; - const deleteInspections = async () => { - if (inspChecked.length === 0) { - toast.error("삭제할 항목을 선택해주세요"); - return; - } - const ok = await confirm("검사기준 삭제", { - description: `선택한 ${inspChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, - }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { - data: inspChecked.map((id) => ({ id })), - }); - toast.success(`${inspChecked.length}건을 삭제했어요`); - setInspChecked([]); - fetchInspections(); - } catch { - toast.error("삭제에 실패했어요"); - } - }; - - /* ═══════════════════ 불량관리 CRUD ═══════════════════ */ - const openDefCreate = async () => { - setDefForm({}); - setDefEditMode(false); - setNumberingRuleId(null); - setPreviewCode(null); - setDefModalOpen(true); - try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`); - const ruleData = ruleRes.data; - if (ruleData?.success && ruleData?.data?.ruleId) { - const ruleId = ruleData.data.ruleId; - setNumberingRuleId(ruleId); - const prev = await previewNumberingCode(ruleId); - if (prev.success && prev.data?.generatedCode) { - setPreviewCode(prev.data.generatedCode); - } - } - } catch { /* 채번 규칙 없으면 무시 */ } - }; - const openDefEdit = (row: any) => { - setDefForm({ ...row }); - setDefEditMode(true); - setDefModalOpen(true); - }; - const saveDefect = async () => { - if (!numberingRuleId && !defForm.defect_code) { - toast.error("불량코드는 필수예요"); - return; - } - if (!defForm.defect_type) { - toast.error("불량유형은 필수예요"); - return; - } - if (!defForm.defect_name) { - toast.error("불량명은 필수예요"); - return; - } - if (!defForm.severity) { - toast.error("심각도는 필수예요"); - return; - } - if (!defForm.defect_content) { - toast.error("불량내용은 필수예요"); - return; - } - if (!defForm.inspection_type) { - toast.error("검사유형을 1개 이상 선택해주세요"); - return; - } - setDefSaving(true); - try { - let finalCode = defForm.defect_code || ""; - if (!defEditMode && numberingRuleId) { - const allocRes = await allocateNumberingCode(numberingRuleId); - if (allocRes.success && allocRes.data?.generatedCode) { - finalCode = allocRes.data.generatedCode; - } else { - toast.error("채번 코드 할당에 실패했습니다."); - setDefSaving(false); - return; - } - } - if (defEditMode) { - await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, { - originalData: { id: defForm.id }, - updatedData: defForm, - }); - toast.success("불량유형을 수정했어요"); - } else { - await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { id: crypto.randomUUID(), ...defForm, defect_code: finalCode }); - toast.success("불량유형을 등록했어요"); - } - setDefModalOpen(false); - fetchDefects(); - } catch { - toast.error("저장에 실패했어요"); - } finally { - setDefSaving(false); - } - }; - const deleteDefects = async () => { - if (defChecked.length === 0) { - toast.error("삭제할 항목을 선택해주세요"); - return; - } - const ok = await confirm("불량유형 삭제", { - description: `선택한 ${defChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, - }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${DEFECT_TABLE}/delete`, { - data: defChecked.map((id) => ({ id })), - }); - toast.success(`${defChecked.length}건을 삭제했어요`); - setDefChecked([]); - fetchDefects(); - } catch { - toast.error("삭제에 실패했어요"); - } - }; - - /* ═══════════════════ 검사장비 CRUD ═══════════════════ */ - const openEqCreate = async () => { - setEqForm({ - calibration_period: "12", - equipment_status: "NORMAL", - }); - setEqEditMode(false); - setNumberingRuleId(null); - setPreviewCode(null); - setEqModalOpen(true); - try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`); - const ruleData = ruleRes.data; - if (ruleData?.success && ruleData?.data?.ruleId) { - const ruleId = ruleData.data.ruleId; - setNumberingRuleId(ruleId); - const prev = await previewNumberingCode(ruleId); - if (prev.success && prev.data?.generatedCode) { - setPreviewCode(prev.data.generatedCode); - } - } else { - // 채번 규칙 없으면 기존 수동 채번 fallback - const maxNum = - equipments - .map((e: any) => e.equipment_code || "") - .filter((c: string) => /^EQP-\d+$/.test(c)) - .map((c: string) => parseInt(c.replace("EQP-", ""), 10)) - .sort((a: number, b: number) => b - a)[0] || 0; - setEqForm((p) => ({ ...p, equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}` })); - } - } catch { - // 채번 규칙 조회 실패 시 기존 수동 채번 fallback - const maxNum = - equipments - .map((e: any) => e.equipment_code || "") - .filter((c: string) => /^EQP-\d+$/.test(c)) - .map((c: string) => parseInt(c.replace("EQP-", ""), 10)) - .sort((a: number, b: number) => b - a)[0] || 0; - setEqForm((p) => ({ ...p, equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}` })); - } - }; - const openEqEdit = (row: any) => { - setEqForm({ ...row }); - setEqEditMode(true); - setEqModalOpen(true); - }; - const saveEquipment = async () => { - if (!numberingRuleId && !eqForm.equipment_code) { - toast.error("장비코드는 필수예요"); - return; - } - if (!eqForm.equipment_name) { - toast.error("장비명은 필수예요"); - return; - } - if (!eqForm.equipment_type) { - toast.error("장비유형은 필수예요"); - return; - } - setEqSaving(true); - try { - let finalCode = eqForm.equipment_code || ""; - if (!eqEditMode && numberingRuleId) { - const allocRes = await allocateNumberingCode(numberingRuleId); - if (allocRes.success && allocRes.data?.generatedCode) { - finalCode = allocRes.data.generatedCode; - } else { - toast.error("채번 코드 할당에 실패했습니다."); - setEqSaving(false); - return; - } - } - if (eqEditMode) { - await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, { - originalData: { id: eqForm.id }, - updatedData: eqForm, - }); - toast.success("검사장비를 수정했어요"); - } else { - await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { id: crypto.randomUUID(), ...eqForm, equipment_code: finalCode }); - toast.success("검사장비를 등록했어요"); - } - setEqModalOpen(false); - fetchEquipments(); - } catch { - toast.error("저장에 실패했어요"); - } finally { - setEqSaving(false); - } - }; - const deleteEquipments = async () => { - if (eqChecked.length === 0) { - toast.error("삭제할 항목을 선택해주세요"); - return; - } - const ok = await confirm("검사장비 삭제", { - description: `선택한 ${eqChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, - }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${EQUIPMENT_TABLE}/delete`, { - data: eqChecked.map((id) => ({ id })), - }); - toast.success(`${eqChecked.length}건을 삭제했어요`); - setEqChecked([]); - fetchEquipments(); - } catch { - toast.error("삭제에 실패했어요"); - } - }; - - /* ═══════════════════ JSX ═══════════════════ */ - return ( -
- {ConfirmDialogComponent} - -
- -
- - - - 검사기준 - - {inspCount} - - - - - 불량관리 - - {defCount} - - - - - 검사장비 - - {eqCount} - - - -
- - {/* ──── 검사기준 탭 ──── */} - -
- - - - - -
- } - /> -
-
- openInspEdit(row)} - showPagination={true} - draggableColumns={false} - columnOrderKey="c16-inspection-main" - /> -
- - - {/* ──── 불량관리 탭 ──── */} - -
-
-
- - setDefKeyword(e.target.value)} - /> -
- - {filteredDefects.length}건 - -
-
- - - -
-
-
- - - - - 0 && defChecked.length === filteredDefects.length} - onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map((r) => r.id) : [])} - /> - - - 불량코드 - - - 불량유형 - - - 불량명 - - - 불량내용 - - - 심각도 - - - 검사유형 - - - 적용대상 - - - 사용여부 - - - 등록일 - - - 관리자 - - - 비고 - - - - - {defLoading ? ( - - - - - - ) : filteredDefects.length === 0 ? ( - - - -

등록된 불량유형이 없어요

-
-
- ) : ( - filteredDefects.map((row) => { - const severityLabel = getCatLabel(DEFECT_TABLE, "severity", row.severity); - const severityColor = - severityLabel === "치명적" - ? "destructive" - : severityLabel === "심각" - ? "destructive" - : severityLabel === "보통" - ? "secondary" - : "outline"; - return ( - - setDefChecked((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id], - ) - } - onDoubleClick={() => openDefEdit(row)} - > - e.stopPropagation()}> - - setDefChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id))) - } - /> - - {row.defect_code || "-"} - - - {getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)} - - - {row.defect_name || "-"} - - {row.defect_content || "-"} - - - - {severityLabel || "-"} - - - -
- {row.inspection_type - ? row.inspection_type - .split(",") - .filter(Boolean) - .map((c: string) => ( - - {getCatLabel(DEFECT_TABLE, "inspection_type", c)} - - )) - : "-"} -
-
- -
- {row.apply_target - ? row.apply_target - .split(",") - .filter(Boolean) - .map((t: string) => ( - - {t} - - )) - : "-"} -
-
- - - {getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"} - - - - {row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")} - - {row.manager_id || "-"} - {row.remarks || "-"} -
- ); - }) - )} -
-
-
-
- - {/* ──── 검사장비 탭 ──── */} - -
-
-
- - setEqKeyword(e.target.value)} - /> -
- - {filteredEquipments.length}건 - -
-
- - - -
-
-
- - - - - 0 && eqChecked.length === filteredEquipments.length} - onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])} - /> - - - 장비코드 - - - 장비명 - - - 장비유형 - - - 모델명 - - - 제조사 - - - 설치장소 - - - 최근교정일 - - - 교정주기(개월) - - - 장비상태 - - - 담당자 - - - - - {eqLoading ? ( - - - - - - ) : filteredEquipments.length === 0 ? ( - - - -

등록된 검사장비가 없어요

-
-
- ) : ( - filteredEquipments.map((row) => { - const statusLabel = getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status); - const statusColor = - statusLabel === "정상" ? "default" : statusLabel === "폐기" ? "destructive" : "secondary"; - return ( - - setEqChecked((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id], - ) - } - onDoubleClick={() => openEqEdit(row)} - > - e.stopPropagation()}> - - setEqChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id))) - } - /> - - {row.equipment_code || "-"} - {row.equipment_name || "-"} - - - {getCatLabel(EQUIPMENT_TABLE, "equipment_type", row.equipment_type) || "-"} - - - {row.model_name || "-"} - {row.manufacturer || "-"} - {row.installation_location || "-"} - {row.last_calibration_date || "-"} - {row.calibration_period ? `${row.calibration_period}개월` : "-"} - - - {statusLabel || "-"} - - - - {userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"} - - - ); - }) - )} -
-
-
-
- -
- - {/* ═══════════════════ 검사기준 모달 ═══════════════════ */} - - - - {inspEditMode ? "검사기준 수정" : "검사기준 등록"} - 검사기준 정보를 입력해주세요 - -
- {/* 검사코드 */} -
- - {!inspEditMode && numberingRuleId ? ( - - ) : inspEditMode ? ( - - ) : ( - setInspForm((p) => ({ ...p, inspection_code: e.target.value }))} - placeholder="검사코드 입력" - /> - )} -
- {/* 유형 (다중선택) */} -
- -
- {(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map((o) => { - const types: string[] = inspForm.inspection_type - ? inspForm.inspection_type.split(",").filter(Boolean) - : []; - const checked = types.includes(o.code); - return ( -
- { - const next = v ? [...types, o.code] : types.filter((t) => t !== o.code); - setInspForm((p) => ({ ...p, inspection_type: next.join(",") })); - }} - /> - -
- ); - })} -
-
- {/* 검사기준 */} -
- - setInspForm((p) => ({ ...p, inspection_criteria: e.target.value }))} - placeholder="검사기준 입력" - /> -
- {/* 기준상세 */} -
- - setInspForm((p) => ({ ...p, criteria_detail: e.target.value }))} - placeholder="기준상세 입력" - /> -
- {/* 검사항목 */} -
- - setInspForm((p) => ({ ...p, inspection_item: e.target.value }))} - placeholder="검사항목 입력" - /> -
- {/* 검사방법 */} -
- - -
- {/* 판단기준 */} -
- - -
- {/* 단위 */} -
- - -
- {/* 적용구분 */} -
- - -
- {/* 관리자 */} -
- - -
- {/* 비고 */} -
- -