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);
|
||||
|
||||
|
||||
@@ -38,9 +38,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월 선택 -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<!-- 월 선택 + 기준일 전환 -->
|
||||
<div class="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<input type="month" id="paMonth" class="px-3 py-2 border rounded-lg text-sm">
|
||||
<div class="flex rounded-lg border overflow-hidden text-sm">
|
||||
<button id="btnDatePurchase" onclick="setDateBasis('purchase')" class="px-3 py-2 bg-orange-600 text-white">구매일</button>
|
||||
<button id="btnDateReceived" onclick="setDateBasis('received')" class="px-3 py-2 bg-white text-gray-600 hover:bg-gray-50">입고일</button>
|
||||
</div>
|
||||
<button onclick="loadAnalysis()" class="px-4 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">
|
||||
<i class="fas fa-search mr-1"></i>조회
|
||||
</button>
|
||||
@@ -104,10 +108,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 입고 목록 (입고일 기준 모드에서만 표시) -->
|
||||
<div id="paReceivedSection" class="hidden bg-white rounded-xl shadow-sm p-5 mt-6">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-box-open text-teal-500 mr-2"></i>월간 입고 내역</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">품목</th>
|
||||
<th class="px-4 py-3 text-left">분류</th>
|
||||
<th class="px-4 py-3 text-right">수량</th>
|
||||
<th class="px-4 py-3 text-right">단가</th>
|
||||
<th class="px-4 py-3 text-left">업체</th>
|
||||
<th class="px-4 py-3 text-left">입고일</th>
|
||||
<th class="px-4 py-3 text-left">보관위치</th>
|
||||
<th class="px-4 py-3 text-left">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="paReceivedList" class="divide-y">
|
||||
<tr><td colspan="8" class="px-4 py-8 text-center text-gray-400">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
||||
<script src="/static/js/purchase-analysis.js?v=2026031602"></script>
|
||||
<script src="/static/js/tkfb-core.js?v=2026040103"></script>
|
||||
<script src="/static/js/purchase-analysis.js?v=2026040103"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
<option value="grouped">구매진행중</option>
|
||||
<option value="purchased">구매완료</option>
|
||||
<option value="received">입고완료</option>
|
||||
<option value="cancelled">취소</option>
|
||||
<option value="returned">반품</option>
|
||||
<option value="hold">보류</option>
|
||||
</select>
|
||||
<select id="prFilterCategory" class="px-3 py-2 border rounded-lg text-sm" onchange="loadRequests()">
|
||||
|
||||
@@ -2,35 +2,76 @@
|
||||
const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '수선비', equipment: '설비' };
|
||||
const CAT_ICONS = { consumable: 'fa-box', safety: 'fa-hard-hat', repair: 'fa-wrench', equipment: 'fa-cogs' };
|
||||
const CAT_BG = { consumable: 'bg-blue-50 text-blue-700', safety: 'bg-green-50 text-green-700', repair: 'bg-amber-50 text-amber-700', equipment: 'bg-purple-50 text-purple-700' };
|
||||
const STATUS_LABELS = { received: '입고완료', returned: '반품' };
|
||||
const STATUS_COLORS = { received: 'badge-teal', returned: 'badge-red' };
|
||||
|
||||
let currentYearMonth = '';
|
||||
let dateBasis = 'purchase'; // 'purchase' 또는 'received'
|
||||
|
||||
function setDateBasis(basis) {
|
||||
dateBasis = basis;
|
||||
document.getElementById('btnDatePurchase').className = basis === 'purchase'
|
||||
? 'px-3 py-2 bg-orange-600 text-white' : 'px-3 py-2 bg-white text-gray-600 hover:bg-gray-50';
|
||||
document.getElementById('btnDateReceived').className = basis === 'received'
|
||||
? 'px-3 py-2 bg-orange-600 text-white' : 'px-3 py-2 bg-white text-gray-600 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
async function loadAnalysis() {
|
||||
currentYearMonth = document.getElementById('paMonth').value;
|
||||
if (!currentYearMonth) { showToast('월을 선택해주세요.', 'error'); return; }
|
||||
|
||||
try {
|
||||
const [summaryRes, purchasesRes, priceChangesRes] = await Promise.all([
|
||||
api(`/settlements/summary?year_month=${currentYearMonth}`),
|
||||
api(`/settlements/purchases?year_month=${currentYearMonth}`),
|
||||
api(`/settlements/price-changes?year_month=${currentYearMonth}`)
|
||||
]);
|
||||
|
||||
renderCategorySummary(summaryRes.data?.categorySummary || []);
|
||||
renderVendorSummary(summaryRes.data?.vendorSummary || []);
|
||||
renderPurchaseList(purchasesRes.data || []);
|
||||
renderPriceChanges(priceChangesRes.data || []);
|
||||
if (dateBasis === 'purchase') {
|
||||
await loadPurchaseBasis();
|
||||
} else {
|
||||
await loadReceivedBasis();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('데이터 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 구매일 기준 (기존) ===== */
|
||||
async function loadPurchaseBasis() {
|
||||
// 입고 섹션 숨김
|
||||
document.getElementById('paReceivedSection').classList.add('hidden');
|
||||
|
||||
const [summaryRes, purchasesRes, priceChangesRes] = await Promise.all([
|
||||
api(`/settlements/summary?year_month=${currentYearMonth}`),
|
||||
api(`/settlements/purchases?year_month=${currentYearMonth}`),
|
||||
api(`/settlements/price-changes?year_month=${currentYearMonth}`)
|
||||
]);
|
||||
|
||||
renderCategorySummary(summaryRes.data?.categorySummary || []);
|
||||
renderVendorSummary(summaryRes.data?.vendorSummary || []);
|
||||
renderPurchaseList(purchasesRes.data || []);
|
||||
renderPriceChanges(priceChangesRes.data || []);
|
||||
}
|
||||
|
||||
/* ===== 입고일 기준 ===== */
|
||||
async function loadReceivedBasis() {
|
||||
const [summaryRes, listRes] = await Promise.all([
|
||||
api(`/settlements/received-summary?year_month=${currentYearMonth}`),
|
||||
api(`/settlements/received-list?year_month=${currentYearMonth}`)
|
||||
]);
|
||||
|
||||
renderCategorySummary(summaryRes.data?.categorySummary || []);
|
||||
// 업체/구매목록/가격변동은 빈 상태로
|
||||
document.getElementById('paVendorSummary').innerHTML = '<tr><td colspan="5" class="px-4 py-4 text-center text-gray-400">입고일 기준에서는 업체별 정산이 표시되지 않습니다.</td></tr>';
|
||||
document.getElementById('paPurchaseList').innerHTML = '<tr><td colspan="8" class="px-4 py-4 text-center text-gray-400">아래 입고 내역을 확인하세요.</td></tr>';
|
||||
document.getElementById('paPriceChanges').innerHTML = '';
|
||||
|
||||
// 입고 섹션 표시
|
||||
document.getElementById('paReceivedSection').classList.remove('hidden');
|
||||
renderReceivedList(listRes.data || []);
|
||||
}
|
||||
|
||||
/* ===== 렌더링 함수들 ===== */
|
||||
function renderCategorySummary(data) {
|
||||
const el = document.getElementById('paCategorySummary');
|
||||
const allCategories = ['consumable', 'safety', 'repair', 'equipment'];
|
||||
const dataMap = {};
|
||||
data.forEach(d => { dataMap[d.category] = d; });
|
||||
|
||||
const totalAmount = data.reduce((sum, d) => sum + Number(d.total_amount || 0), 0);
|
||||
|
||||
el.innerHTML = allCategories.map(cat => {
|
||||
@@ -48,7 +89,7 @@ function renderCategorySummary(data) {
|
||||
</div>`;
|
||||
}).join('') + `
|
||||
<div class="col-span-2 lg:col-span-4 bg-orange-50 rounded-xl p-3 text-center">
|
||||
<span class="text-sm text-orange-700 font-semibold">월 합계: ${totalAmount.toLocaleString()}원</span>
|
||||
<span class="text-sm text-orange-700 font-semibold">${dateBasis === 'purchase' ? '구매' : '입고'} 월 합계: ${totalAmount.toLocaleString()}원</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -60,21 +101,15 @@ function renderVendorSummary(data) {
|
||||
}
|
||||
tbody.innerHTML = data.map(v => {
|
||||
const isCompleted = v.settlement_status === 'completed';
|
||||
const statusBadge = isCompleted
|
||||
? '<span class="badge badge-green">정산완료</span>'
|
||||
: '<span class="badge badge-gray">미정산</span>';
|
||||
const statusBadge = isCompleted ? '<span class="badge badge-green">정산완료</span>' : '<span class="badge badge-gray">미정산</span>';
|
||||
const vendorName = v.vendor_name || '(업체 미지정)';
|
||||
const vendorId = v.vendor_id || 0;
|
||||
|
||||
let actionBtn = '';
|
||||
if (vendorId > 0) {
|
||||
if (isCompleted) {
|
||||
actionBtn = `<button onclick="cancelSettlement(${vendorId})" class="px-3 py-1 border border-gray-300 rounded text-xs text-gray-600 hover:bg-gray-50">정산 취소</button>`;
|
||||
} else {
|
||||
actionBtn = `<button onclick="completeSettlement(${vendorId})" class="px-3 py-1 bg-green-500 text-white rounded text-xs hover:bg-green-600">정산완료</button>`;
|
||||
}
|
||||
actionBtn = isCompleted
|
||||
? `<button onclick="cancelSettlement(${vendorId})" class="px-3 py-1 border border-gray-300 rounded text-xs text-gray-600 hover:bg-gray-50">정산 취소</button>`
|
||||
: `<button onclick="completeSettlement(${vendorId})" class="px-3 py-1 bg-green-500 text-white rounded text-xs hover:bg-green-600">정산완료</button>`;
|
||||
}
|
||||
|
||||
return `<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-800">${escapeHtml(vendorName)}</td>
|
||||
<td class="px-4 py-3 text-right">${v.count}건</td>
|
||||
@@ -99,7 +134,6 @@ function renderPurchaseList(data) {
|
||||
const unitPrice = Number(p.unit_price || 0);
|
||||
const hasPriceDiff = basePrice > 0 && unitPrice > 0 && basePrice !== unitPrice;
|
||||
const priceDiffClass = hasPriceDiff ? (unitPrice > basePrice ? 'text-red-600 font-semibold' : 'text-blue-600 font-semibold') : '';
|
||||
|
||||
return `<tr class="hover:bg-gray-50 ${hasPriceDiff ? 'bg-yellow-50' : ''}">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-800">${escapeHtml(p.item_name)}${p.spec ? ' <span class="text-gray-400">[' + escapeHtml(p.spec) + ']</span>' : ''}</div>
|
||||
@@ -149,13 +183,39 @@ function renderPriceChanges(data) {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderReceivedList(data) {
|
||||
const tbody = document.getElementById('paReceivedList');
|
||||
if (!data.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="px-4 py-8 text-center text-gray-400">해당 월 입고 내역이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.map(r => {
|
||||
const catLabel = CAT_LABELS[r.category] || r.category || '-';
|
||||
const catColor = CAT_BG[r.category] || '';
|
||||
const statusLabel = STATUS_LABELS[r.status] || r.status;
|
||||
const statusColor = STATUS_COLORS[r.status] || 'badge-gray';
|
||||
return `<tr class="hover:bg-gray-50 ${r.status === 'returned' ? 'bg-red-50' : ''}">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-800">${escapeHtml(r.item_name || '-')}${r.spec ? ' <span class="text-gray-400">[' + escapeHtml(r.spec) + ']</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(r.maker || '')} · ${escapeHtml(r.requester_name || '')}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="px-1.5 py-0.5 rounded text-xs ${catColor}">${catLabel}</span></td>
|
||||
<td class="px-4 py-3 text-right">${r.quantity}</td>
|
||||
<td class="px-4 py-3 text-right">${r.unit_price ? Number(r.unit_price).toLocaleString() + '원' : '-'}</td>
|
||||
<td class="px-4 py-3 text-gray-600">${escapeHtml(r.vendor_name || '-')}</td>
|
||||
<td class="px-4 py-3 text-gray-600">${formatDateTime(r.received_at)}</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-xs">${escapeHtml(r.received_location || '-')}</td>
|
||||
<td class="px-4 py-3 text-center"><span class="badge ${statusColor}">${statusLabel}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== 정산 처리 ===== */
|
||||
async function completeSettlement(vendorId) {
|
||||
if (!confirm('이 업체의 정산을 완료 처리하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/settlements/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year_month: currentYearMonth, vendor_id: vendorId })
|
||||
method: 'POST', body: JSON.stringify({ year_month: currentYearMonth, vendor_id: vendorId })
|
||||
});
|
||||
showToast('정산 완료 처리되었습니다.');
|
||||
await loadAnalysis();
|
||||
@@ -166,8 +226,7 @@ async function cancelSettlement(vendorId) {
|
||||
if (!confirm('정산 완료를 취소하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/settlements/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year_month: currentYearMonth, vendor_id: vendorId })
|
||||
method: 'POST', body: JSON.stringify({ year_month: currentYearMonth, vendor_id: vendorId })
|
||||
});
|
||||
showToast('정산이 취소되었습니다.');
|
||||
await loadAnalysis();
|
||||
@@ -177,7 +236,6 @@ async function cancelSettlement(vendorId) {
|
||||
/* ===== Init ===== */
|
||||
(async function() {
|
||||
if (!await initAuth()) return;
|
||||
// 기본값: 현재 월
|
||||
const now = new Date();
|
||||
document.getElementById('paMonth').value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
@@ -4,8 +4,8 @@ const TKUSER_BASE_URL = location.hostname.includes('technicalkorea.net')
|
||||
: location.protocol + '//' + location.hostname + ':30180';
|
||||
|
||||
const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '수선비', equipment: '설비' };
|
||||
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', hold: '보류' };
|
||||
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', hold: 'badge-gray' };
|
||||
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', cancelled: '취소', returned: '반품', hold: '보류' };
|
||||
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', cancelled: 'badge-red', returned: 'badge-red', hold: 'badge-gray' };
|
||||
const MATCH_LABELS = { exact: '정확', name: '이름', alias: '별칭', spec: '규격', chosung: '초성', chosung_alias: '초성' };
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
@@ -7,8 +7,8 @@ const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '
|
||||
const CAT_COLORS = { consumable: 'badge-blue', safety: 'badge-green', repair: 'badge-amber', equipment: 'badge-purple' };
|
||||
const CAT_BG = { consumable: '#dbeafe', safety: '#dcfce7', repair: '#fef3c7', equipment: '#f3e8ff' };
|
||||
const CAT_FG = { consumable: '#1e40af', safety: '#166534', repair: '#92400e', equipment: '#7e22ce' };
|
||||
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', hold: '보류' };
|
||||
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', hold: 'badge-gray' };
|
||||
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', cancelled: '취소', returned: '반품', hold: '보류' };
|
||||
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', cancelled: 'badge-red', returned: 'badge-red', hold: 'badge-gray' };
|
||||
|
||||
function _fmtSpec(spec) { return spec ? ' [' + spec + ']' : ''; }
|
||||
|
||||
@@ -359,7 +359,12 @@ function renderRequests() {
|
||||
actions = `<button onclick="openPurchaseModal(${r.request_id})" class="px-2 py-1 bg-orange-500 text-white rounded text-xs hover:bg-orange-600 mr-1" title="구매 처리"><i class="fas fa-shopping-cart"></i></button>
|
||||
<button onclick="openHoldModal(${r.request_id})" class="px-2 py-1 bg-gray-400 text-white rounded text-xs hover:bg-gray-500" title="보류"><i class="fas fa-pause"></i></button>`;
|
||||
} else if (isAdmin && r.status === 'purchased') {
|
||||
actions = `<button onclick="openReceiveModal(${r.request_id})" class="px-2 py-1 bg-teal-500 text-white rounded text-xs hover:bg-teal-600" title="입고 처리"><i class="fas fa-box-open"></i></button>`;
|
||||
actions = `<button onclick="openReceiveModal(${r.request_id})" class="px-2 py-1 bg-teal-500 text-white rounded text-xs hover:bg-teal-600 mr-1" title="입고 처리"><i class="fas fa-box-open"></i></button>
|
||||
<button onclick="cancelPurchase(${r.request_id})" class="px-2 py-1 bg-red-400 text-white rounded text-xs hover:bg-red-500" title="구매 취소"><i class="fas fa-times"></i></button>`;
|
||||
} else if (isAdmin && r.status === 'received') {
|
||||
actions = `<button onclick="returnItem(${r.request_id})" class="px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600" title="반품"><i class="fas fa-undo-alt"></i></button>`;
|
||||
} else if (isAdmin && r.status === 'cancelled') {
|
||||
actions = `<button onclick="revertCancel(${r.request_id})" class="px-2 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600" title="대기로 되돌리기"><i class="fas fa-undo"></i></button>`;
|
||||
} else if (isAdmin && r.status === 'hold') {
|
||||
actions = `<button onclick="revertRequest(${r.request_id})" class="px-2 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600" title="대기로 되돌리기"><i class="fas fa-undo"></i></button>`;
|
||||
}
|
||||
@@ -660,6 +665,40 @@ async function batchReceive(batchId) {
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== 구매 취소 / 반품 / 되돌리기 ===== */
|
||||
async function cancelPurchase(requestId) {
|
||||
const reason = prompt('취소 사유를 입력하세요 (선택):');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
await api(`/purchase-requests/${requestId}/cancel`, {
|
||||
method: 'PUT', body: JSON.stringify({ cancel_reason: reason || null })
|
||||
});
|
||||
showToast('구매가 취소되었습니다.');
|
||||
await loadRequests();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function returnItem(requestId) {
|
||||
const reason = prompt('반품 사유를 입력하세요:');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
await api(`/purchase-requests/${requestId}/return`, {
|
||||
method: 'PUT', body: JSON.stringify({ cancel_reason: reason || null })
|
||||
});
|
||||
showToast('반품 처리되었습니다.');
|
||||
await loadRequests();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function revertCancel(requestId) {
|
||||
if (!confirm('이 신청을 대기 상태로 되돌리시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/purchase-requests/${requestId}/revert-cancel`, { method: 'PUT' });
|
||||
showToast('대기 상태로 되돌렸습니다.');
|
||||
await loadRequests();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== 입고 처리 모달 ===== */
|
||||
let currentRequestForReceive = null;
|
||||
let receivePhotoBase64 = null;
|
||||
|
||||
Reference in New Issue
Block a user