feat(sprint005): 월간 확인 워크플로우 — 관리자 확인요청 + 수정요청
- DB: status ENUM 확장 (review_sent, change_request) + reviewed_by/at, change_details - API: POST /review-send (일괄 확인요청), POST /review-respond (수정 승인/거부) - 작업자: pending=검토대기, review_sent=확인/수정요청, rejected=동의(재확인) - 관리자: 필터 탭 확장 + 확인요청 일괄 발송 버튼 - confirm 상태 전환 검증: pending→confirmed 차단 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,23 +86,37 @@ const MonthlyComparisonModel = {
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 기존 상태 체크
|
||||
// 기존 상태 체크 + 전환 검증
|
||||
const [existing] = await conn.query(
|
||||
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
||||
[data.user_id, data.year, data.month]
|
||||
);
|
||||
if (existing.length > 0 && existing[0].status === 'confirmed') {
|
||||
const currentStatus = existing.length > 0 ? existing[0].status : null;
|
||||
|
||||
if (currentStatus === 'confirmed') {
|
||||
await conn.rollback();
|
||||
return { error: '이미 확인된 내역은 변경할 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 작업자 확인: review_sent 또는 rejected 상태에서만 가능
|
||||
if (data.status === 'confirmed' && currentStatus && currentStatus !== 'review_sent' && currentStatus !== 'rejected') {
|
||||
await conn.rollback();
|
||||
return { error: '관리자 확인요청 후에 확인할 수 있습니다.' };
|
||||
}
|
||||
|
||||
// 작업자 수정요청: review_sent 상태에서만 가능
|
||||
if (data.status === 'change_request' && currentStatus !== 'review_sent') {
|
||||
await conn.rollback();
|
||||
return { error: '확인요청 상태에서만 수정요청이 가능합니다.' };
|
||||
}
|
||||
|
||||
// UPSERT
|
||||
const [result] = await conn.query(`
|
||||
INSERT INTO monthly_work_confirmations
|
||||
(user_id, year, month, status, total_work_days, total_work_hours,
|
||||
total_overtime_hours, vacation_days, mismatch_count, reject_reason,
|
||||
confirmed_at, rejected_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
confirmed_at, rejected_at, change_details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
total_work_days = VALUES(total_work_days),
|
||||
@@ -112,20 +126,22 @@ const MonthlyComparisonModel = {
|
||||
mismatch_count = VALUES(mismatch_count),
|
||||
reject_reason = VALUES(reject_reason),
|
||||
confirmed_at = VALUES(confirmed_at),
|
||||
rejected_at = VALUES(rejected_at)
|
||||
rejected_at = VALUES(rejected_at),
|
||||
change_details = VALUES(change_details)
|
||||
`, [
|
||||
data.user_id, data.year, data.month, data.status,
|
||||
data.total_work_days || 0, data.total_work_hours || 0,
|
||||
data.total_overtime_hours || 0, data.vacation_days || 0,
|
||||
data.mismatch_count || 0, data.reject_reason || null,
|
||||
data.status === 'confirmed' ? new Date() : null,
|
||||
data.status === 'rejected' ? new Date() : null
|
||||
data.status === 'rejected' ? new Date() : null,
|
||||
data.change_details || null
|
||||
]);
|
||||
|
||||
const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null);
|
||||
|
||||
// 반려 시 알림 생성
|
||||
if (data.status === 'rejected' && notificationData && confirmationId) {
|
||||
// 알림 생성 (반려 또는 수정요청)
|
||||
if (notificationData && confirmationId) {
|
||||
const { recipients, title, message, linkUrl, createdBy } = notificationData;
|
||||
for (const recipientId of recipients) {
|
||||
await conn.query(`
|
||||
@@ -146,6 +162,100 @@ const MonthlyComparisonModel = {
|
||||
}
|
||||
},
|
||||
|
||||
// 관리자: 확인요청 발송 (pending → review_sent)
|
||||
async bulkReviewSend(year, month, userIds, reviewedBy) {
|
||||
const db = await getDb();
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 대상 작업자 결정 (userIds 있으면 단건, 없으면 pending 전체)
|
||||
let targetIds = userIds || [];
|
||||
if (!targetIds.length) {
|
||||
const [pendingRows] = await conn.query(
|
||||
`SELECT DISTINCT w.user_id FROM workers w
|
||||
LEFT JOIN monthly_work_confirmations mwc ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
|
||||
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||
AND (mwc.status IS NULL OR mwc.status = 'pending')`,
|
||||
[year, month]
|
||||
);
|
||||
targetIds = pendingRows.map(r => r.user_id);
|
||||
}
|
||||
|
||||
if (!targetIds.length) {
|
||||
await conn.rollback();
|
||||
return { count: 0, message: '대상 작업자가 없습니다.' };
|
||||
}
|
||||
|
||||
// 상태 전환 + 알림 생성
|
||||
for (const uid of targetIds) {
|
||||
await conn.query(
|
||||
`INSERT INTO monthly_work_confirmations (user_id, year, month, status, reviewed_by, reviewed_at)
|
||||
VALUES (?, ?, ?, 'review_sent', ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE status = 'review_sent', reviewed_by = ?, reviewed_at = NOW()`,
|
||||
[uid, year, month, reviewedBy, reviewedBy]
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, is_read, created_by)
|
||||
VALUES (?, 'system', '월간 근무 확인 요청', ?, '/pages/attendance/my-monthly-confirm.html?year=${year}&month=${month}', 'monthly_work_confirmation', 0, ?)`,
|
||||
[uid, `${year}년 ${month}월 근무 내역을 확인해주세요.`, reviewedBy]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
return { count: targetIds.length };
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
|
||||
async reviewRespond(userId, year, month, action, rejectReason, respondedBy) {
|
||||
const db = await getDb();
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [existing] = await conn.query(
|
||||
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
||||
[userId, year, month]
|
||||
);
|
||||
if (!existing.length || existing[0].status !== 'change_request') {
|
||||
await conn.rollback();
|
||||
return { error: '수정요청 상태가 아닙니다.' };
|
||||
}
|
||||
|
||||
var newStatus = action === 'approve' ? 'review_sent' : 'rejected';
|
||||
await conn.query(
|
||||
`UPDATE monthly_work_confirmations SET status = ?, reviewed_by = ?, reviewed_at = NOW(),
|
||||
reject_reason = ?, change_details = NULL WHERE id = ?`,
|
||||
[newStatus, respondedBy, action === 'reject' ? rejectReason : null, existing[0].id]
|
||||
);
|
||||
|
||||
// 작업자에게 알림
|
||||
var title = action === 'approve' ? '수정요청 승인' : '수정요청 거부';
|
||||
var message = action === 'approve'
|
||||
? `${year}년 ${month}월 근무 수정이 반영되었습니다. 다시 확인해주세요.`
|
||||
: `${year}년 ${month}월 근무 수정요청이 거부되었습니다. 사유: ${rejectReason || '-'}`;
|
||||
await conn.query(
|
||||
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
|
||||
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)`,
|
||||
[userId, title, message, '/pages/attendance/my-monthly-confirm.html?year=' + year + '&month=' + month, existing[0].id, respondedBy]
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
return { status: newStatus };
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
// 5. 전체 작업자 확인 현황
|
||||
async getAllStatus(year, month, departmentId) {
|
||||
const db = await getDb();
|
||||
|
||||
Reference in New Issue
Block a user