diff --git a/system1-factory/api/controllers/purchaseRequestController.js b/system1-factory/api/controllers/purchaseRequestController.js index 2769f90..2e6ae05 100644 --- a/system1-factory/api/controllers/purchaseRequestController.js +++ b/system1-factory/api/controllers/purchaseRequestController.js @@ -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: '서버 오류가 발생했습니다.' }); + } } }; diff --git a/system1-factory/api/controllers/settlementController.js b/system1-factory/api/controllers/settlementController.js index 484329b..f73f397 100644 --- a/system1-factory/api/controllers/settlementController.js +++ b/system1-factory/api/controllers/settlementController.js @@ -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 { diff --git a/system1-factory/api/db/migrations/20260401_purchase_cancel_return.sql b/system1-factory/api/db/migrations/20260401_purchase_cancel_return.sql new file mode 100644 index 0000000..2b67347 --- /dev/null +++ b/system1-factory/api/db/migrations/20260401_purchase_cancel_return.sql @@ -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; diff --git a/system1-factory/api/models/purchaseRequestModel.js b/system1-factory/api/models/purchaseRequestModel.js index 171bfe8..812ddd1 100644 --- a/system1-factory/api/models/purchaseRequestModel.js +++ b/system1-factory/api/models/purchaseRequestModel.js @@ -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); } }; diff --git a/system1-factory/api/models/settlementModel.js b/system1-factory/api/models/settlementModel.js index e25004e..cec955f 100644 --- a/system1-factory/api/models/settlementModel.js +++ b/system1-factory/api/models/settlementModel.js @@ -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(); diff --git a/system1-factory/api/routes/purchaseRequestRoutes.js b/system1-factory/api/routes/purchaseRequestRoutes.js index c98dcb8..ca46250 100644 --- a/system1-factory/api/routes/purchaseRequestRoutes.js +++ b/system1-factory/api/routes/purchaseRequestRoutes.js @@ -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; diff --git a/system1-factory/api/routes/settlementRoutes.js b/system1-factory/api/routes/settlementRoutes.js index 29a54aa..f685eb0 100644 --- a/system1-factory/api/routes/settlementRoutes.js +++ b/system1-factory/api/routes/settlementRoutes.js @@ -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); diff --git a/system1-factory/web/pages/admin/purchase-analysis.html b/system1-factory/web/pages/admin/purchase-analysis.html index 2c5af7c..c38870c 100644 --- a/system1-factory/web/pages/admin/purchase-analysis.html +++ b/system1-factory/web/pages/admin/purchase-analysis.html @@ -38,9 +38,13 @@ - -
+ +
+
+ + +
@@ -104,10 +108,34 @@
+ + + - - + + diff --git a/system1-factory/web/pages/purchase/request.html b/system1-factory/web/pages/purchase/request.html index ee789e6..88a3c7d 100644 --- a/system1-factory/web/pages/purchase/request.html +++ b/system1-factory/web/pages/purchase/request.html @@ -117,6 +117,8 @@ + +