feat(tkuser): 협력업체 완전삭제 기능 추가 (admin 전용)

- 관련 데이터 cascade 삭제 (workers, schedules, checkins, reports, SSO 계정 등)
- 구매 이력 있는 업체는 삭제 차단
- 프론트엔드: 목록/상세에 완전삭제 버튼 + prompt("삭제") 안전장치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-16 07:58:39 +09:00
parent 73bd13a7cd
commit 8ed0b832ab
5 changed files with 169 additions and 4 deletions

View File

@@ -119,7 +119,32 @@ async function deactivateWorker(req, res) {
}
}
async function getDeleteInfo(req, res) {
try {
const company = await partnerModel.findById(req.params.id);
if (!company) return res.status(404).json({ success: false, error: '업체를 찾을 수 없습니다' });
const info = await partnerModel.getDeleteInfo(req.params.id);
res.json({ success: true, data: { company_name: company.company_name, ...info } });
} catch (err) {
console.error('Partner getDeleteInfo error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
async function permanentDelete(req, res) {
try {
const company = await partnerModel.findById(req.params.id);
if (!company) return res.status(404).json({ success: false, error: '업체를 찾을 수 없습니다' });
await partnerModel.permanentDelete(req.params.id, req.user.id);
res.json({ success: true, message: `"${company.company_name}" 업체가 완전히 삭제되었습니다` });
} catch (err) {
console.error('Partner permanentDelete error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
module.exports = {
list, getById, create, update, deactivate,
listWorkers, createWorker, updateWorker, deactivateWorker
listWorkers, createWorker, updateWorker, deactivateWorker,
getDeleteInfo, permanentDelete
};

View File

@@ -109,7 +109,108 @@ async function deactivateWorker(id) {
await db.query('UPDATE partner_workers SET is_active = FALSE WHERE id = ?', [id]);
}
async function getDeleteInfo(companyId) {
const db = getPool();
const [[workers]] = await db.query('SELECT COUNT(*) as cnt FROM partner_workers WHERE company_id = ?', [companyId]);
const [[schedules]] = await db.query('SELECT COUNT(*) as cnt FROM partner_schedules WHERE company_id = ?', [companyId]);
const [[checkins]] = await db.query('SELECT COUNT(*) as cnt FROM partner_work_checkins WHERE company_id = ?', [companyId]);
const [[reports]] = await db.query('SELECT COUNT(*) as cnt FROM partner_work_reports WHERE company_id = ?', [companyId]);
const [[visits]] = await db.query('SELECT COUNT(*) as cnt FROM daily_visits WHERE company_id = ?', [companyId]);
const [[accounts]] = await db.query('SELECT COUNT(*) as cnt FROM sso_users WHERE partner_company_id = ?', [companyId]);
// 삭제 차단 조건: 해당 업체 SSO 계정이 구매 이력을 가지고 있는지
const [userRows] = await db.query('SELECT id FROM sso_users WHERE partner_company_id = ?', [companyId]);
const userIds = userRows.map(r => r.id);
let purchaseRequests = 0;
let purchases = 0;
if (userIds.length > 0) {
const [[pr]] = await db.query('SELECT COUNT(*) as cnt FROM purchase_requests WHERE requester_id IN (?)', [userIds]);
const [[pu]] = await db.query('SELECT COUNT(*) as cnt FROM purchases WHERE purchaser_id IN (?)', [userIds]);
purchaseRequests = pr.cnt;
purchases = pu.cnt;
}
return {
workers: workers.cnt,
schedules: schedules.cnt,
checkins: checkins.cnt,
reports: reports.cnt,
visits: visits.cnt,
accounts: accounts.cnt,
purchaseRequests,
purchases
};
}
async function permanentDelete(companyId, requestUserId) {
const db = getPool();
// 사전 체크: 구매 이력 확인
const [userRows] = await db.query('SELECT id FROM sso_users WHERE partner_company_id = ?', [companyId]);
const userIds = userRows.map(r => r.id);
if (userIds.length > 0) {
const [[pr]] = await db.query('SELECT COUNT(*) as cnt FROM purchase_requests WHERE requester_id IN (?)', [userIds]);
const [[pu]] = await db.query('SELECT COUNT(*) as cnt FROM purchases WHERE purchaser_id IN (?)', [userIds]);
if (pr.cnt > 0 || pu.cnt > 0) {
throw new Error('구매 이력이 있어 삭제할 수 없습니다. 비활성화를 이용해주세요.');
}
}
// 작업자 ID 조회
const [workerRows] = await db.query('SELECT id FROM partner_workers WHERE company_id = ?', [companyId]);
const workerIds = workerRows.map(r => r.id);
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 1. work_report_workers에서 해당 업체 작업자 참조 SET NULL
if (workerIds.length > 0) {
await conn.query('UPDATE work_report_workers SET partner_worker_id = NULL WHERE partner_worker_id IN (?)', [workerIds]);
}
// 2. partner_work_reports 삭제 (→ work_report_workers CASCADE 자동)
await conn.query('DELETE FROM partner_work_reports WHERE company_id = ?', [companyId]);
// 3. partner_work_checkins 삭제
await conn.query('DELETE FROM partner_work_checkins WHERE company_id = ?', [companyId]);
// 4. partner_schedules 삭제
await conn.query('DELETE FROM partner_schedules WHERE company_id = ?', [companyId]);
// 5. daily_visit_workers에서 해당 업체 작업자 참조 SET NULL
if (workerIds.length > 0) {
await conn.query('UPDATE daily_visit_workers SET partner_worker_id = NULL WHERE partner_worker_id IN (?)', [workerIds]);
}
// 6. daily_visits는 SET NULL 자동 (방문기록 보존)
// 7. partner_workers 삭제
await conn.query('DELETE FROM partner_workers WHERE company_id = ?', [companyId]);
// 8. 협력업체 SSO 계정 처리
if (userIds.length > 0) {
await conn.query('UPDATE sp_vacation_requests SET reviewed_by = NULL WHERE reviewed_by IN (?)', [userIds]);
await conn.query('DELETE FROM sp_vacation_requests WHERE user_id IN (?)', [userIds]);
await conn.query('UPDATE sp_vacation_balances SET created_by = ? WHERE created_by IN (?)', [requestUserId, userIds]);
await conn.query('DELETE FROM sp_vacation_balances WHERE user_id IN (?)', [userIds]);
await conn.query('DELETE FROM sso_users WHERE partner_company_id = ?', [companyId]);
}
// 9. partner_companies 삭제
await conn.query('DELETE FROM partner_companies WHERE id = ?', [companyId]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
module.exports = {
findAll, findById, create, update, deactivate,
findWorkersByCompany, findWorkerById, createWorker, updateWorker, deactivateWorker
findWorkersByCompany, findWorkerById, createWorker, updateWorker, deactivateWorker,
getDeleteInfo, permanentDelete
};

View File

@@ -6,6 +6,8 @@ const ctrl = require('../controllers/partnerController');
router.use(requireAuth);
router.get('/', ctrl.list);
router.get('/:id/delete-info', requireAdmin, ctrl.getDeleteInfo);
router.delete('/:id/permanent', requireAdmin, ctrl.permanentDelete);
router.get('/:id', ctrl.getById);
router.post('/', requireAdmin, ctrl.create);
router.put('/:id', requireAdmin, ctrl.update);

View File

@@ -2009,7 +2009,7 @@
<script src="/static/js/tkuser-tasks.js?v=2026031401"></script>
<script src="/static/js/tkuser-vacations.js?v=2026031401"></script>
<script src="/static/js/tkuser-layout-map.js?v=2026031401"></script>
<script src="/static/js/tkuser-partners.js?v=2026031401"></script>
<script src="/static/js/tkuser-partners.js?v=2026031601"></script>
<script src="/static/js/tkuser-vendors.js?v=2026031401"></script>
<script src="/static/js/tkuser-consumables.js?v=2026031401"></script>
<script src="/static/js/tkuser-notificationRecipients.js?v=2026031401"></script>

View File

@@ -56,6 +56,7 @@ function renderPartnersListTkuser() {
${isAdmin ? `<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="event.stopPropagation(); openEditPartnerTkuser(${p.id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="수정"><i class="fas fa-pen text-xs"></i></button>
${p.is_active ? `<button onclick="event.stopPropagation(); deactivatePartnerTkuser(${p.id}, '${escHtml(p.company_name).replace(/'/g, "\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
<button onclick="event.stopPropagation(); hardDeletePartnerTkuser(${p.id})" class="p-1.5 text-red-300 hover:text-red-600 hover:bg-red-100 rounded" title="완전삭제"><i class="fas fa-trash text-xs"></i></button>
</div>` : ''}
</div>`;
}).join('');
@@ -82,7 +83,10 @@ function renderPartnerDetailTkuser(p) {
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
document.getElementById('partnerDetailTkuser').innerHTML = `
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<h3 class="text-lg font-semibold text-gray-800 mb-3">${escHtml(p.company_name)}</h3>
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-800">${escHtml(p.company_name)}</h3>
${isAdmin ? `<button onclick="hardDeletePartnerTkuser(${p.id})" class="px-2.5 py-1 text-xs text-red-400 hover:text-red-600 hover:bg-red-50 rounded border border-red-200" title="완전삭제"><i class="fas fa-trash mr-1"></i>완전삭제</button>` : ''}
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-500">사업자번호:</span> <span class="font-medium">${escHtml(p.business_number) || '-'}</span></div>
<div><span class="text-gray-500">대표자:</span> <span class="font-medium">${escHtml(p.representative) || '-'}</span></div>
@@ -121,6 +125,39 @@ function renderPartnerDetailTkuser(p) {
</div>`;
}
/* ===== 업체 완전삭제 (admin) ===== */
async function hardDeletePartnerTkuser(id) {
try {
const r = await api(`/partners/${id}/delete-info`);
const info = r.data;
if (info.purchaseRequests > 0 || info.purchases > 0) {
alert(`"${info.company_name}" 업체는 구매 이력이 있어 삭제할 수 없습니다.\n(구매요청 ${info.purchaseRequests}건, 구매 ${info.purchases}건)\n\n비활성화를 이용해주세요.`);
return;
}
const lines = [];
if (info.workers > 0) lines.push(`작업자 ${info.workers}`);
if (info.schedules > 0) lines.push(`스케줄 ${info.schedules}`);
if (info.checkins > 0) lines.push(`출근기록 ${info.checkins}`);
if (info.reports > 0) lines.push(`작업보고 ${info.reports}`);
if (info.visits > 0) lines.push(`방문기록 ${info.visits}건 (보존, 업체연결 해제)`);
if (info.accounts > 0) lines.push(`SSO 계정 ${info.accounts}`);
const summary = lines.length > 0 ? `\n\n삭제될 데이터:\n- ${lines.join('\n- ')}` : '\n\n관련 데이터가 없습니다.';
const input = prompt(`"${info.company_name}" 업체를 완전히 삭제합니다.\n이 작업은 되돌릴 수 없습니다.${summary}\n\n계속하려면 "삭제"를 입력하세요:`);
if (input !== '삭제') {
if (input !== null) showToast('삭제가 취소되었습니다', 'error');
return;
}
await api(`/partners/${id}/permanent`, { method: 'DELETE' });
showToast('업체가 완전히 삭제되었습니다');
await loadPartnersList();
if (selectedPartnerIdTkuser === id) {
document.getElementById('partnerDetailTkuser').classList.add('hidden');
document.getElementById('partnerEmptyTkuser').classList.remove('hidden');
selectedPartnerIdTkuser = null;
}
} catch (e) { showToast(e.message, 'error'); }
}
/* ===== 업체 등록 ===== */
function openAddPartnerTkuser() { document.getElementById('addPartnerModalTkuser').classList.remove('hidden'); }
function closeAddPartnerTkuser() { document.getElementById('addPartnerModalTkuser').classList.add('hidden'); document.getElementById('addPartnerFormTkuser').reset(); }