feat(purchase): 생산소모품 구매 관리 시스템 구현

tkuser: 업체(공급업체) CRUD + 소모품 마스터 CRUD (사진 업로드 포함)
tkfb: 구매신청 → 구매 처리 → 월간 분석/정산 전체 워크플로
설비(equipment) 분류 구매 시 자동 등록 + 실패 시 admin 알림

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 21:21:59 +09:00
parent 1abdb92a71
commit 3623551a6b
29 changed files with 2581 additions and 1 deletions

View File

@@ -0,0 +1,90 @@
const consumableItemModel = require('../models/consumableItemModel');
const fs = require('fs');
const path = require('path');
async function list(req, res) {
try {
const { category, search, is_active } = req.query;
const rows = await consumableItemModel.findAll({
category,
search,
is_active: is_active !== undefined ? is_active === 'true' || is_active === '1' : undefined
});
res.json({ success: true, data: rows });
} catch (err) {
console.error('ConsumableItem list error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function getById(req, res) {
try {
const item = await consumableItemModel.findById(req.params.id);
if (!item) return res.status(404).json({ success: false, error: '소모품을 찾을 수 없습니다' });
res.json({ success: true, data: item });
} catch (err) {
console.error('ConsumableItem get error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function create(req, res) {
try {
const { item_name, category } = req.body;
if (!item_name || !item_name.trim()) {
return res.status(400).json({ success: false, error: '품명은 필수입니다' });
}
if (!category) {
return res.status(400).json({ success: false, error: '분류는 필수입니다' });
}
const data = { ...req.body };
if (req.file) {
data.photo_path = '/uploads/consumables/' + req.file.filename;
}
const item = await consumableItemModel.create(data);
res.status(201).json({ success: true, data: item });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ success: false, error: '동일한 품명+메이커 조합이 이미 존재합니다' });
}
console.error('ConsumableItem create error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function update(req, res) {
try {
const existing = await consumableItemModel.findById(req.params.id);
if (!existing) return res.status(404).json({ success: false, error: '소모품을 찾을 수 없습니다' });
const data = { ...req.body };
if (req.file) {
data.photo_path = '/uploads/consumables/' + req.file.filename;
// 기존 사진 삭제
if (existing.photo_path) {
const oldPath = path.join(__dirname, '..', existing.photo_path);
fs.unlink(oldPath, () => {});
}
}
const item = await consumableItemModel.update(req.params.id, data);
res.json({ success: true, data: item });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ success: false, error: '동일한 품명+메이커 조합이 이미 존재합니다' });
}
console.error('ConsumableItem update error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function deactivate(req, res) {
try {
await consumableItemModel.deactivate(req.params.id);
res.json({ success: true, message: '비활성화 완료' });
} catch (err) {
console.error('ConsumableItem deactivate error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
module.exports = { list, getById, create, update, deactivate };

View File

@@ -0,0 +1,66 @@
const vendorModel = require('../models/vendorModel');
async function list(req, res) {
try {
const { search, is_active } = req.query;
const rows = await vendorModel.findAll({
search,
is_active: is_active !== undefined ? is_active === 'true' || is_active === '1' : undefined
});
res.json({ success: true, data: rows });
} catch (err) {
console.error('Vendor list error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function getById(req, res) {
try {
const vendor = await vendorModel.findById(req.params.id);
if (!vendor) return res.status(404).json({ success: false, error: '업체를 찾을 수 없습니다' });
res.json({ success: true, data: vendor });
} catch (err) {
console.error('Vendor get error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function create(req, res) {
try {
const { vendor_name } = req.body;
if (!vendor_name || !vendor_name.trim()) {
return res.status(400).json({ success: false, error: '업체명은 필수입니다' });
}
const vendor = await vendorModel.create(req.body);
res.status(201).json({ success: true, data: vendor });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ success: false, error: '이미 등록된 업체입니다' });
}
console.error('Vendor create error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function update(req, res) {
try {
const vendor = await vendorModel.update(req.params.id, req.body);
if (!vendor) return res.status(404).json({ success: false, error: '업체를 찾을 수 없습니다' });
res.json({ success: true, data: vendor });
} catch (err) {
console.error('Vendor update error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function deactivate(req, res) {
try {
await vendorModel.deactivate(req.params.id);
res.json({ success: true, message: '비활성화 완료' });
} catch (err) {
console.error('Vendor deactivate error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
module.exports = { list, getById, create, update, deactivate };

View File

@@ -18,6 +18,8 @@ const equipmentRoutes = require('./routes/equipmentRoutes');
const taskRoutes = require('./routes/taskRoutes');
const vacationRoutes = require('./routes/vacationRoutes');
const partnerRoutes = require('./routes/partnerRoutes');
const vendorRoutes = require('./routes/vendorRoutes');
const consumableItemRoutes = require('./routes/consumableItemRoutes');
const notificationRecipientRoutes = require('./routes/notificationRecipientRoutes');
const app = express();
@@ -59,6 +61,8 @@ app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/vacations', vacationRoutes);
app.use('/api/partners', partnerRoutes);
app.use('/api/vendors', vendorRoutes);
app.use('/api/consumable-items', consumableItemRoutes);
app.use('/api/notification-recipients', notificationRecipientRoutes);
// 404

View File

@@ -5,6 +5,7 @@
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
@@ -32,4 +33,26 @@ const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }
});
// 소모품 사진 업로드
const consumablesDir = path.join(__dirname, '..', 'uploads', 'consumables');
if (!fs.existsSync(consumablesDir)) { fs.mkdirSync(consumablesDir, { recursive: true }); }
const consumableStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, consumablesDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `consumable-${Date.now()}-${crypto.randomInt(100000000, 999999999)}${ext}`;
cb(null, uniqueName);
}
});
const consumableUpload = multer({
storage: consumableStorage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 }
});
module.exports = upload;
module.exports.consumableUpload = consumableUpload;

View File

@@ -0,0 +1,56 @@
const { getPool } = require('./userModel');
// ===== 소모품 마스터 =====
async function findAll({ category, search, is_active } = {}) {
const db = getPool();
let sql = 'SELECT * FROM consumable_items WHERE 1=1';
const params = [];
if (is_active !== undefined) { sql += ' AND is_active = ?'; params.push(is_active); }
if (category) { sql += ' AND category = ?'; params.push(category); }
if (search) { sql += ' AND (item_name LIKE ? OR maker LIKE ?)'; params.push(`%${search}%`, `%${search}%`); }
sql += ' ORDER BY category, item_name';
const [rows] = await db.query(sql, params);
return rows;
}
async function findById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM consumable_items WHERE item_id = ?', [id]);
return rows[0] || null;
}
async function create(data) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO consumable_items (item_name, maker, category, base_price, unit, photo_path)
VALUES (?, ?, ?, ?, ?, ?)`,
[data.item_name, data.maker || null, data.category,
data.base_price || 0, data.unit || 'EA', data.photo_path || null]
);
return findById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.item_name !== undefined) { fields.push('item_name = ?'); values.push(data.item_name); }
if (data.maker !== undefined) { fields.push('maker = ?'); values.push(data.maker || null); }
if (data.category !== undefined) { fields.push('category = ?'); values.push(data.category); }
if (data.base_price !== undefined) { fields.push('base_price = ?'); values.push(data.base_price); }
if (data.unit !== undefined) { fields.push('unit = ?'); values.push(data.unit || 'EA'); }
if (data.photo_path !== undefined) { fields.push('photo_path = ?'); values.push(data.photo_path || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (fields.length === 0) return findById(id);
values.push(id);
await db.query(`UPDATE consumable_items SET ${fields.join(', ')} WHERE item_id = ?`, values);
return findById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query('UPDATE consumable_items SET is_active = FALSE WHERE item_id = ?', [id]);
}
module.exports = { findAll, findById, create, update, deactivate };

View File

@@ -0,0 +1,59 @@
const { getPool } = require('./userModel');
// ===== 업체(공급업체) =====
async function findAll({ search, is_active } = {}) {
const db = getPool();
let sql = 'SELECT * FROM vendors WHERE 1=1';
const params = [];
if (is_active !== undefined) { sql += ' AND is_active = ?'; params.push(is_active); }
if (search) { sql += ' AND (vendor_name LIKE ? OR business_number LIKE ? OR contact_name LIKE ?)'; params.push(`%${search}%`, `%${search}%`, `%${search}%`); }
sql += ' ORDER BY vendor_name';
const [rows] = await db.query(sql, params);
return rows;
}
async function findById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM vendors WHERE vendor_id = ?', [id]);
return rows[0] || null;
}
async function create(data) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO vendors (vendor_name, business_number, representative, contact_name, contact_phone, address, bank_name, bank_account, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[data.vendor_name, data.business_number || null, data.representative || null,
data.contact_name || null, data.contact_phone || null, data.address || null,
data.bank_name || null, data.bank_account || null, data.notes || null]
);
return findById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.vendor_name !== undefined) { fields.push('vendor_name = ?'); values.push(data.vendor_name); }
if (data.business_number !== undefined) { fields.push('business_number = ?'); values.push(data.business_number || null); }
if (data.representative !== undefined) { fields.push('representative = ?'); values.push(data.representative || null); }
if (data.contact_name !== undefined) { fields.push('contact_name = ?'); values.push(data.contact_name || null); }
if (data.contact_phone !== undefined) { fields.push('contact_phone = ?'); values.push(data.contact_phone || null); }
if (data.address !== undefined) { fields.push('address = ?'); values.push(data.address || null); }
if (data.bank_name !== undefined) { fields.push('bank_name = ?'); values.push(data.bank_name || null); }
if (data.bank_account !== undefined) { fields.push('bank_account = ?'); values.push(data.bank_account || null); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (fields.length === 0) return findById(id);
values.push(id);
await db.query(`UPDATE vendors SET ${fields.join(', ')} WHERE vendor_id = ?`, values);
return findById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query('UPDATE vendors SET is_active = FALSE WHERE vendor_id = ?', [id]);
}
module.exports = { findAll, findById, create, update, deactivate };

View File

@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { requireAuth, requireAdmin } = require('../middleware/auth');
const ctrl = require('../controllers/consumableItemController');
const { consumableUpload } = require('../middleware/upload');
router.use(requireAuth);
router.get('/', ctrl.list);
router.get('/:id', ctrl.getById);
router.post('/', requireAdmin, consumableUpload.single('photo'), ctrl.create);
router.put('/:id', requireAdmin, consumableUpload.single('photo'), ctrl.update);
router.delete('/:id', requireAdmin, ctrl.deactivate);
module.exports = router;

View File

@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { requireAuth, requireAdmin } = require('../middleware/auth');
const ctrl = require('../controllers/vendorController');
router.use(requireAuth);
router.get('/', ctrl.list);
router.get('/:id', ctrl.getById);
router.post('/', requireAdmin, ctrl.create);
router.put('/:id', requireAdmin, ctrl.update);
router.delete('/:id', requireAdmin, ctrl.deactivate);
module.exports = router;