Files
tk-factory-services/system1-factory/web/static/js/purchase-analysis.js
Hyungi Ahn 4063eba5bb feat(purchase): 카테고리 테이블 분리 + 동적 로드 + tkuser 관리
- DB: consumable_categories 테이블 생성, ENUM→VARCHAR 변환, 시드 4개
- API: GET/POST/PUT/DEACTIVATE /api/consumable-categories
- 프론트: 3개 JS 하드코딩 CAT_LABELS 제거 → API loadCategories() 동적 로드
- tkuser: 카테고리 관리 섹션 추가, select 옵션 동적 생성
- 별칭 시드 SQL (INSERT IGNORE 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:07:14 +09:00

256 lines
13 KiB
JavaScript

/* ===== 구매 분석 페이지 ===== */
// 카테고리 — API 동적 로드
let _categories = null;
async function loadCategories() {
if (_categories) return _categories;
try { const r = await api('/consumable-categories'); _categories = r.data || []; } catch(e) { _categories = []; }
return _categories;
}
function getCatLabel(code) { return (_categories || []).find(c => c.category_code === code)?.category_name || code || '-'; }
function getCatIcon(code) { return (_categories || []).find(c => c.category_code === code)?.icon || 'fa-box'; }
function getCatBgClass(code) {
const c = (_categories || []).find(x => x.category_code === code);
if (!c) return 'bg-gray-50 text-gray-700';
// Tailwind class 생성 (인라인 스타일 대신)
return '';
}
function getCatColors(code) {
const c = (_categories || []).find(x => x.category_code === code);
return c ? { bg: c.color_bg, fg: c.color_fg } : { bg: '#f3f4f6', fg: '#374151' };
}
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 {
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 cats = _categories || [];
const dataMap = {};
data.forEach(d => { dataMap[d.category] = d; });
const totalAmount = data.reduce((sum, d) => sum + Number(d.total_amount || 0), 0);
el.innerHTML = cats.map(cat => {
const d = dataMap[cat.category_code] || { count: 0, total_amount: 0 };
return `<div class="bg-white rounded-xl shadow-sm p-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background:${cat.color_bg};color:${cat.color_fg}"><i class="fas ${cat.icon} text-sm"></i></div>
<span class="text-sm font-medium text-gray-700">${escapeHtml(cat.category_name)}</span>
</div>
<div class="text-xl font-bold text-gray-800">${Number(d.total_amount || 0).toLocaleString()}<span class="text-xs font-normal text-gray-400 ml-1">원</span></div>
<div class="text-xs text-gray-500 mt-1">${d.count || 0}건</div>
</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">${dateBasis === 'purchase' ? '구매' : '입고'} 월 합계: ${totalAmount.toLocaleString()}원</span>
</div>`;
}
function renderVendorSummary(data) {
const tbody = document.getElementById('paVendorSummary');
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400">해당 월 구매 내역이 없습니다.</td></tr>';
return;
}
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 vendorName = v.vendor_name || '(업체 미지정)';
const vendorId = v.vendor_id || 0;
let actionBtn = '';
if (vendorId > 0) {
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>
<td class="px-4 py-3 text-right font-medium">${Number(v.total_amount || 0).toLocaleString()}원</td>
<td class="px-4 py-3 text-center">${statusBadge}</td>
<td class="px-4 py-3 text-center">${actionBtn}</td>
</tr>`;
}).join('');
}
function renderPurchaseList(data) {
const tbody = document.getElementById('paPurchaseList');
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(p => {
const catLabel = getCatLabel(p.category);
const catColor = getCatBgClass(p.category);
const subtotal = (p.quantity || 0) * (p.unit_price || 0);
const basePrice = Number(p.base_price || 0);
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>
<div class="text-xs text-gray-500">${escapeHtml(p.maker || '')}</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">${p.quantity}</td>
<td class="px-4 py-3 text-right ${priceDiffClass}">${unitPrice.toLocaleString()}${hasPriceDiff ? `<div class="text-xs text-gray-400">(기준: ${basePrice.toLocaleString()})</div>` : ''}</td>
<td class="px-4 py-3 text-right font-medium">${subtotal.toLocaleString()}원</td>
<td class="px-4 py-3 text-gray-600">${escapeHtml(p.vendor_name || '-')}</td>
<td class="px-4 py-3 text-gray-600">${formatDate(p.purchase_date)}</td>
<td class="px-4 py-3 text-gray-500 text-xs">${escapeHtml(p.notes || '')}</td>
</tr>`;
}).join('');
}
function renderPriceChanges(data) {
const el = document.getElementById('paPriceChanges');
if (!data.length) {
el.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">가격 변동 항목이 없습니다.</p>';
return;
}
el.innerHTML = `<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-right">기준가</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>
</tr>
</thead>
<tbody class="divide-y">${data.map(p => {
const diff = Number(p.unit_price) - Number(p.base_price);
const arrow = diff > 0 ? '&#9650;' : '&#9660;';
const color = diff > 0 ? 'text-red-600' : 'text-blue-600';
return `<tr class="hover:bg-gray-50">
<td class="px-4 py-3">${escapeHtml(p.item_name)}${p.spec ? ' [' + escapeHtml(p.spec) + ']' : ''} ${p.maker ? '(' + escapeHtml(p.maker) + ')' : ''}</td>
<td class="px-4 py-3 text-right">${Number(p.base_price).toLocaleString()}원</td>
<td class="px-4 py-3 text-right font-medium ${color}">${Number(p.unit_price).toLocaleString()}원</td>
<td class="px-4 py-3 text-right ${color}">${arrow} ${Math.abs(diff).toLocaleString()}원</td>
<td class="px-4 py-3 text-gray-600">${escapeHtml(p.vendor_name || '-')}</td>
<td class="px-4 py-3 text-gray-600">${formatDate(p.purchase_date)}</td>
</tr>`;
}).join('')}</tbody>
</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 = getCatLabel(r.category);
const catColor = getCatBgClass(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 })
});
showToast('정산 완료 처리되었습니다.');
await loadAnalysis();
} catch (e) { showToast(e.message, 'error'); }
}
async function cancelSettlement(vendorId) {
if (!confirm('정산 완료를 취소하시겠습니까?')) return;
try {
await api('/settlements/cancel', {
method: 'POST', body: JSON.stringify({ year_month: currentYearMonth, vendor_id: vendorId })
});
showToast('정산이 취소되었습니다.');
await loadAnalysis();
} catch (e) { showToast(e.message, 'error'); }
}
/* ===== Init ===== */
(async function() {
if (!await initAuth()) return;
await loadCategories();
const now = new Date();
document.getElementById('paMonth').value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
})();