feat(purchase): 구매신청 검색/직접입력/사진첨부/HEIC 지원/마스터 자동등록

- 소모품 select → 검색형 드롭다운 (debounce + 키보드 탐색)
- 미등록 품목 직접 입력 + 분류 선택 지원
- 사진 첨부 (base64 업로드, HEIC→JPEG 프론트 변환)
- 구매 처리 시 미등록 품목 소모품 마스터 자동 등록
- item_id NULL 허용, LEFT JOIN, custom_item_name/custom_category/photo_path 컬럼
- DB 마이그레이션 필요: ALTER TABLE purchase_requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 21:49:41 +09:00
parent 13e177e818
commit cae735f243
8 changed files with 414 additions and 69 deletions

View File

@@ -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

View File

@@ -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));

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 };