diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index c2d9a21f..ff14ba19 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -132,6 +132,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) +import popInventoryRoutes from "./routes/popInventoryRoutes"; // POP 재고 조정/이동 import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 @@ -297,6 +298,7 @@ app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 +app.use("/api/pop/inventory", popInventoryRoutes); // POP 재고 조정/이동 app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index ea9bd7a9..03048d6d 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -924,12 +924,26 @@ export const previewFile = async ( ); res.setHeader("Access-Control-Allow-Credentials", "true"); - // 캐시 헤더 설정 + // Cross-Origin-Resource-Policy: cross-origin 설정 + // helmet 기본값(same-origin)을 오버라이드하여 v1.vexplor.com에서 api.vexplor.com 이미지 로드 허용 + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + + // 파일 크기 및 캐시 헤더 설정 + const stat = fs.statSync(finalPath); + res.setHeader("Content-Length", stat.size); res.setHeader("Cache-Control", "public, max-age=3600"); res.setHeader("Content-Type", mimeType); // 파일 스트림으로 전송 const fileStream = fs.createReadStream(finalPath); + fileStream.on("error", (err) => { + console.error("파일 스트림 오류:", err); + if (!res.headersSent) { + res.status(500).json({ success: false, message: "파일 읽기 오류" }); + } else { + res.end(); + } + }); fileStream.pipe(res); } catch (error) { console.error("파일 미리보기 오류:", error); @@ -1031,9 +1045,20 @@ export const downloadFile = async ( `attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"` ); res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + const stat = fs.statSync(filePath); + res.setHeader("Content-Length", stat.size); // 파일 스트림 전송 const fileStream = fs.createReadStream(filePath); + fileStream.on("error", (err) => { + console.error("파일 스트림 오류:", err); + if (!res.headersSent) { + res.status(500).json({ success: false, message: "파일 읽기 오류" }); + } else { + res.end(); + } + }); fileStream.pipe(res); } catch (error) { console.error("파일 다운로드 오류:", error); @@ -1218,10 +1243,21 @@ export const getFileByToken = async (req: Request, res: Response) => { "Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"` ); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + const stat = fs.statSync(filePath); + res.setHeader("Content-Length", stat.size); res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시 // 파일 스트림 전송 const fileStream = fs.createReadStream(filePath); + fileStream.on("error", (err) => { + console.error("파일 스트림 오류:", err); + if (!res.headersSent) { + res.status(500).json({ success: false, message: "파일 읽기 오류" }); + } else { + res.end(); + } + }); fileStream.pipe(res); } catch (error) { console.error("❌ 토큰 파일 접근 오류:", error); diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts index 1c76246a..b5427659 100644 --- a/backend-node/src/controllers/materialStatusController.ts +++ b/backend-node/src/controllers/materialStatusController.ts @@ -226,11 +226,12 @@ export async function getMaterialStatus( return res.json({ success: true, data: [] }); } - // 4) 재고 조회 (창고/위치별) - const stockPlaceholders = materialIds + // 4) 재고 조회 (창고/위치별) — inventory_stock.item_code는 item_number 기준 + const materialCodes = materialIds.map((id) => materialMap[id].materialCode); + const stockPlaceholders = materialCodes .map((_, i) => `$${i + 1}`) .join(","); - const stockParams: any[] = [...materialIds]; + const stockParams: any[] = [...materialCodes]; let stockParamIdx = materialIds.length + 1; const stockConditions: string[] = [ diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts index cf01f362..25e49186 100644 --- a/backend-node/src/controllers/moldController.ts +++ b/backend-node/src/controllers/moldController.ts @@ -94,7 +94,7 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom mold_code, mold_name, mold_type, category, manufacturer, manufacturing_number, manufacturing_date, cavity_count, shot_count, mold_quantity, base_input_qty, operation_status, - remarks, image_path, memo, + remarks, image_path, memo, warranty_shot_count, } = req.body; if (!mold_code || !mold_name) { @@ -107,15 +107,16 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom id, company_code, mold_code, mold_name, mold_type, category, manufacturer, manufacturing_number, manufacturing_date, cavity_count, shot_count, mold_quantity, base_input_qty, - operation_status, remarks, image_path, memo, writer, created_date - ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW()) + operation_status, remarks, image_path, memo, warranty_shot_count, writer, created_date + ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW()) RETURNING * `; const params = [ companyCode, mold_code, mold_name, mold_type || null, category || null, manufacturer || null, manufacturing_number || null, manufacturing_date || null, cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0, - operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId, + operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, + warranty_shot_count || 0, userId, ]; const result = await query(sql, params); @@ -139,7 +140,7 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom mold_name, mold_type, category, manufacturer, manufacturing_number, manufacturing_date, cavity_count, shot_count, mold_quantity, base_input_qty, operation_status, - remarks, image_path, memo, + remarks, image_path, memo, warranty_shot_count, } = req.body; const sql = ` @@ -153,8 +154,9 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom base_input_qty = COALESCE($10, base_input_qty), operation_status = COALESCE($11, operation_status), remarks = $12, image_path = $13, memo = $14, + warranty_shot_count = $15, updated_date = NOW() - WHERE mold_code = $15 AND company_code = $16 + WHERE mold_code = $16 AND company_code = $17 RETURNING * `; const params = [ @@ -162,7 +164,7 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom manufacturing_number, manufacturing_date, cavity_count, shot_count, mold_quantity, base_input_qty, operation_status, remarks, image_path, memo, - moldCode, companyCode, + warranty_shot_count || 0, moldCode, companyCode, ]; const result = await query(sql, params); diff --git a/backend-node/src/controllers/popInventoryController.ts b/backend-node/src/controllers/popInventoryController.ts new file mode 100644 index 00000000..0c0ef743 --- /dev/null +++ b/backend-node/src/controllers/popInventoryController.ts @@ -0,0 +1,1112 @@ +import { Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { userId: string; companyCode: string; userName?: string }; + body: any; + query: any; +} + +/** + * GET /api/pop/inventory/stock-detail + * 재고 목록 + 품목상세(item_info) + 창고명(warehouse_info) JOIN 조회 + */ +export const getStockDetail = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code, keyword } = req.query; + const conditions = ["ist.company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + // 수량 > 0 인 재고만 + conditions.push("COALESCE(CAST(NULLIF(ist.current_qty, '') AS numeric), 0) > 0"); + + if (warehouse_code && warehouse_code !== "all") { + conditions.push(`ist.warehouse_code = $${idx++}`); + params.push(warehouse_code); + } + + if (keyword) { + conditions.push(`( + ist.item_code ILIKE $${idx} + OR COALESCE(ii.item_name, '') ILIKE $${idx} + OR COALESCE(ii.item_number, '') ILIKE $${idx} + )`); + params.push(`%${keyword}%`); + idx++; + } + + const result = await pool.query( + `SELECT + ist.id, + ist.item_code, + COALESCE(ii.item_name, ist.item_code) AS item_name, + COALESCE(ii.item_number, ist.item_code) AS item_number, + COALESCE(ii.size, '') AS spec, + COALESCE(ii.unit, 'EA') AS unit, + COALESCE(ii.material, '') AS material, + ist.warehouse_code, + COALESCE(wi.warehouse_name, ist.warehouse_code) AS warehouse_name, + COALESCE(ist.location_code, '') AS location_code, + COALESCE(wl.location_name, '') AS location_name, + COALESCE(wl.floor, '') AS floor, + ist.current_qty + FROM inventory_stock ist + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, size, unit, material, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code + LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code + AND ist.company_code = wi.company_code + LEFT JOIN warehouse_location wl ON ist.location_code = wl.location_code + AND ist.company_code = wl.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY COALESCE(ii.item_name, ist.item_code) + LIMIT 500`, + params + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] stock-detail 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/temp-load + * 임시저장 cart_items 불러오기 (cart_type + status='saved' 기준) + */ +export const loadTempCart = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const cartType = req.query.cart_type || "inventory-adjust"; + + const result = await pool.query( + `SELECT id, row_data, row_key, cart_type, status, created_date + FROM cart_items + WHERE company_code = $1 AND cart_type = $2 AND status = 'saved' + ORDER BY created_date`, + [companyCode, cartType] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] temp-load 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * DELETE /api/pop/inventory/temp-clear + * 임시저장 cart_items 삭제 (cart_type 기준) + */ +export const clearTempCart = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const cartType = req.query.cart_type || "inventory-adjust"; + + await pool.query( + `DELETE FROM cart_items WHERE company_code = $1 AND cart_type = $2`, + [companyCode, cartType] + ); + + return res.json({ success: true, message: "임시저장 삭제 완료" }); + + } catch (error: any) { + logger.error("[pop/inventory] temp-clear 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +interface AdjustItem { + stock_id: string; + item_code: string; + warehouse_code: string; + location_code?: string; + system_qty: number; + type: "confirm" | "adjust"; + actual_qty?: number; + reason?: string; + new_warehouse?: string; + new_location?: string; + memo?: string; +} + +/** + * POST /api/pop/inventory/adjust-batch + * 재고 조정 일괄 확정 + * - confirm: 이상없음 (이력만 기록) + * - adjust: 수량 조정 (inventory_stock UPDATE + inventory_history INSERT) + * - 위치불일치: 기존 위치 차감 + 새 위치 추가 + */ +export const adjustBatch = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { items } = req.body as { items: AdjustItem[] }; + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "조정 항목이 없습니다" }); + } + + await client.query("BEGIN"); + + let confirmCount = 0; + let adjustCount = 0; + const results: Array<{ item_code: string; type: string; status: string }> = []; + + for (const item of items) { + // company_code 필터: 멀티테넌시 필수 + const stockCheck = await client.query( + `SELECT id, item_code, warehouse_code, location_code, + COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as current_qty + FROM inventory_stock + WHERE id = $1 AND company_code = $2`, + [item.stock_id, companyCode] + ); + + if (stockCheck.rowCount === 0) { + results.push({ item_code: item.item_code, type: item.type, status: "not_found" }); + continue; + } + + const stock = stockCheck.rows[0]; + const systemQty = parseFloat(stock.current_qty) || 0; + + if (item.type === "confirm") { + // 확인 (이상없음): 이력만 기록, 재고 변경 없음 + await client.query( + `INSERT INTO inventory_history ( + id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, + remark, writer, manager_name, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, $4, + '조정확인', NOW(), '0', $5, + $6, $7, $8, NOW() + )`, + [ + companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(systemQty), + JSON.stringify({ reason: "이상없음", type: "confirm", system_qty: systemQty }), + userId, userName + ] + ); + confirmCount++; + results.push({ item_code: item.item_code, type: "confirm", status: "ok" }); + + } else if (item.type === "adjust") { + const actualQty = item.actual_qty ?? systemQty; + const diff = actualQty - systemQty; + + if (item.reason === "위치불일치" && item.new_warehouse) { + // 위치불일치: 기존 위치에서 차감 + 새 위치에 추가 + // 1. 기존 위치 차감 + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) - $1, 0) AS text), + updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(actualQty), userId, item.stock_id, companyCode] + ); + + // 2. 새 위치에 UPSERT + const newLoc = item.new_location || item.new_warehouse; + const existingNew = 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, stock.item_code, item.new_warehouse, newLoc] + ); + + if (existingNew.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) + $1 AS text), + updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(actualQty), userId, existingNew.rows[0].id, companyCode] + ); + } else { + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, created_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6)`, + [companyCode, stock.item_code, item.new_warehouse, newLoc, String(actualQty), userId] + ); + } + + // 3. 이력 2건 (출발 + 도착) + const remarkJson = JSON.stringify({ + reason: item.reason, type: "adjust", + system_qty: systemQty, actual_qty: actualQty, + from_warehouse: stock.warehouse_code, from_location: stock.location_code, + to_warehouse: item.new_warehouse, to_location: newLoc, + memo: item.memo || "" + }); + + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '조정', NOW(), $5, '0', $6, $7, $8, NOW())`, + [companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(-actualQty), remarkJson, userId, userName] + ); + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '조정', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, stock.item_code, item.new_warehouse, newLoc, + String(actualQty), String(actualQty), remarkJson, userId, userName] + ); + + } else { + // 일반 수량 조정 + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(actualQty), userId, item.stock_id, companyCode] + ); + + // 이력 1건 + const remarkJson = JSON.stringify({ + reason: item.reason || "실사차이", type: "adjust", + system_qty: systemQty, actual_qty: actualQty, diff, + memo: item.memo || "" + }); + + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '조정', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(diff), String(actualQty), remarkJson, userId, userName] + ); + } + + adjustCount++; + results.push({ item_code: item.item_code, type: "adjust", status: "ok" }); + } + } + + await client.query("COMMIT"); + + logger.info("[pop/inventory] adjust-batch 완료", { + companyCode, userId, confirmCount, adjustCount, total: items.length + }); + + return res.json({ + success: true, + message: `확인 ${confirmCount}건, 조정 ${adjustCount}건 처리 완료`, + data: { confirmCount, adjustCount, results } + }); + + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/inventory] adjust-batch 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +/** + * GET /api/pop/inventory/adjust-history + * 재고 조정 이력 조회 + */ +export const getAdjustHistory = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { date_from, date_to, item_code } = req.query; + const conditions = ["company_code = $1", "transaction_type IN ('조정', '조정확인')"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (date_from) { + conditions.push(`transaction_date >= $${idx++}`); + params.push(date_from); + } + if (date_to) { + conditions.push(`transaction_date <= $${idx++}::date + interval '1 day'`); + params.push(date_to); + } + if (item_code) { + conditions.push(`item_code ILIKE $${idx++}`); + params.push(`%${item_code}%`); + } + + const result = await pool.query( + `SELECT id, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, + remark, writer, manager_name, created_date + FROM inventory_history + WHERE ${conditions.join(" AND ")} + ORDER BY transaction_date DESC + LIMIT 100`, + params + ); + + // remark JSON 파싱 + const data = result.rows.map((row: any) => { + let detail: any = {}; + try { detail = JSON.parse(row.remark || "{}"); } catch { /* */ } + return { + ...row, + reason: detail.reason || "", + adjust_type: detail.type || "", + system_qty: detail.system_qty, + actual_qty: detail.actual_qty, + diff: detail.diff, + memo: detail.memo || "", + from_warehouse: detail.from_warehouse, + to_warehouse: detail.to_warehouse, + }; + }); + + return res.json({ success: true, data }); + + } catch (error: any) { + logger.error("[pop/inventory] adjust-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/locations?warehouse_code=XXX + * 특정 창고의 위치 목록 조회 (warehouse_location 테이블) + */ +export const getLocations = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code } = req.query; + if (!warehouse_code) { + return res.status(400).json({ success: false, message: "warehouse_code 필수" }); + } + + const result = await pool.query( + `SELECT id, location_code, location_name, floor, zone, row_num, level_num, + warehouse_code, warehouse_name + FROM warehouse_location + WHERE company_code = $1 AND warehouse_code = $2 + ORDER BY floor, zone, row_num, level_num`, + [companyCode, warehouse_code] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] locations 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/location-lookup?code=XXX + * 위치코드로 창고+위치 정보 조회 (QR 스캔 결과 처리) + */ +export const locationLookup = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { code } = req.query; + if (!code) { + return res.status(400).json({ success: false, message: "code 필수" }); + } + + const result = await pool.query( + `SELECT id, location_code, location_name, floor, zone, row_num, level_num, + warehouse_code, warehouse_name + FROM warehouse_location + WHERE company_code = $1 AND location_code = $2 + LIMIT 1`, + [companyCode, code] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "해당 위치코드를 찾을 수 없습니다" }); + } + + return res.json({ success: true, data: result.rows[0] }); + + } catch (error: any) { + logger.error("[pop/inventory] location-lookup 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * POST /api/pop/inventory/temp-status + * cart_items 상태 일괄 변경 (cart_type + fromStatus → toStatus) + * 개별 건: ids 배열 전달 시 해당 건만 변경 + */ +export const updateCartStatus = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { cart_type, from_status, to_status, ids } = req.body; + if (!cart_type || !to_status) { + return res.status(400).json({ success: false, message: "cart_type, to_status 필수" }); + } + + let result; + if (ids && Array.isArray(ids) && ids.length > 0) { + // 개별 건 상태 변경 + const placeholders = ids.map((_: string, i: number) => `$${i + 4}`).join(", "); + result = await pool.query( + `UPDATE cart_items + SET status = $1, updated_date = NOW() + WHERE company_code = $2 AND cart_type = $3 + AND id IN (${placeholders}) + ${from_status ? "AND status = $" + (ids.length + 4) : ""}`, + from_status + ? [to_status, companyCode, cart_type, ...ids, from_status] + : [to_status, companyCode, cart_type, ...ids] + ); + } else { + // 전체 건 상태 변경 (from_status 기준) + if (!from_status) { + return res.status(400).json({ success: false, message: "전체 변경 시 from_status 필수" }); + } + result = await pool.query( + `UPDATE cart_items + SET status = $1, updated_date = NOW() + WHERE company_code = $2 AND cart_type = $3 AND status = $4`, + [to_status, companyCode, cart_type, from_status] + ); + } + + logger.info("[pop/inventory] temp-status 변경", { + companyCode, cart_type, from_status, to_status, + ids: ids?.length ?? "all", + affected: result.rowCount, + }); + + return res.json({ + success: true, + message: `${result.rowCount}건 상태 변경 완료`, + data: { affected: result.rowCount }, + }); + + } catch (error: any) { + logger.error("[pop/inventory] temp-status 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/process-stock + * 공정 진행 중인 항목의 수량 표시 (양품수량 > 0) + */ +export const getProcessStock = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const result = await pool.query( + `SELECT wop.id, wop.wo_id, wi.work_instruction_no, wop.process_name, wop.seq_no, + COALESCE(wop.input_qty, '0') as input_qty, + COALESCE(wop.good_qty, '0') as good_qty, + wop.item_code, ii.item_name + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON wop.item_code = ii.item_number AND wop.company_code = ii.company_code + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND COALESCE(CAST(NULLIF(wop.good_qty,'') AS numeric), 0) > 0 + ORDER BY wi.work_instruction_no, wop.seq_no`, + [companyCode] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] process-stock 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/item-history?item_code=XXX + * 특정 품목의 재고 이력 조회 + */ +export const getItemHistory = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { item_code } = req.query; + if (!item_code) { + return res.status(400).json({ success: false, message: "item_code 필수" }); + } + + const result = await pool.query( + `SELECT id, item_code, warehouse_code, location_code, transaction_type, + transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date + FROM inventory_history + WHERE company_code = $1 AND item_code = $2 + ORDER BY created_date DESC + LIMIT 50`, + [companyCode, item_code] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory] item-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/process-stock-v2 + * 공정명+설비 필터 기반 품목 리스트 (재고이동 화면용) + * - 필터: process_name, equipment_code (선택) + * - 응답: processNames[], equipments[], processes[] (품목 기준) + * - 대기수량 = N공정 양품 - (N+1)공정 투입합계 + * - 미입고 = 마지막 공정 AND good_qty > 0 AND target_warehouse_id IS NULL + * - 리워크 제외 (is_rework = 'Y' 제외) + */ +export const getProcessStockV2 = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { process_name, equipment_code } = req.query; + + // 1) DISTINCT 공정명 목록 (양품 > 0인 것만) + const processNamesResult = await pool.query( + `SELECT DISTINCT wop.process_name + FROM work_order_process wop + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0 + ORDER BY wop.process_name`, + [companyCode] + ); + + // 2) DISTINCT 설비 목록 (양품 > 0인 것만) + const equipmentsResult = await pool.query( + `SELECT DISTINCT COALESCE(wop.equipment_code, '') AS equipment_code + FROM work_order_process wop + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0 + ORDER BY equipment_code`, + [companyCode] + ); + + // 3) 전체 공정 목록 (양품 > 0, 마스터 행, 리워크 제외) + 필터 적용 + const conditions = [ + "wop.company_code = $1", + "wop.parent_process_id IS NULL", + "(wop.is_rework IS NULL OR wop.is_rework != 'Y')", + "COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0", + ]; + const params: any[] = [companyCode]; + let idx = 2; + + if (process_name) { + conditions.push(`wop.process_name = $${idx++}`); + params.push(process_name); + } + if (equipment_code) { + conditions.push(`COALESCE(wop.equipment_code, '') = $${idx++}`); + params.push(equipment_code); + } + + const processResult = await pool.query( + `SELECT wop.id, wop.wo_id, wop.seq_no, wop.process_name, + COALESCE(wop.equipment_code, '') AS equipment_code, + COALESCE(wop.input_qty, '0') AS input_qty, + COALESCE(wop.good_qty, '0') AS good_qty, + COALESCE(wop.concession_qty, '0') AS concession_qty, + wop.target_warehouse_id, + wop.target_location_code, + wop.status, + wi.work_instruction_no, + COALESCE(ii.item_number, wi.item_id) AS item_number, + COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN ( + SELECT DISTINCT ON (id, company_code) + id, item_number, item_name, company_code + FROM item_info + ORDER BY id, company_code, created_date DESC + ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY wop.process_name, wi.work_instruction_no, CAST(wop.seq_no AS int)`, + params + ); + + const rows = processResult.rows; + + // 작업지시별 공정 그룹핑하여 마지막 공정 판별 + 대기수량 계산 + // wo_id 별로 그룹핑하여 각 공정의 순서 내 마지막인지 확인 + const woProcessMap: Record = {}; + for (const row of rows) { + if (!woProcessMap[row.wo_id]) woProcessMap[row.wo_id] = []; + woProcessMap[row.wo_id].push(row); + } + + // 각 wo_id의 전체 공정 목록 (양품 없는 것 포함) 가져와서 마지막 공정/대기수량 정확히 계산 + const woIds = Object.keys(woProcessMap); + const allProcessMap: Record = {}; + + if (woIds.length > 0) { + const allProcessResult = await pool.query( + `SELECT wop.id, wop.wo_id, wop.seq_no, wop.process_name, + COALESCE(wop.input_qty, '0') AS input_qty, + COALESCE(wop.good_qty, '0') AS good_qty, + COALESCE(wop.concession_qty, '0') AS concession_qty, + wop.target_warehouse_id + FROM work_order_process wop + WHERE wop.wo_id = ANY($1) AND wop.company_code = $2 + AND wop.parent_process_id IS NULL + AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') + ORDER BY wop.wo_id, CAST(wop.seq_no AS int)`, + [woIds, companyCode] + ); + for (const r of allProcessResult.rows) { + if (!allProcessMap[r.wo_id]) allProcessMap[r.wo_id] = []; + allProcessMap[r.wo_id].push(r); + } + } + + // enriched 결과 생성 + const enriched = []; + for (const row of rows) { + const allProcs = allProcessMap[row.wo_id] || []; + const procIdx = allProcs.findIndex((p: any) => p.id === row.id); + const isLastProcess = procIdx === allProcs.length - 1; + + const goodQty = (parseFloat(row.good_qty) || 0) + (parseFloat(row.concession_qty) || 0); + + // 분할 행 양품 합계 + const splitGoodResult = await pool.query( + `SELECT COALESCE(SUM(COALESCE(CAST(NULLIF(good_qty,'') AS numeric),0) + COALESCE(CAST(NULLIF(concession_qty,'') AS numeric),0)), 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 + AND (is_rework IS NULL OR is_rework != 'Y')`, + [row.wo_id, row.seq_no, companyCode] + ); + const totalGood = parseFloat(splitGoodResult.rows[0]?.total_good) || goodQty; + const effectiveGood = totalGood > 0 ? totalGood : goodQty; + + // 다음 공정 투입합계 + let nextInputTotal = 0; + if (!isLastProcess && procIdx >= 0 && procIdx < allProcs.length - 1) { + const nextProc = allProcs[procIdx + 1]; + const nextInputResult = await pool.query( + `SELECT COALESCE(SUM(CAST(NULLIF(input_qty,'') AS numeric)), 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 + AND (is_rework IS NULL OR is_rework != 'Y')`, + [row.wo_id, nextProc.seq_no, companyCode] + ); + nextInputTotal = parseFloat(nextInputResult.rows[0]?.total_input) || 0; + } + + const waitingQty = effectiveGood - nextInputTotal; + const isUnstored = isLastProcess && effectiveGood > 0 && !row.target_warehouse_id; + + enriched.push({ + id: row.id, + wo_id: row.wo_id, + seq_no: row.seq_no, + process_name: row.process_name, + equipment_code: row.equipment_code, + good_qty: String(effectiveGood), + input_qty: row.input_qty, + status: row.status, + target_warehouse_id: row.target_warehouse_id || null, + target_location_code: row.target_location_code || null, + is_last_process: isLastProcess, + is_unstored: isUnstored, + waiting_qty: Math.max(waitingQty, 0), + next_input_total: nextInputTotal, + item_code: row.item_number, + item_name: row.item_name, + work_instruction_no: row.work_instruction_no, + }); + } + + return res.json({ + success: true, + data: { + processNames: processNamesResult.rows.map((r: any) => r.process_name), + equipments: equipmentsResult.rows.map((r: any) => r.equipment_code).filter((e: string) => e !== ''), + processes: enriched, + }, + }); + + } catch (error: any) { + logger.error("[pop/inventory] process-stock-v2 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +interface MoveItem { + item_code: string; + from_warehouse: string; + from_location?: string; + to_warehouse: string; + to_location?: string; + quantity: number; + stock_id?: string; + source_type?: "warehouse" | "process"; + work_order_process_id?: string; +} + +/** + * POST /api/pop/inventory/move-batch + * 재고 이동 일괄 실행 + * - 출발 창고 inventory_stock 차감 + * - 도착 창고 inventory_stock UPSERT (증가) + * - inventory_history 2건 (출발 -수량, 도착 +수량, transaction_type='이동') + */ +export const moveBatch = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { items } = req.body as { items: MoveItem[] }; + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "이동 항목이 없습니다" }); + } + + await client.query("BEGIN"); + + let moveCount = 0; + const results: Array<{ item_code: string; status: string; message?: string }> = []; + + for (const item of items) { + const sourceType = item.source_type || "warehouse"; + + if (sourceType === "process") { + // ===== 공정 → 창고 입고 ===== + if (!item.work_order_process_id || !item.to_warehouse || !item.item_code) { + results.push({ item_code: item.item_code || "", status: "invalid", message: "공정 입고: 필수값 누락" }); + continue; + } + + // 1. work_order_process 조회 (이중 입고 방지) + const procResult = await client.query( + `SELECT id, wo_id, good_qty, concession_qty, target_warehouse_id, seq_no, parent_process_id, is_rework + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [item.work_order_process_id, companyCode] + ); + + if (procResult.rowCount === 0) { + results.push({ item_code: item.item_code, status: "not_found", message: "공정을 찾을 수 없음" }); + continue; + } + + const proc = procResult.rows[0]; + if (proc.target_warehouse_id) { + results.push({ item_code: item.item_code, status: "already_stored", message: "이미 입고 완료" }); + continue; + } + + const goodQty = (parseFloat(proc.good_qty || "0")) + (parseFloat(proc.concession_qty || "0")); + if (goodQty <= 0) { + results.push({ item_code: item.item_code, status: "no_qty", message: "양품 수량 없음" }); + continue; + } + + const moveQty = item.quantity > 0 ? Math.min(item.quantity, goodQty) : goodQty; + const toLocation = item.to_location || ""; + + // 2. inventory_stock UPSERT (도착 창고) + const existingTo = await client.query( + `SELECT id, current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, item.item_code, item.to_warehouse, toLocation] + ); + + if (existingTo.rows.length > 0) { + const toQty = parseFloat(existingTo.rows[0].current_qty) || 0; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, last_in_date = NOW(), updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(toQty + moveQty), userId, existingTo.rows[0].id, companyCode] + ); + } 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, item.item_code, item.to_warehouse, toLocation, String(moveQty), userId] + ); + } + + // 3. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) + const idsToUpdate = [item.work_order_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`, + [id, companyCode, item.to_warehouse, toLocation || null, userId] + ); + } + + // 4. 리워크 마크 해제 + 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`, + [item.work_order_process_id, companyCode] + ); + } + + // 5. inventory_history 1건 (공정 입고) + const toBalance = existingTo.rows.length > 0 + ? (parseFloat(existingTo.rows[0].current_qty) || 0) + moveQty + : moveQty; + + const remarkJson = JSON.stringify({ + type: "process_inbound", + source: "process", + work_order_process_id: item.work_order_process_id, + to_warehouse: item.to_warehouse, + quantity: moveQty, + }); + + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '공정입고', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, item.item_code, item.to_warehouse, toLocation, + String(moveQty), String(toBalance), remarkJson, userId, userName] + ); + + moveCount++; + results.push({ item_code: item.item_code, status: "ok" }); + + } else { + // ===== 창고 → 창고 이동 (기존 로직) ===== + if (!item.item_code || !item.from_warehouse || !item.to_warehouse || !item.quantity || item.quantity <= 0) { + results.push({ item_code: item.item_code, status: "invalid", message: "필수값 누락 또는 수량 오류" }); + continue; + } + + // 1. 출발 창고 재고 확인 + 차감 + const fromConditions = [ + "company_code = $1", + "item_code = $2", + "warehouse_code = $3", + ]; + const fromParams: any[] = [companyCode, item.item_code, item.from_warehouse]; + + if (item.stock_id) { + fromConditions.push(`id = $${fromParams.length + 1}`); + fromParams.push(item.stock_id); + } + + const fromStock = await client.query( + `SELECT id, current_qty, location_code + FROM inventory_stock + WHERE ${fromConditions.join(" AND ")} + LIMIT 1`, + fromParams + ); + + if (fromStock.rowCount === 0) { + results.push({ item_code: item.item_code, status: "not_found", message: "출발 재고 없음" }); + continue; + } + + const fromRow = fromStock.rows[0]; + const currentQty = parseFloat(fromRow.current_qty) || 0; + + if (currentQty < item.quantity) { + results.push({ item_code: item.item_code, status: "insufficient", message: `재고 부족 (현재: ${currentQty})` }); + continue; + } + + // 출발 재고 차감 + const newFromQty = currentQty - item.quantity; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(newFromQty), userId, fromRow.id, companyCode] + ); + + // 2. 도착 창고 UPSERT + const toLocation = item.to_location || ""; + const existingTo = await client.query( + `SELECT id, current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, item.item_code, item.to_warehouse, toLocation] + ); + + if (existingTo.rows.length > 0) { + const toQty = parseFloat(existingTo.rows[0].current_qty) || 0; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(toQty + item.quantity), userId, existingTo.rows[0].id, companyCode] + ); + } else { + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, created_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6)`, + [companyCode, item.item_code, item.to_warehouse, toLocation, String(item.quantity), userId] + ); + } + + // 3. inventory_history 2건 (출발 -수량, 도착 +수량) + const remarkJson = JSON.stringify({ + type: "move", + from_warehouse: item.from_warehouse, + to_warehouse: item.to_warehouse, + quantity: item.quantity, + }); + + // 출발 이력 (마이너스) + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '이동', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, item.item_code, item.from_warehouse, item.from_location || "", + String(-item.quantity), String(newFromQty), remarkJson, userId, userName] + ); + + // 도착 이력 (플러스) + const toBalance = existingTo.rows.length > 0 + ? (parseFloat(existingTo.rows[0].current_qty) || 0) + item.quantity + : item.quantity; + + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '이동', NOW(), $5, $6, $7, $8, $9, NOW())`, + [companyCode, item.item_code, item.to_warehouse, toLocation, + String(item.quantity), String(toBalance), remarkJson, userId, userName] + ); + + moveCount++; + results.push({ item_code: item.item_code, status: "ok" }); + } + } + + await client.query("COMMIT"); + + logger.info("[pop/inventory] move-batch 완료", { + companyCode, userId, moveCount, total: items.length + }); + + return res.json({ + success: true, + message: `${moveCount}건 이동 완료`, + data: { moveCount, results } + }); + + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/inventory] move-batch 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index dabb2f1f..fca4baf2 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -162,6 +162,7 @@ async function generateWorkProcessesForInstruction( planQty: string | null, companyCode: string, userId: string, + batchId?: string | null, ): Promise<{ processes: Array<{ id: string; @@ -171,14 +172,27 @@ async function generateWorkProcessesForInstruction( }>; 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; // 이미 존재 + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리) + if (batchId) { + // 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND batch_id = $3`, + [workInstructionId, companyCode, batchId], + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + return null; // 이미 존재 + } + } else { + // 기존 동작: batch_id 없으면 wo_id 전체로 체크 + 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 (공정 목록 + 공정명) @@ -207,13 +221,13 @@ async function generateWorkProcessesForInstruction( let totalChecklists = 0; for (const rd of routingDetails.rows) { - // 2. work_order_process INSERT + // 2. work_order_process INSERT (batch_id 포함) 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) + status, routing_detail_id, batch_id, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [ companyCode, @@ -229,6 +243,7 @@ async function generateWorkProcessesForInstruction( ? "acceptable" : "waiting", rd.id, + batchId || null, userId, ], ); @@ -358,45 +373,42 @@ export const syncWorkInstructions = async ( userId, }); - // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 - // header에 routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응) + // 미동기화 작업지시 조회 — 다중 품목(detail) 지원 + // 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환) + // 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목 const unsyncedResult = await pool.query( `SELECT wi.id, wi.work_instruction_no, - COALESCE(wi.routing, wid.routing_version_id) AS routing, - COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty, - COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id + wi.routing AS header_routing, + wi.qty AS header_qty, + wi.item_id AS header_item_id FROM work_instruction wi - LEFT JOIN LATERAL ( - SELECT routing_version_id, qty, item_number - FROM work_instruction_detail - WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1 - LIMIT 1 - ) wid ON true WHERE wi.company_code = $1 - AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM work_order_process wop - WHERE wop.wo_id = wi.id AND wop.company_code = $1 + AND ( + -- header routing이 있는데 공정이 아예 없는 경우 + (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 + )) + OR + -- detail에 routing이 있는 경우 (다중 품목 지원) + EXISTS ( + SELECT 1 FROM work_instruction_detail wid + WHERE wid.work_instruction_no = wi.work_instruction_no + AND wid.company_code = $1 + AND wid.routing_version_id IS NOT NULL + AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0 + AND NOT EXISTS ( + SELECT 1 FROM work_order_process wop + WHERE wop.wo_id = wi.id AND wop.company_code = $1 + AND wop.batch_id = wid.item_number + ) + ) )`, [companyCode], ); const unsynced = unsyncedResult.rows; - // header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화) - for (const wi of unsynced) { - await pool.query( - `UPDATE work_instruction SET - routing = COALESCE(routing, $2), - qty = COALESCE(NULLIF(qty, ''), $3), - item_id = COALESCE(item_id, $4), - updated_date = NOW() - WHERE id = $1 AND company_code = $5 - AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, - [wi.id, wi.routing, wi.qty, wi.item_id, companyCode], - ); - } - if (unsynced.length === 0) { return res.json({ success: true, @@ -410,64 +422,178 @@ export const syncWorkInstructions = async ( const details: Array<{ work_instruction_id: string; work_instruction_no: string; + item_number?: 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"); + // detail 목록 조회: routing_version_id가 있고 qty > 0인 것 + const detailResult = await pool.query( + `SELECT wid.item_number, wid.routing_version_id, wid.qty + FROM work_instruction_detail wid + WHERE wid.work_instruction_no = $1 AND wid.company_code = $2 + AND wid.routing_version_id IS NOT NULL + AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0 + ORDER BY wid.created_date ASC`, + [wi.work_instruction_no, companyCode], + ); - const result = await generateWorkProcessesForInstruction( - client, - wi.id, - wi.routing, - wi.qty || null, - companyCode, - userId, + const detailRows = detailResult.rows; + + if (detailRows.length === 0 && wi.header_routing) { + // detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목) + // header에 routing/qty/item_id 자동 보정 + const firstDetail = await pool.query( + `SELECT routing_version_id, qty, item_number + FROM work_instruction_detail + WHERE work_instruction_no = $1 AND company_code = $2 + LIMIT 1`, + [wi.work_instruction_no, companyCode], ); + const wid = firstDetail.rows[0]; + if (wid) { + await pool.query( + `UPDATE work_instruction SET + routing = COALESCE(routing, $2), + qty = COALESCE(NULLIF(qty, ''), $3), + item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), + updated_date = NOW() + WHERE id = $1 AND company_code = $5 + AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, + [wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode], + ); + } - if (!result) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await generateWorkProcessesForInstruction( + client, + wi.id, + wi.header_routing, + wi.header_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", + }); + } else { + 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: 공정 생성 완료 (header routing)", { + work_instruction_no: wi.work_instruction_no, + process_count: result.processes.length, + }); + } + } catch (err: any) { await client.query("ROLLBACK"); - skipped++; + errors++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, - status: "skipped", + status: "error", + error: err.message || "알 수 없는 오류", }); - continue; + logger.error("[pop/production] sync: header routing 오류", { + work_instruction_no: wi.work_instruction_no, + error: err.message, + }); + } finally { + client.release(); } + 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, - }); + // 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number) + // header routing/item_id도 첫 번째 detail 기준 보정 + if (detailRows.length > 0) { + const first = detailRows[0]; + await pool.query( + `UPDATE work_instruction SET + routing = COALESCE(routing, $2), + qty = COALESCE(NULLIF(qty, ''), $3), + item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), + updated_date = NOW() + WHERE id = $1 AND company_code = $5 + AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, + [wi.id, first.routing_version_id, first.qty, first.item_number, companyCode], + ); + } - 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(); + for (const detail of detailRows) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const result = await generateWorkProcessesForInstruction( + client, + wi.id, + detail.routing_version_id, + detail.qty || null, + companyCode, + userId, + detail.item_number, // batch_id = item_number + ); + + if (!result) { + await client.query("ROLLBACK"); + skipped++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + status: "skipped", + }); + continue; + } + + await client.query("COMMIT"); + synced++; + details.push({ + work_instruction_id: wi.id, + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + status: "synced", + process_count: result.processes.length, + }); + + logger.info("[pop/production] sync: 다중품목 공정 생성 완료", { + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + 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, + item_number: detail.item_number, + status: "error", + error: err.message || "알 수 없는 오류", + }); + logger.error("[pop/production] sync: 다중품목 개별 오류", { + work_instruction_no: wi.work_instruction_no, + item_number: detail.item_number, + error: err.message, + }); + } finally { + client.release(); + } } } diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 49fe6e72..cff7ccfa 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -116,12 +116,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) const userId = req.user!.userId; const { menuObjid, ...value } = req.body; - if (!menuObjid) { - return res.status(400).json({ - success: false, - message: "menuObjid는 필수입니다", - }); - } + // menuObjid는 선택사항 — 옵션설정 등 전역 관리 화면에서는 없을 수 있음 logger.info("카테고리 값 추가 요청", { tableName: value.tableName, @@ -134,7 +129,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) value, companyCode, userId, - Number(menuObjid) // ← menuObjid 전달 + menuObjid ? Number(menuObjid) : null ); return res.status(201).json({ diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index a95b08f1..1de120a7 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -86,6 +86,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.source_id, d.routing_version_id AS detail_routing_version_id, COALESCE(itm.item_name, '') AS item_name, + COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, @@ -97,7 +98,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { INNER JOIN work_instruction_detail d ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code LEFT JOIN LATERAL ( - SELECT item_name, size FROM item_info + SELECT item_name, size, type FROM item_info WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 ) itm ON true LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code diff --git a/backend-node/src/routes/popInventoryRoutes.ts b/backend-node/src/routes/popInventoryRoutes.ts new file mode 100644 index 00000000..958a3d5f --- /dev/null +++ b/backend-node/src/routes/popInventoryRoutes.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { adjustBatch, getAdjustHistory, getStockDetail, loadTempCart, clearTempCart, updateCartStatus, getLocations, locationLookup, getProcessStock, getProcessStockV2, getItemHistory, moveBatch } from "../controllers/popInventoryController"; + +const router = Router(); +router.use(authenticateToken); + +// 재고 목록 + 품목상세 JOIN 조회 +router.get("/stock-detail", getStockDetail); + +// 임시저장 불러오기/삭제/상태변경 +router.get("/temp-load", loadTempCart); +router.delete("/temp-clear", clearTempCart); +router.post("/temp-status", updateCartStatus); + +// 재고 조정 일괄 확정 +router.post("/adjust-batch", adjustBatch); + +// 재고 조정 이력 조회 +router.get("/adjust-history", getAdjustHistory); + +// 창고별 위치 목록 조회 +router.get("/locations", getLocations); + +// 위치코드로 창고+위치 조회 (QR 스캔) +router.get("/location-lookup", locationLookup); + +// 공정 진행 중 수량 조회 +router.get("/process-stock", getProcessStock); + +// 공정별 대기수량/미입고 조회 (v2) +router.get("/process-stock-v2", getProcessStockV2); + +// 품목별 재고 이력 조회 +router.get("/item-history", getItemHistory); + +// 재고 이동 일괄 실행 +router.post("/move-batch", moveBatch); + +export default router; diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 6fc07d39..16bc75a2 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -269,7 +269,7 @@ class TableCategoryValueService { value: TableCategoryValue, companyCode: string, userId: string, - menuObjid: number + menuObjid: number | null ): Promise { const pool = getPool(); @@ -286,29 +286,35 @@ class TableCategoryValueService { let duplicateQuery: string; let duplicateParams: any[]; + const menuCondition = menuObjid + ? "AND menu_objid = $4" + : "AND menu_objid IS NULL"; + const baseParams = menuObjid + ? [value.tableName, value.columnName, value.valueCode, menuObjid] + : [value.tableName, value.columnName, value.valueCode]; + if (companyCode === "*") { - // 최고 관리자: 모든 회사에서 중복 체크 duplicateQuery = ` - SELECT value_id + SELECT value_id FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 - AND menu_objid = $4 + ${menuCondition} `; - duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid]; + duplicateParams = baseParams; } else { - // 일반 회사: 자신의 회사에서만 중복 체크 + const companyIdx = menuObjid ? "$5" : "$4"; duplicateQuery = ` - SELECT value_id + SELECT value_id FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 - AND menu_objid = $4 - AND company_code = $5 + ${menuCondition} + AND company_code = ${companyIdx} `; - duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode]; + duplicateParams = [...baseParams, companyCode]; } const duplicateResult = await pool.query(duplicateQuery, duplicateParams); @@ -352,11 +358,11 @@ class TableCategoryValueService { const insertQuery = ` INSERT INTO category_values ( - table_name, column_name, value_code, value_label, value_order, + value_id, table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING + ) VALUES ((SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index e0984a9e..115aef91 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -42,6 +42,7 @@ services: dockerfile: ../docker/deploy/frontend.Dockerfile args: - NEXT_PUBLIC_API_URL=https://api.vexplor.com/api + - SERVER_API_URL=http://backend:3001 container_name: pms-frontend-prod restart: always environment: diff --git a/docker/deploy/frontend.Dockerfile b/docker/deploy/frontend.Dockerfile index 5accb6c4..de8df2a9 100644 --- a/docker/deploy/frontend.Dockerfile +++ b/docker/deploy/frontend.Dockerfile @@ -23,6 +23,10 @@ ENV NEXT_TELEMETRY_DISABLED 1 ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com/api ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +# Next.js rewrites 프록시용 (빌드 시 routes-manifest.json에 포함됨) +ARG SERVER_API_URL=http://backend:3001 +ENV SERVER_API_URL=$SERVER_API_URL + # Build the application ENV DISABLE_ESLINT_PLUGIN=true RUN npm run build diff --git a/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx index fed63cef..587940ce 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx @@ -38,6 +38,30 @@ const fmtDate = (v: any) => { return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10); }; +/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */ +const parseRemark = (remark: string | null | undefined): string => { + if (!remark) return ""; + const trimmed = remark.trim(); + if (!trimmed.startsWith("{")) return trimmed; + try { + const d = JSON.parse(trimmed); + switch (d.type) { + case "move": + return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`; + case "adjust": + return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`; + case "confirm": + return `재고확인 (${d.reason || "이상없음"})`; + case "process_inbound": + return "공정입고"; + default: + return d.reason || d.memo || trimmed; + } + } catch { + return trimmed; + } +}; + export default function InboundOutboundPage() { const { user } = useAuth(); @@ -62,7 +86,6 @@ export default function InboundOutboundPage() { try { const filters: any[] = []; if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter }); - if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter }); for (const f of searchFilters) { if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value }); @@ -81,6 +104,21 @@ export default function InboundOutboundPage() { const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))]; if (itemCodes.length > 0) { try { + // 단위 카테고리 코드→라벨 매핑 로드 + let unitLabelMap: Record = {}; + try { + const catRes = await apiClient.get("/table-categories/item_info/unit/values"); + if (catRes.data?.success && catRes.data.data?.length > 0) { + const flatten = (vals: any[]) => { + for (const v of vals) { + unitLabelMap[v.valueCode] = v.valueLabel; + if (v.children?.length) flatten(v.children); + } + }; + flatten(catRes.data.data); + } + } catch { /* skip */ } + const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: itemCodes.length + 10, dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, @@ -89,7 +127,8 @@ export default function InboundOutboundPage() { const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const map: Record = {}; for (const i of items) { - if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" }; + const rawUnit = i.unit || ""; + if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit }; } setItemMap(map); } catch { /* skip */ } @@ -124,7 +163,7 @@ export default function InboundOutboundPage() { } finally { setLoading(false); } - }, [searchFilters, typeFilter, categoryFilter]); + }, [searchFilters, typeFilter]); useEffect(() => { fetchData(); }, [fetchData]); @@ -132,20 +171,26 @@ export default function InboundOutboundPage() { const categoryOptions = useMemo(() => { const set = new Set(); - data.forEach((r) => { if (r.remark) set.add(r.remark); }); + data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); }); return Array.from(set).sort(); }, [data]); // ════════ 그룹핑 ════════ + // 카테고리 필터 (클라이언트) + const filteredData = useMemo(() => { + if (categoryFilter === "all") return data; + return data.filter((r) => parseRemark(r.remark) === categoryFilter); + }, [data, categoryFilter]); + const groupedData = useMemo(() => { - if (groupBy === "none") return data; + if (groupBy === "none") return filteredData; const groups = new Map(); - for (const row of data) { + for (const row of filteredData) { let key: string; switch (groupBy) { case "transaction_type": key = row.transaction_type || "미지정"; break; - case "remark": key = row.remark || "미지정"; break; + case "remark": key = parseRemark(row.remark) || "미지정"; break; case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break; case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break; default: key = row[groupBy] || "미지정"; @@ -191,7 +236,7 @@ export default function InboundOutboundPage() { const rows = data.map((r, i) => ({ No: i + 1, 입출고구분: r.transaction_type || "", - 카테고리: r.remark || "", + 카테고리: parseRemark(r.remark), 처리일자: fmtDate(r.transaction_date), 창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "", 위치: r.location_code || "", @@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
입출고 내역 - {data.length}건 + {filteredData.length}건
setMoldForm((prev) => ({ ...prev, image_path: v }))} + tableName="mold_mng" + recordId={moldForm.id || ""} + columnName="image_path" + height="h-32" />
diff --git a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx index ad6622da..7649d280 100644 --- a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx @@ -142,7 +142,7 @@ export default function SubcontractorItemPage() { try { const filters: any[] = []; if (outsourcingDivisionCode) { - filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode }); + filters.push({ columnName: "division", operator: "contains", value: outsourcingDivisionCode }); } if (searchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword }); diff --git a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx index 934c03c5..46b78a03 100644 --- a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx @@ -141,6 +141,12 @@ export default function SubcontractorManagementPage() { if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + for (const col of ["division", "unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); + } catch { /* skip */ } + } setCategoryOptions(optMap); const priceOpts: Record = {}; @@ -413,11 +419,12 @@ export default function SubcontractorManagementPage() { }); const allItems = res.data?.data?.data || res.data?.data?.rows || []; const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number)); - const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T"; + const outsourcingCode = categoryOptions["item_division"]?.find((o) => o.label === "외주관리")?.code; setItemSearchResults(allItems.filter((item: any) => { if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false; + if (!outsourcingCode) return true; const div = item.division || ""; - return div.includes(OUTSOURCING_CODE) || div.includes("외주"); + return div.includes(outsourcingCode); })); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -1176,8 +1183,8 @@ export default function SubcontractorManagementPage() { {item.item_number} {item.item_name} {item.size} - {item.material} - {item.unit} + {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} + {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit} ))} @@ -1221,7 +1228,7 @@ export default function SubcontractorManagementPage() { {/* 품목 헤더 */}
{idx + 1}. {item.item_name || itemKey}
-
{itemKey} | {item.size || ""} | {item.unit || ""}
+
{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}
diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index 65e83cf2..ee095229 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -309,6 +309,8 @@ export default function BomManagementPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사용자 맵 (userId → userName) + const [userMap, setUserMap] = useState>({}); // 트리 편집 state const [editingTree, setEditingTree] = useState([]); @@ -433,6 +435,16 @@ export default function BomManagementPage() { } catch {} }; loadCategories(); + // 사용자 목록 로드 + apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => { + const users = res.data?.data || res.data || []; + const map: Record = {}; + for (const u of users) { + const id = u.userId || u.user_id || u.id; + if (id) map[id] = u.userName || u.user_name || u.name || id; + } + setUserMap(map); + }).catch(() => {}); }, []); // ─── BOM 상세 로드 ──────────────────────────── @@ -1802,7 +1814,7 @@ export default function BomManagementPage() { {/* 비고 */} {isVirtualRoot ? "-" : (node.remark || "-")} {/* 작성자 */} - {isVirtualRoot ? "-" : (node.writer || "-")} + {isVirtualRoot ? "-" : (userMap[node.writer] || node.writer || "-")} {/* 수정일시 */} {isVirtualRoot ? "-" : (node.updated_date ? new Date(node.updated_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "-")} diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index 50be10ee..38b01658 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() { }; return ( - +
- + 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" /> - + 품목코드 품목명 {ts.visibleColumns.map((col) => ( - + {col.label} ))} diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx index 08ace7ec..ef7c2a39 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -349,7 +349,14 @@ export default function WorkInstructionPage() { const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0); return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100)); }; - const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; }; + const getProgressLabel = (o: any) => { + const p = getProgress(o); + if (o.progress_status) { + const map: Record = { completed: "완료", in_progress: "진행중", pending: "대기" }; + return map[o.progress_status] || o.progress_status; + } + if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; + }; const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize)); const getDisplayNo = (o: any) => { diff --git a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx index 1bc3bc88..143a84a9 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx @@ -15,9 +15,12 @@ import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, ClipboardList, Pencil, Search, X, Package, ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, - Settings2, + Settings2, GripVertical, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { apiClient } from "@/lib/api/client"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; @@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [ { key: "memo", label: "메모" }, ]; +const MODAL_DETAIL_COLUMNS = [ + { key: "item_code", label: "품번", width: "w-[120px]" }, + { key: "item_name", label: "품명", width: "w-[120px]" }, + { key: "supplier", label: "공급업체", width: "w-[150px]" }, + { key: "spec", label: "규격", width: "w-[80px]" }, + { key: "unit", label: "단위", width: "w-[60px]" }, + { key: "order_qty", label: "발주수량", width: "w-[90px]" }, + { key: "received_qty", label: "입고수량", width: "w-[90px]" }, + { key: "remain_qty", label: "잔량", width: "w-[80px]" }, + { key: "unit_price", label: "단가", width: "w-[100px]" }, + { key: "amount", label: "금액", width: "w-[100px]" }, + { key: "due_date", label: "납기일", width: "w-[160px]" }, + { key: "memo", label: "메모", width: "w-[120px]" }, +]; + +const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16"; + +function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + return ( + +
+ + {col.label} +
+
+ ); +} + export default function PurchaseOrderPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); @@ -121,8 +167,43 @@ export default function PurchaseOrderPage() { // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); + // 모달 품목 테이블 컬럼 순서 (드래그 재정렬) + const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS); + const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + + useEffect(() => { + const saved = localStorage.getItem(MODAL_COL_ORDER_KEY); + if (saved) { + try { + const order = JSON.parse(saved) as string[]; + const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS; + const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key)); + setModalColumns([...reordered, ...remaining]); + } catch { /* skip */ } + } + }, []); + + const handleModalDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setModalColumns((prev) => { + const oldIndex = prev.findIndex((c) => c.key === active.id); + const newIndex = prev.findIndex((c) => c.key === over.id); + const next = arrayMove(prev, oldIndex, newIndex); + localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key))); + return next; + }); + }; + const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소"; + const visibleModalColumns = useMemo(() => { + return modalColumns.filter((col) => { + if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false; + return true; + }); + }, [modalColumns, masterForm.input_mode]); + // 카테고리 로드 useEffect(() => { const loadCategories = async () => { @@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() { ) : (
-
- - - {!isReadOnly && } - 품번 - 품명 - {masterForm.input_mode === "itemFirst" && ( - 공급업체 - )} - 규격 - 단위 - 발주수량 - 입고수량 - 잔량 - 단가 - 금액 - 납기일 - 메모 - - - - {detailRows.map((row, idx) => ( - - {!isReadOnly && ( - - - - )} - {row.item_code} - {row.item_name} - {masterForm.input_mode === "itemFirst" && ( - - {isReadOnly ? ( - {(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"} - ) : ( - - )} - - )} - {row.spec} - {row.unit} - - {isReadOnly ? ( - {row.order_qty ? Number(row.order_qty).toLocaleString() : ""} - ) : ( - updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" /> + +
+ + c.key)} strategy={horizontalListSortingStrategy}> + + {!isReadOnly && } + {visibleModalColumns.map((col) => ( + + ))} + + + + + {detailRows.map((row, idx) => ( + + {!isReadOnly && ( + + + )} - - {row.received_qty ? Number(row.received_qty).toLocaleString() : "0"} - {row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"} - - {isReadOnly ? ( - {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} - ) : ( - updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" /> - )} - - {row.amount ? Number(row.amount).toLocaleString() : ""} - - {isReadOnly ? ( - {row.due_date} - ) : ( - updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> - )} - - - {isReadOnly ? ( - {row.memo} - ) : ( - updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> - )} - - - ))} - -
+ {visibleModalColumns.map((col) => { + switch (col.key) { + case "item_code": + return {row.item_code}; + case "item_name": + return {row.item_name}; + case "supplier": + return ( + + {isReadOnly ? ( + {(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"} + ) : ( + + )} + + ); + case "spec": + return {row.spec}; + case "unit": + return {row.unit}; + case "order_qty": + return ( + + {isReadOnly ? ( + {row.order_qty ? Number(row.order_qty).toLocaleString() : ""} + ) : ( + updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" /> + )} + + ); + case "received_qty": + return {row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}; + case "remain_qty": + return {row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}; + case "unit_price": + return ( + + {isReadOnly ? ( + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + ) : ( + updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" /> + )} + + ); + case "amount": + return {row.amount ? Number(row.amount).toLocaleString() : ""}; + case "due_date": + return ( + + {isReadOnly ? ( + {row.due_date} + ) : ( + updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> + )} + + ); + case "memo": + return ( + + {isReadOnly ? ( + {row.memo} + ) : ( + updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> + )} + + ); + default: + return ; + } + })} + + ))} + + +
)} diff --git a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx index 7f211a88..ce6e8198 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx @@ -9,23 +9,36 @@ * 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블) */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Badge } from "@/components/ui/badge"; -import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Search, X, Settings2, Package } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { ImageUpload } from "@/components/common/ImageUpload"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Search, X, Settings2, Package, + ChevronRight, ChevronDown, Coins, GripVertical, Check, ChevronsUpDown, +} from "lucide-react"; +import { + DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; import { exportToExcel } from "@/lib/utils/excelExport"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { useTableSettings } from "@/hooks/useTableSettings"; @@ -36,6 +49,121 @@ const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "supplier_item_mapping"; const SUPPLIER_TABLE = "supplier_mng"; +// 검색 가능한 카테고리 콤보박스 +function CategoryCombobox({ options, value, onChange, placeholder }: { + options: { code: string; label: string }[]; + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const selected = options.find((o) => o.code === value); + return ( + + + + + + + + + 검색 결과가 없어요 + + {options.map((opt) => ( + { onChange(opt.code); setOpen(false); }}> + + {opt.label} + + ))} + + + + + + ); +} + +// 다중 선택 카테고리 콤보박스 +function MultiCategoryCombobox({ options, value, onChange, placeholder }: { + options: { code: string; label: string }[]; + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : []; + const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean); + + const toggle = (code: string) => { + const next = selectedCodes.includes(code) + ? selectedCodes.filter((c) => c !== code) + : [...selectedCodes, code]; + onChange(next.join(",")); + }; + + return ( + + + + + + + + + 검색 결과가 없어요 + + {options.map((opt) => ( + toggle(opt.code)}> + + {opt.label} + + ))} + + + + + + ); +} + +const FORM_FIELDS = [ + { key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" }, + { key: "item_name", label: "품명", type: "text", required: true }, + { key: "division", label: "관리품목", type: "multi-category" }, + { key: "type", label: "품목구분", type: "category" }, + { key: "size", label: "규격", type: "text" }, + { key: "unit", label: "단위", type: "category" }, + { key: "material", label: "재질", type: "category" }, + { key: "status", label: "상태", type: "category" }, + { key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" }, + { key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" }, + { key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" }, + { key: "inventory_unit", label: "재고단위", type: "category" }, + { key: "selling_price", label: "판매가격", type: "text" }, + { key: "standard_price", label: "기준단가", type: "text" }, + { key: "currency_code", label: "통화", type: "category" }, + { key: "user_type01", label: "대분류", type: "category" }, + { key: "user_type02", label: "중분류", type: "category" }, + { key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" }, + { key: "image", label: "품목 이미지", type: "image" }, + { key: "meno", label: "메모", type: "textarea" }, +] as const; + +const CATEGORY_COLUMNS = [ + "division", "type", "unit", "material", "status", + "inventory_unit", "currency_code", "user_type01", "user_type02", +]; + // 숫자 포맷 헬퍼 const formatNum = (val: any): string => { if (val === null || val === undefined || val === "") return ""; @@ -48,30 +176,63 @@ const ITEM_GRID_COLUMNS = [ { key: "item_name", label: "품명" }, { key: "size", label: "규격" }, { key: "unit", label: "단위" }, - { key: "standard_price", label: "기준단가" }, - { key: "standard_price", label: "구매단가" }, + { key: "standard_price", label: "기준단가/구매단가" }, { key: "currency_code", label: "통화" }, { key: "status", label: "상태" }, ]; +function SortableMappingRow({ id, children }: { id: string; children: React.ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : 1, + }; + return ( +
+
+ +
+ {children} +
+ ); +} + export default function PurchaseItemPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); const ts = useTableSettings("c16-purchase-item", ITEM_TABLE, ITEM_GRID_COLUMNS); + const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); // 좌측: 품목 const [items, setItems] = useState([]); + const [rawItems, setRawItems] = useState([]); const [itemLoading, setItemLoading] = useState(false); const [itemCount, setItemCount] = useState(0); const [selectedItemId, setSelectedItemId] = useState(null); + // 품목 등록/수정 모달 (item-info 스타일) + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editId, setEditId] = useState(null); + const [formData, setFormData] = useState>({}); + + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); // 우측: 공급업체 const [supplierItems, setSupplierItems] = useState([]); + const [supplierGroups, setSupplierGroups] = useState>({}); const [supplierLoading, setSupplierLoading] = useState(false); const [supplierCheckedIds, setSupplierCheckedIds] = useState([]); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [collapsedPriceCards, setCollapsedPriceCards] = useState>(new Set()); // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); @@ -84,13 +245,12 @@ export default function PurchaseItemPage() { const [suppSearchLoading, setSuppSearchLoading] = useState(false); const [suppCheckedIds, setSuppCheckedIds] = useState>(new Set()); - // 품목 수정 모달 - const [editItemOpen, setEditItemOpen] = useState(false); - const [editItemForm, setEditItemForm] = useState>({}); const [saving, setSaving] = useState(false); // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); // 공급업체 상세 입력 모달 (공급업체 품번/품명 + 단가) @@ -118,12 +278,14 @@ export default function PurchaseItemPage() { } return result; }; - for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { - try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); - if (res.data?.success) optMap[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } + await Promise.all( + CATEGORY_COLUMNS.map(async (col) => { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) optMap[col] = flatten(res.data.data); + } catch { /* skip */ } + }) + ); setCategoryOptions(optMap); // 단가 카테고리 @@ -150,8 +312,11 @@ export default function PurchaseItemPage() { try { const filters: { columnName: string; operator: string; value: any }[] = []; - // 구매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭) - filters.push({ columnName: "division", operator: "contains", value: "s" }); + // 구매관리 division 필터: 카테고리에서 "구매관리" 라벨의 코드를 찾아서 필터링 + const purchaseCode = categoryOptions["division"]?.find((o) => o.label === "구매관리")?.code; + if (purchaseCode) { + filters.push({ columnName: "division", operator: "contains", value: purchaseCode }); + } // DynamicSearchFilter에서 전달된 필터 추가 for (const f of searchFilters) { @@ -159,15 +324,15 @@ export default function PurchaseItemPage() { } const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 5000, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; - const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + setRawItems(raw); const data = raw.map((r: any) => { const converted = { ...r }; - for (const col of CATS) { + for (const col of CATEGORY_COLUMNS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } return converted; @@ -184,12 +349,265 @@ export default function PurchaseItemPage() { useEffect(() => { fetchItems(); }, [fetchItems]); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + const parsePreviewIntoParts = (previewCode: string, rule: any) => { + if (!previewCode || !rule?.parts) return []; + const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const globalSep = rule.separator || ""; + + const partMeta = sorted.map((part: any, idx: number) => { + const sep = idx < sorted.length - 1 + ? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep) + : ""; + const config = part.autoConfig || {}; + if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType }; + switch (part.partType) { + case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" }; + case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" }; + case "date": { + const now = new Date(); + const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0"); + const fmt = config.dateFormat || "YYYYMMDD"; + const map: Record = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d }; + return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" }; + } + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + let remaining = previewCode; + const results: { value: string; isManual: boolean; separator: string }[] = []; + + for (let i = 0; i < partMeta.length; i++) { + const meta = partMeta[i]; + const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null; + + if (meta.isManual) { + const markerIdx = remaining.indexOf("____"); + if (markerIdx >= 0) { + remaining = remaining.substring(markerIdx + 4); + if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length); + } + results.push({ value: "", isManual: true, separator: meta.sep }); + continue; + } + + if (meta.known) { + const valIdx = remaining.indexOf(meta.value); + if (valIdx >= 0) { + remaining = remaining.substring(valIdx + meta.value.length); + if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length); + } + results.push({ value: meta.value, isManual: false, separator: meta.sep }); + } else { + let endIdx = remaining.length; + if (meta.sep) { + if (nextMeta) { + if (nextMeta.known && nextMeta.value) { + const patIdx = remaining.indexOf(meta.sep + nextMeta.value); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + const patIdx = remaining.indexOf(meta.sep + "____"); + if (patIdx >= 0) endIdx = patIdx; + } else { + const sepIdx = remaining.indexOf(meta.sep); + if (sepIdx >= 0) endIdx = sepIdx; + } + } + } else if (nextMeta) { + if (nextMeta.known && nextMeta.value) { + const valIdx = remaining.indexOf(nextMeta.value); + if (valIdx >= 0) endIdx = valIdx; + } else if (nextMeta.isManual) { + const markerIdx = remaining.indexOf("____"); + if (markerIdx >= 0) endIdx = markerIdx; + } + } + const extracted = remaining.substring(0, endIdx); + remaining = remaining.substring(endIdx); + if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length); + results.push({ value: extracted, isManual: false, separator: meta.sep }); + } + } + return results; + }; + + // 파트 값으로부터 전체 코드 조합 + const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => { + return parts.map((p, idx) => { + const val = p.isManual ? manualVal : p.value; + const sep = idx < parts.length - 1 ? p.separator : ""; + return val + sep; + }).join(""); + }; + + // 채번 미리보기 + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { + try { + setIsNumberingLoading(true); + + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } + } + + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; + } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 등록 모달 열기 + const openRegisterModal = async () => { + setFormData({}); + setManualInputValue(""); + setNumberingParts([]); + setIsEditMode(false); + setEditId(null); + setIsModalOpen(true); + const result = await loadNumberingPreview({}); + if (result.code) { + const hasManual = result.parts.some(p => p.isManual); + const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code; + setFormData(prev => ({ ...prev, item_number: displayCode })); + } + }; + + // 수정 모달 열기 + const openEditModal = (item: any) => { + const raw = rawItems.find((r) => r.id === item.id) || item; + setFormData({ ...raw }); + setManualInputValue(""); + setNumberingParts([]); + setIsEditMode(true); + setEditId(item.id); + setIsModalOpen(true); + }; + + // 카테고리 변경 시 채번 preview 재호출 + useEffect(() => { + if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; + + const hasCategoryPart = numberingRule?.parts?.some( + (p: any) => p.partType === "category" && p.generationMethod === "auto" + ); + if (!hasCategoryPart) return; + + const timer = setTimeout(async () => { + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } + }, 300); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...CATEGORY_COLUMNS.map(col => formData[col])]); + + // 수동 입력값 변경 시 preview 갱신 + useEffect(() => { + if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; + if (!numberingParts.some(p => p.isManual)) return; + + const timer = setTimeout(async () => { + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + + // 저장 (등록 또는 수정) + const handleSave = async () => { + if (!formData.item_name) { + toast.error("품명은 필수 입력이에요."); + return; + } + setSaving(true); + try { + if (isEditMode && editId) { + const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; + await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { + originalData: { id: editId }, + updatedData: updateFields, + }); + toast.success("수정되었어요."); + } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual && manualInputValue + ? manualInputValue + : undefined; + + const allocRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/allocate`, + { formData, userInputCode } + ); + + if (allocRes.data?.success && allocRes.data?.data?.generatedCode) { + finalItemNumber = allocRes.data.data.generatedCode; + } + } catch (err) { + console.error("채번 할당 실패:", err); + toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요."); + setSaving(false); + return; + } + } + + const { id, created_date, updated_date, ...insertFields } = formData; + await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); + toast.success("등록되었어요."); + } + setIsModalOpen(false); + fetchItems(); + } catch (err: any) { + console.error("저장 실패:", err); + toast.error(err.response?.data?.message || "저장에 실패했어요."); + } finally { + setSaving(false); + } + }; + // 선택된 품목 const selectedItem = items.find((i) => i.id === selectedItemId); // 우측: 공급업체 목록 조회 useEffect(() => { - if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierCheckedIds([]); return; } + if (!selectedItem?.item_number) { + setSupplierItems([]); + setSupplierGroups({}); + setSupplierCheckedIds([]); + return; + } setSupplierCheckedIds([]); const itemKey = selectedItem.item_number; const fetchSupplierItems = async () => { @@ -200,6 +618,7 @@ export default function PurchaseItemPage() { page: 1, size: 500, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, autoFilter: true, + sort: { columnName: "created_date", order: "asc" }, }); const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; @@ -234,36 +653,57 @@ export default function PurchaseItemPage() { } catch { /* skip */ } } - // 4. 공급업체별 중복 제거 + 오늘 날짜 기준 단가 매칭 + // 4. 공급업체별 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트 const priceResolve = (col: string, code: string) => { if (!code) return ""; return priceCategoryOptions[col]?.find((o: any) => o.code === code)?.label || code; }; const today = new Date().toISOString().split("T")[0]; const seenCustIds = new Set(); - const sortedMappings = [...mappings].sort((a: any, b: any) => (a.supplier_id || "").localeCompare(b.supplier_id || "")); + const grouped: Record = {}; + const flatItems: any[] = []; - setSupplierItems(sortedMappings.map((m: any) => { + for (const m of mappings) { const custKey = m.supplier_id || ""; - const isFirstOfGroup = !seenCustIds.has(custKey); - if (custKey) seenCustIds.add(custKey); + if (seenCustIds.has(custKey)) continue; // 공급업체당 첫 매핑만 마스터 + seenCustIds.add(custKey); - const custPriceList = allPrices.filter((p: any) => p.supplier_id === custKey); + const custInfo = custMap[custKey] || {}; + const custPriceList = allPrices + .filter((p: any) => p.supplier_id === custKey) + .sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || "")); const todayPrice = custPriceList.find((p: any) => (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today) ) || custPriceList[0] || {}; - return { + const masterRow = { ...m, - supplier_code: isFirstOfGroup ? custKey : "", - supplier_name: isFirstOfGroup ? (custMap[custKey]?.supplier_name || "") : "", + supplier_code: custKey, + supplier_name: custInfo.supplier_name || "", supplier_item_code: m.supplier_item_code || "", supplier_item_name: m.supplier_item_name || "", + base_price_type: priceResolve("base_price_type", todayPrice.base_price_type || ""), base_price: todayPrice.base_price || "", + discount_type: priceResolve("discount_type", todayPrice.discount_type || ""), + discount_value: todayPrice.discount_value || "", calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "", currency_code: priceResolve("currency_code", todayPrice.currency_code || ""), }; - })); + + // 단가 리스트 (라벨 변환) + const priceDetails = custPriceList.map((p: any) => ({ + ...p, + base_price_type_label: priceResolve("base_price_type", p.base_price_type || ""), + discount_type_label: priceResolve("discount_type", p.discount_type || ""), + currency_label: priceResolve("currency_code", p.currency_code || ""), + is_current: (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today), + })); + + grouped[custKey] = { master: masterRow, details: priceDetails }; + flatItems.push(masterRow); + } + setSupplierGroups(grouped); + setSupplierItems(flatItems); } catch (err) { console.error("공급업체 조회 실패:", err); } finally { @@ -274,7 +714,7 @@ export default function PurchaseItemPage() { }, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps // 공급업체 검색 - const searchSuppliers = async () => { + const searchSuppliers = useCallback(async () => { setSuppSearchLoading(true); try { const filters: any[] = []; @@ -289,7 +729,14 @@ export default function PurchaseItemPage() { const existing = new Set(supplierItems.map((c: any) => c.supplier_id || c.supplier_code)); setSuppSearchResults(all.filter((c: any) => !existing.has(c.supplier_code))); } catch { /* skip */ } finally { setSuppSearchLoading(false); } - }; + }, [suppSearchKeyword, supplierItems]); + + // 실시간 검색 (2글자 이상) + useEffect(() => { + if (!suppSelectOpen) return; + if (suppSearchKeyword.length > 0 && suppSearchKeyword.length < 2) return; + searchSuppliers(); + }, [suppSearchKeyword, suppSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps // 공급업체 선택 → 상세 모달로 이동 const goToSuppDetail = () => { @@ -329,6 +776,17 @@ export default function PurchaseItemPage() { })); }; + const handleMappingDragEnd = (custKey: string, event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setSuppMappings((prev) => { + const arr = [...(prev[custKey] || [])]; + const oldIdx = arr.findIndex((r) => r._id === active.id); + const newIdx = arr.findIndex((r) => r._id === over.id); + return { ...prev, [custKey]: arrayMove(arr, oldIdx, newIdx) }; + }); + }; + const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => { setSuppMappings((prev) => ({ ...prev, @@ -402,25 +860,53 @@ export default function PurchaseItemPage() { if (found) custInfo = found; } catch { /* skip */ } - const mappingRows = [{ - _id: `m_existing_${row.id}`, - supplier_item_code: row.supplier_item_code || "", - supplier_item_name: row.supplier_item_name || "", - }].filter((m) => m.supplier_item_code || m.supplier_item_name); + // 매핑 전체 조회 + let mappingRows: any[] = []; + try { + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "supplier_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + ]}, autoFilter: true, + sort: { columnName: "created_date", order: "asc" }, + }); + const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + mappingRows = allMappings + .filter((m: any) => m.supplier_item_code || m.supplier_item_name) + .map((m: any) => ({ + _id: `m_existing_${m.id}`, + supplier_item_code: m.supplier_item_code || "", + supplier_item_name: m.supplier_item_name || "", + })); + } catch { /* skip */ } - const priceRows = [{ - _id: `p_existing_${row.id}`, - start_date: row.start_date || "", - end_date: row.end_date || "", - currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", - base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", - base_price: row.base_price ? String(row.base_price) : "", - discount_type: row.discount_type || "", - discount_value: row.discount_value ? String(row.discount_value) : "", - rounding_type: row.rounding_type || "", - rounding_unit_value: row.rounding_unit_value || "", - calculated_price: row.calculated_price ? String(row.calculated_price) : "", - }].filter((p) => p.base_price || p.start_date); + // 단가 전체 조회 + let priceRows: any[] = []; + try { + const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "supplier_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + ]}, autoFilter: true, + }); + const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || []) + .sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || "")); + priceRows = allPriceData.map((p: any) => ({ + _id: `p_existing_${p.id}`, + start_date: p.start_date ? String(p.start_date).split("T")[0] : "", + end_date: p.end_date ? String(p.end_date).split("T")[0] : "", + currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: p.base_price ? String(p.base_price) : "", + discount_type: p.discount_type || "", + discount_value: p.discount_value ? String(p.discount_value) : "", + rounding_type: p.rounding_type || "", + rounding_unit_value: p.rounding_unit_value || "", + calculated_price: p.calculated_price ? String(p.calculated_price) : "", + })); + } catch { /* skip */ } if (priceRows.length === 0) { priceRows.push({ @@ -447,45 +933,102 @@ export default function PurchaseItemPage() { const mappingRows = suppMappings[custKey] || []; if (isEditingExisting && editSuppData?.id) { - await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { - originalData: { id: editSuppData.id }, - updatedData: { - supplier_item_code: mappingRows[0]?.supplier_item_code || "", - supplier_item_name: mappingRows[0]?.supplier_item_name || "", - }, - }); + // 기존 매핑 조회 + let existingMaps: any[] = []; + try { + const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "supplier_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem.item_number }, + ]}, autoFilter: true, + sort: { columnName: "created_date", order: "asc" }, + }); + existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || []; + } catch { /* skip */ } - // 기존 prices 삭제 후 재등록 + // 매핑 upsert: 인덱스 기반 + const usedExistingIds = new Set(); + let firstMappingId: string | null = editSuppData.id; + for (let mi = 0; mi < mappingRows.length; mi++) { + const existMap = existingMaps[mi]; + if (existMap) { + await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { + originalData: { id: existMap.id }, + updatedData: { + supplier_item_code: mappingRows[mi].supplier_item_code || "", + supplier_item_name: mappingRows[mi].supplier_item_name || "", + }, + }); + usedExistingIds.add(existMap.id); + if (mi === 0) firstMappingId = existMap.id; + } else { + const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: crypto.randomUUID(), + supplier_id: custKey, item_id: selectedItem.item_number, + supplier_item_code: mappingRows[mi].supplier_item_code || "", + supplier_item_name: mappingRows[mi].supplier_item_name || "", + }); + if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null; + } + } + // 초과분 delete + const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id)); + if (toDeleteMaps.length > 0) { + await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { + data: toDeleteMaps.map((m: any) => ({ id: m.id })), + }); + } + + // 기존 단가 조회 + let existingPriceRows: any[] = []; try { const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { page: 1, size: 100, dataFilter: { enabled: true, filters: [ - { columnName: "mapping_id", operator: "equals", value: editSuppData.id }, + { columnName: "supplier_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem.item_number }, ]}, autoFilter: true, }); - const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; - if (existing.length > 0) { - await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { - data: existing.map((p: any) => ({ id: p.id })), - }); - } + existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; } catch { /* skip */ } + // 단가 upsert: 인덱스 기반 const priceRows = (suppPrices[custKey] || []).filter((p) => - (p.base_price && Number(p.base_price) > 0) || p.start_date + p.base_price || p.start_date || p.currency_code || p.base_price_type ); - for (const price of priceRows) { - await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { - id: crypto.randomUUID(), - mapping_id: editSuppData.id, - supplier_id: custKey, - item_id: selectedItem.item_number, + const usedPriceIds = new Set(); + for (let pi = 0; pi < priceRows.length; pi++) { + const price = priceRows[pi]; + const priceData = { + mapping_id: firstMappingId || editSuppData.id, + supplier_id: custKey, item_id: selectedItem.item_number, start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, + }; + const existPrice = existingPriceRows[pi]; + if (existPrice) { + await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, { + originalData: { id: existPrice.id }, + updatedData: priceData, + }); + usedPriceIds.add(existPrice.id); + } else { + await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { + id: crypto.randomUUID(), ...priceData, + }); + } + } + // 초과분 delete + const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id)); + if (toDeletePrices.length > 0) { + await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { + data: toDeletePrices.map((p: any) => ({ id: p.id })), }); } } else { @@ -508,7 +1051,7 @@ export default function PurchaseItemPage() { } const priceRows = (suppPrices[custKey] || []).filter((p) => - (p.base_price && Number(p.base_price) > 0) || p.start_date + p.base_price || p.start_date || p.currency_code || p.base_price_type ); for (const price of priceRows) { await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { @@ -517,6 +1060,7 @@ export default function PurchaseItemPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, @@ -539,70 +1083,63 @@ export default function PurchaseItemPage() { } }; - // 품목 수정 - const openEditItem = () => { - if (!selectedItem) return; - setEditItemForm({ ...selectedItem }); - setEditItemOpen(true); - }; - - const handleEditSave = async () => { - if (!editItemForm.id) return; - setSaving(true); - try { - await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { - originalData: { id: editItemForm.id }, - updatedData: { - standard_price: editItemForm.standard_price || null, - currency_code: editItemForm.currency_code || null, - }, - }); - toast.success("수정되었습니다."); - setEditItemOpen(false); - fetchItems(); - } catch (err: any) { - toast.error(err.response?.data?.message || "수정에 실패했습니다."); - } finally { - setSaving(false); - } - }; - - // 우측: 공급업체 매핑 삭제 + // 우측: 공급업체 매핑 해제 (소프트 삭제 — item_id를 null 처리) const handleSupplierMappingDelete = async () => { if (supplierCheckedIds.length === 0) return; - const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체 매핑을 삭제하시겠습니까?`, { - description: "관련된 단가 정보도 함께 삭제됩니다.", - variant: "destructive", confirmText: "삭제", + const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체의 연결을 해제하시겠습니까?`, { + description: "해당 공급업체의 품목 연결이 해제됩니다. (데이터는 유지)", + variant: "destructive", confirmText: "해제", }); if (!ok) return; try { - // 관련 단가 삭제 - for (const mappingId of supplierCheckedIds) { + const supplierCodes = supplierCheckedIds.map((mid) => { + const group = Object.values(supplierGroups).find((g) => g.master.id === mid); + return group?.master.supplier_id || group?.master.supplier_code || ""; + }).filter(Boolean); + + for (const suppCode of supplierCodes) { + // 해당 공급업체의 모든 매핑 조회 → item_id null 처리 + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + { columnName: "supplier_id", operator: "equals", value: suppCode }, + ]}, autoFilter: true, + }); + const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + for (const m of allMappings) { + await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { + originalData: { id: m.id }, + updatedData: { item_id: null }, + }); + } + + // 해당 공급업체의 모든 단가 조회 → item_id null 처리 try { const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { page: 1, size: 500, - dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] }, - autoFilter: true, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + { columnName: "supplier_id", operator: "equals", value: suppCode }, + ]}, autoFilter: true, }); const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; - if (prices.length > 0) { - await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { - data: prices.map((p: any) => ({ id: p.id })), + for (const p of prices) { + await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, { + originalData: { id: p.id }, + updatedData: { item_id: null }, }); } } catch { /* skip */ } } - // 매핑 삭제 - await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { - data: supplierCheckedIds.map((id) => ({ id })), - }); - toast.success(`${supplierCheckedIds.length}개 공급업체 매핑이 삭제되었습니다.`); + + toast.success(`${supplierCheckedIds.length}개 공급업체의 연결이 해제되었습니다.`); setSupplierCheckedIds([]); const sid = selectedItemId; setSelectedItemId(null); setTimeout(() => setSelectedItemId(sid), 50); } catch { - toast.error("삭제에 실패했습니다."); + toast.error("연결 해제에 실패했습니다."); } }; @@ -647,8 +1184,23 @@ export default function PurchaseItemPage() { {/* 액션 버튼 영역 */}
-
- +
- {/* 공급업체 테이블 */} + {/* 공급업체 테이블 (expandable rows) */}
- {supplierLoading ? ( -
- -
- ) : supplierItems.length === 0 ? ( -
- 등록된 공급업체가 없어요 -
- ) : ( - - - - - 0 && supplierCheckedIds.length === supplierItems.length} - onChange={(e) => { - if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id)); - else setSupplierCheckedIds([]); - }} - /> - - 공급업체코드 - 공급업체명 - 공급업체품번 - 공급업체품명 - 기준가 - 단가 - 통화 +
+ + + + 0 && supplierCheckedIds.length === supplierItems.length} + onChange={(e) => { + if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id)); + else setSupplierCheckedIds([]); + }} + /> + + 공급업체코드 + 공급업체명 + 공급업체품번 + 공급업체품명 + 기준가 + 단가 + 통화 + + + + {supplierLoading ? ( + + + + - - - {supplierItems.map((row) => ( - openEditSupp(row)} - > - { - e.stopPropagation(); - setSupplierCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); + ) : Object.keys(supplierGroups).length === 0 ? ( + + + 등록된 공급업체가 없어요 + + + ) : Object.entries(supplierGroups).map(([custKey, group]) => { + const isExpanded = expandedItems.has(custKey); + const m = group.master; + const isChecked = supplierCheckedIds.includes(m.id); + return ( + + {/* 마스터 행 */} + { + setExpandedItems((prev) => { + const next = new Set(prev); + if (next.has(custKey)) next.delete(custKey); else next.add(custKey); + return next; + }); }} + onDoubleClick={() => openEditSupp(m)} > - - - {row.supplier_code} - {row.supplier_name} - {row.supplier_item_code} - {row.supplier_item_name} - - {row.base_price ? Number(row.base_price).toLocaleString() : ""} - - - {row.calculated_price ? Number(row.calculated_price).toLocaleString() : ""} - - {row.currency_code} - - ))} - -
- )} + { + e.stopPropagation(); + setSupplierCheckedIds((prev) => + prev.includes(m.id) ? prev.filter((id) => id !== m.id) : [...prev, m.id] + ); + }} + > + + + +
+ {isExpanded + ? + : + } + {m.supplier_code} +
+
+ {m.supplier_name} + {m.supplier_item_code} + {m.supplier_item_name} + + {m.base_price ? Number(m.base_price).toLocaleString() : ""} + + + {m.calculated_price ? Number(m.calculated_price).toLocaleString() : ""} + + {m.currency_code} + + + {/* 현재 단가 카드 (펼쳤을 때) */} + {isExpanded && (() => { + const cp = group.details.find((p) => p.is_current) || group.details[0]; + if (!cp) return ( + + 등록된 단가가 없어요 + + ); + return ( + + +
+ {/* 카드 헤더 */} +
+
+ + 적용 단가 + 현재 +
+ {group.details.length > 1 && ( + 전체 {group.details.length}건 중 + )} +
+ {/* 카드 내용 */} +
+
+ 기간 + + {cp.start_date ? String(cp.start_date).split("T")[0] : "—"} ~ {cp.end_date ? String(cp.end_date).split("T")[0] : "—"} + +
+
+ 기준유형 + {cp.base_price_type_label || "-"} +
+
+ 기준가 + {cp.base_price ? Number(cp.base_price).toLocaleString() : "-"} +
+
+ 할인유형 + {cp.discount_type_label && cp.discount_type_label !== "할인없음" ? cp.discount_type_label : "-"} +
+
+ 할인값 + {cp.discount_value ? Number(cp.discount_value).toLocaleString() : "-"} +
+
+ 단수처리 + + {cp.rounding_unit_value + ? (priceCategoryOptions["rounding_unit_value"]?.find((o) => o.code === cp.rounding_unit_value)?.label || cp.rounding_unit_value) + : "-"} + +
+ +
+ 계산단가 + + {(cp.calculated_price || cp.unit_price) ? Number(cp.calculated_price || cp.unit_price).toLocaleString() : "-"} + {cp.currency_label} + +
+
+
+
+
+ ); + })()} + + ); + })} + +
)} @@ -823,73 +1477,151 @@ export default function PurchaseItemPage() {
- {/* ── 품목 수정 모달 ── */} - - - - 구매품목 수정 + {/* ── 품목 등록/수정 모달 ── */} + + + + {isEditMode ? "품목 수정" : "품목 등록"} - {editItemForm.item_number || ""} — {editItemForm.item_name || ""} + {isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."} -
- {/* 품목 기본정보 (읽기 전용) */} - {[ - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품명" }, - { key: "size", label: "규격" }, - { key: "unit", label: "단위" }, - { key: "material", label: "재질" }, - { key: "status", label: "상태" }, - ].map((f) => ( -
- - -
- ))} -
- - {/* 구매 설정 (수정 가능) */} -
- - setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} - placeholder="구매단가를 입력해주세요" - className="h-9" - /> -
-
- - setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} - placeholder="기준단가를 입력해주세요" - className="h-9" - /> -
-
- - +
+
+ {FORM_FIELDS.map((field) => ( +
+ + {field.type === "numbering" ? ( + isEditMode ? ( + + ) : isNumberingLoading && numberingParts.length === 0 ? ( +
+ + 생성 중... +
+ ) : numberingParts.some(p => p.isManual) ? ( +
+ {numberingParts.map((part, idx) => { + const isFirst = idx === 0; + const isLast = idx === numberingParts.length - 1; + if (part.isManual) { + return ( + + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: buildCodeFromParts(numberingParts, val), + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {part.separator && !isLast && ( + {part.separator} + )} + + ); + } + return ( + + + {part.value} + + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
+ ) : ( + + ) + ) : field.type === "image" ? ( + setFormData((prev) => ({ ...prev, [field.key]: v }))} + tableName={ITEM_TABLE} + recordId={formData.id || ""} + columnName={field.key} + height="h-32" + /> + ) : field.type === "multi-category" ? ( + setFormData((prev) => ({ ...prev, [field.key]: v }))} + placeholder={`${field.label} 선택`} + /> + ) : field.type === "category" ? ( + setFormData((prev) => ({ ...prev, [field.key]: v }))} + placeholder={`${field.label} 선택`} + /> + ) : field.type === "textarea" ? ( +