feat(purchase): 구매 취소/반품 + 입고일 기준 월별 분석

- 상태 추가: cancelled(구매취소), returned(반품)
- API: PUT /:id/cancel, /:id/return, /:id/revert-cancel
- 데스크탑: 구매완료→취소 버튼, 입고완료→반품 버튼, 취소→되돌리기
- 분석 페이지: 구매일/입고일 기준 전환 토글, 입고일 기준 월간 분류 집계 + 입고 목록
- Settlement API: GET /received-summary, /received-list (입고일 기준)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-01 11:07:19 +09:00
parent 2c032bd9ea
commit 7c1369a1be
12 changed files with 352 additions and 37 deletions

View File

@@ -358,6 +358,73 @@ const PurchaseRequestController = {
logger.error('PurchaseRequest receive error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구매 취소 (purchased → cancelled)
cancel: 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 { cancel_reason } = req.body;
const updated = await PurchaseRequestModel.cancelPurchase(req.params.id, {
cancelledBy: req.user.id,
cancelReason: cancel_reason
});
res.json({ success: true, data: updated, message: '구매가 취소되었습니다.' });
} catch (err) {
logger.error('PurchaseRequest cancel error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 반품 (received → returned)
returnItem: 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 !== 'received') {
return res.status(400).json({ success: false, message: '입고완료 상태의 신청만 반품할 수 있습니다.' });
}
const { cancel_reason } = req.body;
const updated = await PurchaseRequestModel.returnItem(req.params.id, {
cancelledBy: req.user.id,
cancelReason: cancel_reason
});
// 신청자에게 반품 알림
notifyHelper.send({
type: 'purchase',
title: '소모품 반품 처리',
message: `${existing.item_name || existing.custom_item_name} 반품 처리되었습니다.${cancel_reason ? ' 사유: ' + cancel_reason : ''}`,
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 return error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 취소 → 대기로 되돌리기
revertCancel: 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 !== 'cancelled') {
return res.status(400).json({ success: false, message: '취소 상태의 신청만 되돌릴 수 있습니다.' });
}
const updated = await PurchaseRequestModel.revertCancel(req.params.id);
res.json({ success: true, data: updated, message: '대기 상태로 되돌렸습니다.' });
} catch (err) {
logger.error('PurchaseRequest revertCancel error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};

View File

@@ -46,6 +46,32 @@ const SettlementController = {
}
},
// 입고일 기준 월간 요약
getMonthlyReceivedSummary: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const categorySummary = await SettlementModel.getCategorySummaryByReceived(year_month);
res.json({ success: true, data: { categorySummary } });
} catch (err) {
logger.error('Settlement received summary error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 입고일 기준 월간 상세 목록
getMonthlyReceivedList: async (req, res) => {
try {
const { year_month } = req.query;
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
const rows = await SettlementModel.getMonthlyReceived(year_month);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Settlement received list error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 정산 완료
complete: async (req, res) => {
try {

View File

@@ -0,0 +1,12 @@
-- 소모품 구매 취소/반품 지원 + 입고일 관리
-- 1. purchase_requests.status ENUM에 cancelled, returned 추가
ALTER TABLE purchase_requests
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','cancelled','returned','hold') DEFAULT 'pending'
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 취소, 반품, 보류';
-- 2. 취소/반품 관련 컬럼 추가
ALTER TABLE purchase_requests
ADD COLUMN cancelled_at TIMESTAMP NULL COMMENT '취소 시각' AFTER received_by,
ADD COLUMN cancelled_by INT NULL COMMENT '취소 처리자' AFTER cancelled_at,
ADD COLUMN cancel_reason TEXT NULL COMMENT '취소/반품 사유' AFTER cancelled_by;

View File

@@ -233,6 +233,43 @@ const PurchaseRequestModel = {
[batchId]
);
return rows.map(r => r.requester_id);
},
// 구매 취소 (purchased → pending 복원, batch에서도 제거)
async cancelPurchase(requestId, { cancelledBy, cancelReason }) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'cancelled', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?,
batch_id = NULL
WHERE request_id = ? AND status = 'purchased'`,
[cancelledBy, cancelReason || null, requestId]
);
return this.getById(requestId);
},
// 반품 (received → returned)
async returnItem(requestId, { cancelledBy, cancelReason }) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'returned', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?
WHERE request_id = ? AND status = 'received'`,
[cancelledBy, cancelReason || null, requestId]
);
return this.getById(requestId);
},
// 취소/반품에서 원래 상태로 되돌리기
async revertCancel(requestId) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests
SET status = 'pending', cancelled_at = NULL, cancelled_by = NULL, cancel_reason = NULL
WHERE request_id = ? AND status = 'cancelled'`,
[requestId]
);
return this.getById(requestId);
}
};

View File

@@ -83,6 +83,47 @@ const SettlementModel = {
return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' };
},
// 입고일 기준 월간 분류별 요약
async getCategorySummaryByReceived(yearMonth) {
const db = await getDb();
const [rows] = await db.query(`
SELECT ci.category,
COUNT(*) AS count,
SUM(pr.quantity * COALESCE(p.unit_price, 0)) AS total_amount
FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN purchases p ON p.request_id = pr.request_id
WHERE pr.status = 'received'
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
GROUP BY ci.category
`, [yearMonth]);
return rows;
},
// 입고일 기준 월간 상세 목록
async getMonthlyReceived(yearMonth) {
const db = await getDb();
const [rows] = await db.query(`
SELECT pr.request_id, pr.quantity, pr.received_at, pr.received_location,
pr.received_photo_path, pr.status, pr.notes,
ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.base_price,
p.unit_price, p.purchase_date, p.vendor_id,
v.vendor_name,
su.name AS requester_name,
rsu.name AS received_by_name
FROM purchase_requests pr
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN purchases p ON p.request_id = pr.request_id
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
WHERE pr.status IN ('received', 'returned')
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
ORDER BY pr.received_at DESC
`, [yearMonth]);
return rows;
},
// 가격 변동 목록 (월간)
async getPriceChanges(yearMonth) {
const db = await getDb();

View File

@@ -25,6 +25,9 @@ router.post('/', ctrl.create);
router.put('/:id/hold', requirePage('factory_purchases'), ctrl.hold);
router.put('/:id/revert', requirePage('factory_purchases'), ctrl.revert);
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
router.put('/:id/cancel', requirePage('factory_purchases'), ctrl.cancel);
router.put('/:id/return', requirePage('factory_purchases'), ctrl.returnItem);
router.put('/:id/revert-cancel', requirePage('factory_purchases'), ctrl.revertCancel);
router.delete('/:id', ctrl.delete);
module.exports = router;

View File

@@ -8,6 +8,8 @@ const requirePage = createRequirePage(getDb);
router.get('/summary', ctrl.getMonthlySummary);
router.get('/purchases', ctrl.getMonthlyPurchases);
router.get('/price-changes', ctrl.getPriceChanges);
router.get('/received-summary', ctrl.getMonthlyReceivedSummary);
router.get('/received-list', ctrl.getMonthlyReceivedList);
router.post('/complete', requirePage('factory_settlements'), ctrl.complete);
router.post('/cancel', requirePage('factory_settlements'), ctrl.cancel);