- 4단계 상태 플로우: pending → grouped → purchased → received - 한국어 스마트 검색: 초성 매칭(ㅁㅈㄱ→면장갑), 별칭 테이블, 인메모리 캐시 - 모바일 전용 신청 페이지: 바텀시트 UI, FAB, 카드 리스트, 스크롤 페이지네이션 - 인라인 품목 등록: 미등록 품목 검색→등록→신청 단일 트랜잭션 - 관리자 그룹화: 체크박스 다중 선택, 구매 그룹(batch) 생성/일괄 구매/입고 - 입고 처리: 사진+보관위치 등록, 부분 입고 허용, batch 자동 상태 전환 - 알림: notifyHelper에 target_user_ids 추가, 구매진행중/입고완료 시 신청자 ntfy+push Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
|
|
// 스마트 검색 (초성 + 별칭 + 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;
|