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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user