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

@@ -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();