diff --git a/system1-factory/api/Dockerfile b/system1-factory/api/Dockerfile index 314d33e..45f8a08 100644 --- a/system1-factory/api/Dockerfile +++ b/system1-factory/api/Dockerfile @@ -17,7 +17,7 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ && \ COPY . . # 로그 디렉토리 생성 -RUN mkdir -p logs uploads +RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests # 실행 권한 설정 RUN chown -R node:node /usr/src/app diff --git a/system1-factory/api/controllers/purchaseController.js b/system1-factory/api/controllers/purchaseController.js index 8ec722d..060429a 100644 --- a/system1-factory/api/controllers/purchaseController.js +++ b/system1-factory/api/controllers/purchaseController.js @@ -6,16 +6,37 @@ const PurchaseController = { // 구매 처리 (신청 → 구매) create: async (req, res) => { try { - const { request_id, item_id, vendor_id, quantity, unit_price, purchase_date, update_base_price, notes } = req.body; + const { request_id, item_id, vendor_id, quantity, unit_price, purchase_date, update_base_price, register_to_master, notes } = req.body; - if (!item_id) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' }); + // item_id가 없으면 custom item → register_to_master로 자동 등록 가능 + let effectiveItemId = item_id; + + if (!effectiveItemId && request_id) { + // 미등록 품목의 구매 처리 — 마스터 등록 처리 + const requestData = await PurchaseRequestModel.getById(request_id); + if (requestData && requestData.custom_item_name) { + if (register_to_master !== false) { + // 마스터에 등록 + const newItemId = await PurchaseModel.registerToMaster( + requestData.custom_item_name, + requestData.custom_category, + null // maker + ); + effectiveItemId = newItemId; + // purchase_requests.item_id 업데이트 + await PurchaseRequestModel.updateItemId(request_id, newItemId); + } + } + } + + if (!effectiveItemId) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' }); if (!unit_price) return res.status(400).json({ success: false, message: '구매 단가를 입력해주세요.' }); if (!purchase_date) return res.status(400).json({ success: false, message: '구매일을 입력해주세요.' }); // 구매 내역 생성 const purchaseId = await PurchaseModel.createFromRequest({ request_id: request_id || null, - item_id, + item_id: effectiveItemId, vendor_id: vendor_id || null, quantity: quantity || 1, unit_price, @@ -27,9 +48,9 @@ const PurchaseController = { // 기준가 업데이트 요청 시 if (update_base_price) { const items = await PurchaseModel.getConsumableItems(false); - const item = items.find(i => i.item_id === parseInt(item_id)); + const item = items.find(i => i.item_id === parseInt(effectiveItemId)); if (item) { - await PurchaseModel.updateBasePrice(item_id, unit_price, item.base_price, req.user.id); + await PurchaseModel.updateBasePrice(effectiveItemId, unit_price, item.base_price, req.user.id); } } @@ -37,9 +58,10 @@ const PurchaseController = { let equipmentResult = null; if (request_id) { const requestData = await PurchaseRequestModel.getById(request_id); - if (requestData && requestData.category === 'equipment') { + const category = requestData?.category || requestData?.custom_category; + if (category === 'equipment') { equipmentResult = await PurchaseModel.tryAutoRegisterEquipment({ - item_name: requestData.item_name, + item_name: requestData.item_name || requestData.custom_item_name, maker: requestData.maker, vendor_name: null, unit_price, @@ -51,7 +73,7 @@ const PurchaseController = { } else { // 직접 구매 시에도 category 확인 const items = await PurchaseModel.getConsumableItems(false); - const item = items.find(i => i.item_id === parseInt(item_id)); + const item = items.find(i => i.item_id === parseInt(effectiveItemId)); if (item && item.category === 'equipment') { const vendors = await PurchaseModel.getVendors(); const vendor = vendors.find(v => v.vendor_id === parseInt(vendor_id)); diff --git a/system1-factory/api/controllers/purchaseRequestController.js b/system1-factory/api/controllers/purchaseRequestController.js index c5f17c4..1d8de8e 100644 --- a/system1-factory/api/controllers/purchaseRequestController.js +++ b/system1-factory/api/controllers/purchaseRequestController.js @@ -1,5 +1,6 @@ const PurchaseRequestModel = require('../models/purchaseRequestModel'); const PurchaseModel = require('../models/purchaseModel'); +const { saveBase64Image } = require('../services/imageUploadService'); const logger = require('../utils/logger'); const PurchaseRequestController = { @@ -33,16 +34,34 @@ const PurchaseRequestController = { // 구매신청 생성 create: async (req, res) => { try { - const { item_id, quantity, notes } = req.body; - if (!item_id) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' }); - if (!quantity || quantity < 1) return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' }); + 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: 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 + notes, + photo_path }); res.status(201).json({ success: true, data: request, message: '구매신청이 등록되었습니다.' }); } catch (err) { diff --git a/system1-factory/api/models/purchaseModel.js b/system1-factory/api/models/purchaseModel.js index b014723..19d3ef5 100644 --- a/system1-factory/api/models/purchaseModel.js +++ b/system1-factory/api/models/purchaseModel.js @@ -129,6 +129,27 @@ const PurchaseModel = { return rows; }, + // 미등록 품목 → 소모품 마스터 등록 + async registerToMaster(customItemName, customCategory, maker) { + const db = await getDb(); + + // 중복 확인 + const [existing] = await db.query( + `SELECT item_id FROM consumable_items WHERE item_name = ? AND (maker = ? OR (maker IS NULL AND ? IS NULL))`, + [customItemName, maker || null, maker || null] + ); + if (existing.length > 0) { + return existing[0].item_id; + } + + // 신규 등록 (photo_path = NULL) + const [result] = await db.query( + `INSERT INTO consumable_items (item_name, maker, category, is_active) VALUES (?, ?, ?, 1)`, + [customItemName, maker || null, customCategory || 'consumable'] + ); + return result.insertId; + }, + // 가격 변동 이력 async getPriceHistory(itemId) { const db = await getDb(); diff --git a/system1-factory/api/models/purchaseRequestModel.js b/system1-factory/api/models/purchaseRequestModel.js index 92f1cc0..f6242ee 100644 --- a/system1-factory/api/models/purchaseRequestModel.js +++ b/system1-factory/api/models/purchaseRequestModel.js @@ -2,14 +2,16 @@ const { getDb } = require('../dbPool'); const PurchaseRequestModel = { - // 구매신청 목록 (소모품 정보 JOIN) + // 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용) async getAll(filters = {}) { const db = await getDb(); let sql = ` - SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit, ci.photo_path, + SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit, + ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path, + pr.custom_item_name, pr.custom_category, su.name AS requester_name FROM purchase_requests pr - JOIN consumable_items ci ON pr.item_id = ci.item_id + LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id LEFT JOIN sso_users su ON pr.requester_id = su.user_id WHERE 1=1 `; @@ -17,7 +19,10 @@ const PurchaseRequestModel = { if (filters.status) { sql += ' AND pr.status = ?'; params.push(filters.status); } if (filters.requester_id) { sql += ' AND pr.requester_id = ?'; params.push(filters.requester_id); } - if (filters.category) { sql += ' AND ci.category = ?'; params.push(filters.category); } + if (filters.category) { + sql += ' AND (ci.category = ? OR pr.custom_category = ?)'; + params.push(filters.category, filters.category); + } if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); } if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); } @@ -30,10 +35,12 @@ const PurchaseRequestModel = { async getById(requestId) { const db = await getDb(); const [rows] = await db.query(` - SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit, ci.photo_path, + SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit, + ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path, + pr.custom_item_name, pr.custom_category, su.name AS requester_name FROM purchase_requests pr - JOIN consumable_items ci ON pr.item_id = ci.item_id + LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id LEFT JOIN sso_users su ON pr.requester_id = su.user_id WHERE pr.request_id = ? `, [requestId]); @@ -44,9 +51,10 @@ const PurchaseRequestModel = { async create(data) { const db = await getDb(); const [result] = await db.query( - `INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes) - VALUES (?, ?, ?, ?, ?)`, - [data.item_id, data.quantity || 1, data.requester_id, data.request_date, data.notes || null] + `INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [data.item_id || null, data.custom_item_name || null, data.custom_category || null, + data.quantity || 1, data.requester_id, data.request_date, data.notes || null, data.photo_path || null] ); return this.getById(result.insertId); }, @@ -80,6 +88,15 @@ const PurchaseRequestModel = { return this.getById(requestId); }, + // item_id 업데이트 (마스터 등록 후) + async updateItemId(requestId, itemId) { + const db = await getDb(); + await db.query( + `UPDATE purchase_requests SET item_id = ? WHERE request_id = ?`, + [itemId, requestId] + ); + }, + // 삭제 (admin only, pending 상태만) async delete(requestId) { const db = await getDb(); diff --git a/system1-factory/api/services/imageUploadService.js b/system1-factory/api/services/imageUploadService.js index e898f78..b3b9722 100644 --- a/system1-factory/api/services/imageUploadService.js +++ b/system1-factory/api/services/imageUploadService.js @@ -22,7 +22,8 @@ try { // 업로드 디렉토리 설정 (Docker 볼륨 마운트: /usr/src/app/uploads) const UPLOAD_DIRS = { issues: path.join(__dirname, '../uploads/issues'), - equipments: path.join(__dirname, '../uploads/equipments') + equipments: path.join(__dirname, '../uploads/equipments'), + purchase_requests: path.join(__dirname, '../uploads/purchase_requests') }; const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지 const MAX_SIZE = { width: 1920, height: 1920 }; diff --git a/system1-factory/web/pages/purchase/request.html b/system1-factory/web/pages/purchase/request.html index 48bf1db..e4a7eb8 100644 --- a/system1-factory/web/pages/purchase/request.html +++ b/system1-factory/web/pages/purchase/request.html @@ -7,6 +7,18 @@ + +