const PurchaseRequestModel = require('../models/purchaseRequestModel'); const PurchaseModel = require('../models/purchaseModel'); const { saveBase64Image } = require('../services/imageUploadService'); const logger = require('../utils/logger'); const notifyHelper = require('../../../shared/utils/notifyHelper'); const koreanSearch = require('../utils/koreanSearch'); const PurchaseRequestController = { // 구매신청 목록 getAll: async (req, res) => { try { const { status, category, from_date, to_date, batch_id } = req.query; const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level); const filters = { status, category, from_date, to_date, batch_id }; if (!isAdmin) filters.requester_id = req.user.id; const rows = await PurchaseRequestModel.getAll(filters); res.json({ success: true, data: rows }); } catch (err) { logger.error('PurchaseRequest getAll error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 구매신청 상세 getById: async (req, res) => { try { const row = await PurchaseRequestModel.getById(req.params.id); if (!row) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' }); res.json({ success: true, data: row }); } catch (err) { logger.error('PurchaseRequest getById error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 구매신청 생성 create: async (req, res) => { try { const { item_id, custom_item_name, custom_category, quantity, notes, photo } = req.body; // item_id 또는 custom_item_name 중 하나 필수 if (!item_id && !custom_item_name) { return res.status(400).json({ success: false, message: '소모품을 선택하거나 품목명을 입력해주세요.' }); } if (!quantity || quantity < 1) { return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' }); } if (!item_id && custom_item_name && !custom_category) { return res.status(400).json({ success: false, message: '직접 입력 시 분류를 선택해주세요.' }); } // 사진 업로드 let photo_path = null; if (photo) { photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests'); } const request = await PurchaseRequestModel.create({ item_id: item_id || null, custom_item_name: custom_item_name || null, custom_category: custom_category || null, quantity, requester_id: req.user.id, request_date: new Date().toISOString().substring(0, 10), notes, photo_path }); res.status(201).json({ success: true, data: request, message: '구매신청이 등록되었습니다.' }); } catch (err) { logger.error('PurchaseRequest create error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 보류 처리 (admin) hold: async (req, res) => { try { const { hold_reason } = req.body; const request = await PurchaseRequestModel.hold(req.params.id, hold_reason); if (!request) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' }); res.json({ success: true, data: request, message: '보류 처리되었습니다.' }); } catch (err) { logger.error('PurchaseRequest hold error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // pending으로 되돌리기 (admin) revert: async (req, res) => { try { const request = await PurchaseRequestModel.revertToPending(req.params.id); if (!request) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' }); res.json({ success: true, data: request, message: '대기 상태로 되돌렸습니다.' }); } catch (err) { logger.error('PurchaseRequest revert error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 삭제 (본인 + pending만) delete: async (req, res) => { try { const existing = await PurchaseRequestModel.getById(req.params.id); if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' }); const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level); if (!isAdmin && existing.requester_id !== req.user.id) { return res.status(403).json({ success: false, message: '본인의 신청만 삭제할 수 있습니다.' }); } const deleted = await PurchaseRequestModel.delete(req.params.id); if (!deleted) return res.status(400).json({ success: false, message: '대기 상태의 신청만 삭제할 수 있습니다.' }); res.json({ success: true, message: '삭제되었습니다.' }); } catch (err) { logger.error('PurchaseRequest delete error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 품목 등록 + 신청 동시 처리 (단일 트랜잭션) registerAndRequest: async (req, res) => { const { getDb } = require('../dbPool'); let conn; try { const { item_name, spec, maker, category, quantity, notes, photo } = req.body; if (!item_name || !item_name.trim()) { return res.status(400).json({ success: false, message: '품목명을 입력해주세요.' }); } if (!quantity || quantity < 1) { return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' }); } const db = await getDb(); conn = await db.getConnection(); await conn.beginTransaction(); // 1. 소모품 마스터 등록 (중복 확인) const [existing] = await conn.query( `SELECT item_id FROM consumable_items WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL)) AND (maker = ? OR (maker IS NULL AND ? IS NULL))`, [item_name.trim(), spec || null, spec || null, maker || null, maker || null] ); let itemId; if (existing.length > 0) { itemId = existing[0].item_id; } else { const [insertResult] = await conn.query( `INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`, [item_name.trim(), spec || null, maker || null, category || 'consumable'] ); itemId = insertResult.insertId; } // 2. 사진 업로드 (트랜잭션 외부 — 파일 저장은 DB 롤백 불가이므로 마지막에) let photo_path = null; if (photo) { photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests'); } // 3. 구매 신청 생성 const [reqResult] = await conn.query( `INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes, photo_path) VALUES (?, ?, ?, CURDATE(), ?, ?)`, [itemId, quantity, req.user.id, notes || null, photo_path] ); await conn.commit(); // 검색 캐시 무효화 koreanSearch.clearCache(); const request = await PurchaseRequestModel.getById(reqResult.insertId); res.status(201).json({ success: true, data: request, message: '품목 등록 및 신청이 완료되었습니다.' }); } catch (err) { if (conn) await conn.rollback().catch(() => {}); logger.error('registerAndRequest error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } finally { if (conn) conn.release(); } }, // 일괄 신청 (장바구니, 트랜잭션) bulkCreate: async (req, res) => { const { getDb } = require('../dbPool'); let conn; try { const { items, photo } = req.body; if (!items || !Array.isArray(items) || items.length === 0) { return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' }); } for (const item of items) { if (!item.item_id && !item.item_name) { return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' }); } if (!item.quantity || item.quantity < 1) { return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' }); } } // 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가) let photo_path = null; if (photo) { photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests'); } const db = await getDb(); conn = await db.getConnection(); await conn.beginTransaction(); const createdIds = []; let newItemRegistered = false; for (const item of items) { let itemId = item.item_id || null; // 신규 품목 등록 (is_new) if (item.is_new && item.item_name) { const [existing] = await conn.query( `SELECT item_id FROM consumable_items WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL)) AND (maker = ? OR (maker IS NULL AND ? IS NULL))`, [item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null] ); if (existing.length > 0) { itemId = existing[0].item_id; } else { const [ins] = await conn.query( `INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`, [item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable'] ); itemId = ins.insertId; newItemRegistered = true; } } // purchase_request 생성 const [result] = await conn.query( `INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path) VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`, [itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null, item.quantity, req.user.id, item.notes || null, photo_path] ); createdIds.push(result.insertId); } await conn.commit(); if (newItemRegistered) koreanSearch.clearCache(); res.status(201).json({ success: true, data: { request_ids: createdIds, count: createdIds.length }, message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.` }); } catch (err) { if (conn) await conn.rollback().catch(() => {}); logger.error('bulkCreate error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } finally { if (conn) conn.release(); } }, // 스마트 검색 (초성 + 별칭 + substring) search: async (req, res) => { try { const { q } = req.query; const results = await koreanSearch.search(q || ''); res.json({ success: true, data: results }); } catch (err) { logger.error('PurchaseRequest search error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 소모품 목록 (select용) getConsumableItems: async (req, res) => { try { const items = await PurchaseModel.getConsumableItems(); res.json({ success: true, data: items }); } catch (err) { logger.error('ConsumableItems get error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 업체 목록 (select용) getVendors: async (req, res) => { try { const vendors = await PurchaseModel.getVendors(); res.json({ success: true, data: vendors }); } catch (err) { logger.error('Vendors get error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 내 신청 목록 (모바일용, 페이지네이션) getMyRequests: async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; const { status } = req.query; const result = await PurchaseRequestModel.getMyRequests(req.user.id, { page, limit, status }); res.json({ success: true, ...result }); } catch (err) { logger.error('PurchaseRequest getMyRequests error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } }, // 개별 입고 처리 (admin) receive: async (req, res) => { try { const existing = await PurchaseRequestModel.getById(req.params.id); if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' }); if (existing.status !== 'purchased') { return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 입고 처리할 수 있습니다.' }); } const { received_location, photo } = req.body; let receivedPhotoPath = null; if (photo) { receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received'); } const updated = await PurchaseRequestModel.receive(req.params.id, { receivedPhotoPath, receivedLocation: received_location || null, receivedBy: req.user.id }); // batch 내 전체 입고 완료 시 batch.status 자동 전환 if (existing.batch_id) { const allReceived = await PurchaseRequestModel.checkBatchAllReceived(existing.batch_id); if (allReceived) { const { getDb } = require('../dbPool'); const db = await getDb(); await db.query( `UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ? WHERE batch_id = ?`, [req.user.id, existing.batch_id] ); } } // 신청자에게 입고 알림 notifyHelper.send({ type: 'purchase', title: '소모품 입고 완료', message: `${existing.item_name || existing.custom_item_name} 입고 완료${received_location ? '. 보관위치: ' + received_location : ''}`, link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id, target_user_ids: [existing.requester_id], created_by: req.user.id }).catch(() => {}); res.json({ success: true, data: updated, message: '입고 처리가 완료되었습니다.' }); } catch (err) { logger.error('PurchaseRequest receive error:', err); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); } } }; module.exports = PurchaseRequestController;