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:
@@ -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: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user