← Về trang chủ

☁️ Setup Google Sheet Database

Hướng dẫn kết nối hệ thống phỏng vấn với Google Sheet — không cần code

📋 Tại sao cần Google Sheet?

Hiện tại mỗi lần lưu, hệ thống tạo 1 file JSON tải về máy. Khó xem lại, dễ thất lạc, không sort/filter được. Sau khi setup Google Sheet:

🚀 BƯỚC 1: Tạo Google Sheet mới

1

Truy cập sheets.new

Hoặc vào Google Drive → New → Google Sheets

2

Đặt tên Sheet: KITA Interview Database

Đặt tên ô tab (sheet con) ở dưới là PhongVan (quan trọng - không được sai)

3

Copy header row vào dòng 1

Copy đoạn dưới và paste vào ô A1 của sheet (Google Sheet sẽ tự tách cột):

Timestamp Tên ứng viên Vị trí Ngày PV HR/PV viên Vòng SĐT/Email Nguồn Scores (JSON) Notes (JSON) QA (JSON) Decision (JSON) HR Decision (JSON) Điểm TB Đề xuất Full JSON

⚙️ BƯỚC 2: Tạo Apps Script (Backend)

1

Trong Google Sheet vừa tạo: Extensions → Apps Script

Một tab mới mở ra với editor code

2

Xoá toàn bộ code mặc định, paste code dưới đây:

// ============== KITA INTERVIEW DATABASE + CV WORKFLOW ============== // Apps Script Web App v4.0 - Lưu hồ sơ PV + Upload CV + Lead review + Send/Submit test // Tác giả: KITA Interview System const SHEET_NAME = 'PhongVan'; const CV_SHEET_NAME = 'CVQueue'; const CONFIG_SHEET = 'Config'; // Sheet lưu cấu hình động (anh edit qua /quan-tri/) const API_KEY = 'KITA-MyTeam-2026'; // ⚠️ Phải khớp với cloudApiKey trong /config.js // 🎯 CÁC CẤU HÌNH KHÁC (Drive folder, Lark webhook, Lark emails, etc.) // → Đều LẤY TỪ SHEET "Config" qua hàm getConfig('key') // → Anh chỉ cần edit ở /quan-tri/ → KHÔNG cần deploy lại Apps Script // ============== READ CONFIG FROM SHEET (cached 60s) ============== let _configCache = null; let _configCacheTime = 0; function getConfig(key) { if (!_configCache || Date.now() - _configCacheTime > 60000) { refreshConfigCache(); } return _configCache[key] || ''; } function refreshConfigCache() { try { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG_SHEET); _configCache = {}; if (sheet) { const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { if (data[i][0]) _configCache[String(data[i][0]).trim()] = String(data[i][1] || '').trim(); } } _configCacheTime = Date.now(); } catch(e) { _configCache = {}; _configCacheTime = Date.now(); } } // ============== POST: Lưu hồ sơ HOẶC phân tích CV bằng Claude ============== function doPost(e) { try { const data = JSON.parse(e.postData.contents); if (data.apiKey !== API_KEY) { return jsonResponse({ ok: false, error: 'Sai API key' }); } // Action: cập nhật config động (Lark, Drive folder, etc.) if (data.action === 'update_config') { return updateConfig(data); } // Action: test gửi Lark (Apps Script proxy — vì browser bị CORS) if (data.action === 'test_lark') { return testLarkWebhook(data); } // Action: upload CV file mới if (data.action === 'upload_cv') { return uploadCV(data); } // Action: Lead review CV (approve/reject/potential) if (data.action === 'review_cv') { return reviewCV(data); } // Action: Gửi bài test (sau PV V1) if (data.action === 'send_test') { return sendTest(data); } // Action: Ứng viên nộp bài test (PUBLIC - không cần API key check ở đây vì token là auth) if (data.action === 'submit_test') { return submitTest(data); } // Action mặc định: lưu hồ sơ vào sheet const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME); if (!sheet) { return jsonResponse({ ok: false, error: 'Không tìm thấy sheet "' + SHEET_NAME + '"' }); } const scores = Object.values(data.scores || {}).filter(s => typeof s === 'number'); const avg = scores.length ? (scores.reduce((a,b) => a+b, 0) / scores.length).toFixed(2) : ''; let rec = ''; if (avg >= 4) rec = '✅ PASS'; else if (avg >= 3) rec = '🟡 CÂN NHẮC'; else if (scores.length > 0) rec = '❌ LOẠI'; const candName = (data.candidate || {}).name || ''; const exportType = data.exportType || 'all'; const existingRow = findExistingRow(sheet, candName, exportType); const rowData = [ new Date(), candName, (data.candidate || {}).position || '', (data.candidate || {}).date || '', (data.candidate || {}).interviewer || '', exportType, (data.candidate || {}).contact || '', (data.candidate || {}).source || '', JSON.stringify(data.scores || {}), JSON.stringify(data.notes || {}), JSON.stringify(data.qa || []), JSON.stringify(data.decision || {}), JSON.stringify(data.hrDecision || {}), avg, rec, JSON.stringify(data) ]; if (existingRow > 0) { sheet.getRange(existingRow, 1, 1, rowData.length).setValues([rowData]); return jsonResponse({ ok: true, action: 'update', row: existingRow }); } else { sheet.appendRow(rowData); return jsonResponse({ ok: true, action: 'insert', row: sheet.getLastRow() }); } } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // ============== UPLOAD CV (HR upload file → Drive + CVQueue sheet) ============== function uploadCV(data) { const folderId = getConfig('cv_drive_folder_id'); if (!folderId) { return jsonResponse({ ok: false, error: 'CV Drive folder ID chưa cấu hình. Vào /quan-tri/ để set.' }); } try { const folder = DriveApp.getFolderById(folderId); const cand = data.candidate || {}; // Decode base64 file → save lên Drive const bytes = Utilities.base64Decode(data.fileBase64); const blob = Utilities.newBlob(bytes, data.fileMimeType, data.fileName); const safeName = (cand.name || 'unknown').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_'); const finalName = safeName + '_' + new Date().toISOString().slice(0,10) + '_' + data.fileName; const file = folder.createFile(blob).setName(finalName); // Try set sharing public (có thể fail do Workspace policy → bỏ qua, file vẫn truy cập được khi folder đã share) try { file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); } catch(shareErr) { Logger.log('setSharing skipped: ' + shareErr.message); } const fileUrl = file.getUrl(); // Upload Portfolio file (optional) let portfolioFileUrl = ''; if (data.portfolioFileBase64 && data.portfolioFileName) { try { const pBytes = Utilities.base64Decode(data.portfolioFileBase64); const pBlob = Utilities.newBlob(pBytes, data.portfolioFileMime || 'application/octet-stream', data.portfolioFileName); const pFinalName = 'Portfolio_' + safeName + '_' + new Date().toISOString().slice(0,10) + '_' + data.portfolioFileName; const pFile = folder.createFile(pBlob).setName(pFinalName); try { pFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); } catch(e) {} portfolioFileUrl = pFile.getUrl(); } catch (pErr) { Logger.log('Portfolio upload failed: ' + pErr.message); } } // Append vào CVQueue sheet let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME); if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(CV_SHEET_NAME); sheet.appendRow(['Timestamp','Tên','SĐT/Email','Vị trí','Nguồn','CV file','HR upload','Note HR','Status','Lead reviewer','Note Lead','Lịch PV','Reviewed at','HR Score','HR Score Detail']); } // Đảm bảo có cột HR Score + HR Score Detail + Portfolio ensureHrScoreColumns(sheet); ensurePortfolioColumns(sheet); // Build row theo header để đảm bảo đúng vị trí const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const rowData = new Array(headers.length).fill(''); const setCol = (h, val) => { const idx = headers.indexOf(h); if (idx >= 0) rowData[idx] = val; }; setCol('Timestamp', new Date()); setCol('Tên', cand.name || ''); setCol('SĐT/Email', cand.contact || ''); setCol('Vị trí', cand.position || ''); setCol('Nguồn', cand.source || ''); setCol('CV file', fileUrl); setCol('HR upload', cand.hrUploader || 'HR'); setCol('Note HR', cand.hrNote || ''); setCol('Status', 'PENDING'); setCol('HR Score', cand.hrScore || ''); setCol('HR Score Detail', JSON.stringify(cand.hrScoreDetail || {})); setCol('Portfolio link', cand.portfolio || ''); setCol('Portfolio file', portfolioFileUrl); sheet.appendRow(rowData); invalidateCVCache(); // 🔔 Lark notify: HR vừa upload CV mới notifyLark('NEW_CV', { candidate: cand, note: cand.hrNote }); return jsonResponse({ ok: true, row: sheet.getLastRow(), fileUrl: fileUrl, portfolioFileUrl: portfolioFileUrl }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // ============== LIST CV QUEUE (cache 60s qua CacheService) ============== function listCVQueue() { try { const cache = CacheService.getScriptCache(); const cached = cache.get('cv_queue_v1'); if (cached) { // Cache HIT — trả về ngay (10-20ms thay vì 500-2000ms) return ContentService.createTextOutput(cached).setMimeType(ContentService.MimeType.JSON); } const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME); if (!sheet) return jsonResponse({ ok: true, data: [] }); const data = sheet.getDataRange().getValues(); if (data.length < 2) return jsonResponse({ ok: true, data: [] }); const headers = data[0]; const rows = data.slice(1); const result = rows.map((row, idx) => { const obj = { _row: idx + 2 }; headers.forEach((h, i) => obj[h] = row[i] instanceof Date ? row[i].toISOString() : row[i]); return obj; }).filter(r => r['Tên']); result.sort((a, b) => new Date(b.Timestamp) - new Date(a.Timestamp)); const response = JSON.stringify({ ok: true, data: result }); // Cache 60 giây — invalidated khi có upload/review/test mới try { cache.put('cv_queue_v1', response, 60); } catch(e) {} return ContentService.createTextOutput(response).setMimeType(ContentService.MimeType.JSON); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // Helper: invalidate cache khi có thay đổi function invalidateCVCache() { try { CacheService.getScriptCache().remove('cv_queue_v1'); } catch(e) {} } // ============== REVIEW CV (TPMKT: REJECTED/POTENTIAL/SCHEDULED) ============== function reviewCV(data) { try { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME); if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy sheet CVQueue' }); const row = parseInt(data.row); if (!row || row < 2) return jsonResponse({ ok: false, error: 'Row không hợp lệ' }); sheet.getRange(row, 9).setValue(data.decision); sheet.getRange(row, 10).setValue(data.leadName || ''); sheet.getRange(row, 11).setValue(data.leadNote || ''); if (data.interviewDate) sheet.getRange(row, 12).setValue(data.interviewDate); sheet.getRange(row, 13).setValue(new Date()); invalidateCVCache(); // 🔔 Lark notify: TPMKT vừa duyệt const candName = sheet.getRange(row, 2).getValue(); const position = sheet.getRange(row, 4).getValue(); const contact = sheet.getRange(row, 3).getValue(); const eventMap = { REJECTED: 'TPMKT_REJECTED', POTENTIAL: 'TPMKT_POTENTIAL', SCHEDULED: 'TPMKT_SCHEDULED' }; const eventType = eventMap[data.decision]; if (eventType) { notifyLark(eventType, { candidate: { name: candName, position: position, contact: contact }, note: data.leadNote, interviewDate: data.interviewDate }); } return jsonResponse({ ok: true, row: row }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // ============== SEND TEST (Tạo bài test + token unique) ============== function sendTest(data) { try { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME); if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy sheet CVQueue' }); const row = parseInt(data.row); // Đảm bảo có đủ cột cho test (cột 14-19): Test type, Test description, Test deadline, Test token, Test file, Test note, Test submitted at ensureTestColumns(sheet); // Sinh token unique const token = Utilities.getUuid().replace(/-/g, '').slice(0, 24); sheet.getRange(row, 9).setValue('TEST_SENT'); sheet.getRange(row, 14).setValue(data.testType || ''); sheet.getRange(row, 15).setValue(data.testDescription || ''); sheet.getRange(row, 16).setValue(data.testDeadline || ''); sheet.getRange(row, 17).setValue(token); invalidateCVCache(); // 🔔 Lark notify: HR đã gửi bài test const candName2 = sheet.getRange(row, 2).getValue(); const position2 = sheet.getRange(row, 4).getValue(); notifyLark('TEST_SENT', { candidate: { name: candName2, position: position2 }, deadline: data.testDeadline }); return jsonResponse({ ok: true, row: row, token: token }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // ============== SUBMIT TEST (Ứng viên nộp bài qua /bai-test/?token=XYZ) ============== function submitTest(data) { try { const folderId = getConfig('cv_drive_folder_id'); if (!folderId) { return jsonResponse({ ok: false, error: 'Drive folder chưa cấu hình' }); } const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME); if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy CVQueue' }); // Tìm row theo token const row = findRowByToken(sheet, data.token); if (!row) return jsonResponse({ ok: false, error: 'Token không hợp lệ' }); // Lưu file lên Drive const folder = DriveApp.getFolderById(folderId); const bytes = Utilities.base64Decode(data.fileBase64); const blob = Utilities.newBlob(bytes, data.fileMimeType, data.fileName); const candName = sheet.getRange(row, 2).getValue() || 'unknown'; const safeName = (candName + '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_'); const fileFinal = 'BaiTest_' + safeName + '_' + new Date().toISOString().slice(0,10) + '_' + data.fileName; const file = folder.createFile(blob).setName(fileFinal); try { file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); } catch(shareErr) { Logger.log('setSharing skipped: ' + shareErr.message); } sheet.getRange(row, 9).setValue('TEST_SUBMITTED'); sheet.getRange(row, 18).setValue(file.getUrl()); sheet.getRange(row, 19).setValue(data.note || ''); sheet.getRange(row, 20).setValue(new Date()); invalidateCVCache(); // 🔔 Lark notify: Ứng viên đã nộp bài test notifyLark('TEST_SUBMITTED', { candidate: { name: candName, position: sheet.getRange(row, 4).getValue() }, note: data.note }); return jsonResponse({ ok: true, fileUrl: file.getUrl() }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // ============== GET TEST INFO (cho ứng viên xem bài) ============== function getTestInfo(token) { try { if (!token) return jsonResponse({ ok: false, error: 'Thiếu token' }); const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME); if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy CVQueue' }); const row = findRowByToken(sheet, token); if (!row) return jsonResponse({ ok: false, error: 'Token không hợp lệ hoặc đã bị xoá' }); const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const rowData = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0]; const obj = {}; headers.forEach((h, i) => { obj[h] = rowData[i] instanceof Date ? rowData[i].toISOString() : rowData[i]; }); // Chỉ trả các field cần thiết cho ứng viên (KHÔNG trả Note Lead, Lead reviewer) const safe = { 'Tên': obj['Tên'], 'Vị trí': obj['Vị trí'], 'Status': obj['Status'], 'Test type': obj['Test type'], 'Test description': obj['Test description'], 'Test deadline': obj['Test deadline'], 'Test submitted at': obj['Test submitted at'] }; return jsonResponse({ ok: true, data: safe }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } function ensureTestColumns(sheet) { const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const need = ['Test type','Test description','Test deadline','Test token','Test file','Test note','Test submitted at']; let lastCol = headers.length; need.forEach(h => { if (headers.indexOf(h) === -1) { lastCol++; sheet.getRange(1, lastCol).setValue(h); } }); } function ensureHrScoreColumns(sheet) { const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const need = ['HR Score', 'HR Score Detail']; let lastCol = headers.length; need.forEach(h => { if (headers.indexOf(h) === -1) { lastCol++; sheet.getRange(1, lastCol).setValue(h); } }); } function ensurePortfolioColumns(sheet) { const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const need = ['Portfolio link', 'Portfolio file']; let lastCol = headers.length; need.forEach(h => { if (headers.indexOf(h) === -1) { lastCol++; sheet.getRange(1, lastCol).setValue(h); } }); } function findRowByToken(sheet, token) { const data = sheet.getDataRange().getValues(); const headers = data[0]; const tokenCol = headers.indexOf('Test token'); if (tokenCol === -1) return 0; for (let i = 1; i < data.length; i++) { if (data[i][tokenCol] === token) return i + 1; } return 0; } // ============== GET: Tải danh sách hồ sơ HOẶC CV queue ============== function doGet(e) { try { if (e.parameter.apiKey !== API_KEY) { return jsonResponse({ ok: false, error: 'Sai API key' }); } // Action: list_cv_queue if (e.parameter.action === 'list_cv_queue') { return listCVQueue(); } // Action: get_test_info (cho trang public /bai-test/?token=XYZ) if (e.parameter.action === 'get_test_info') { return getTestInfo(e.parameter.token); } // Action: get_config (cho /quan-tri/ load config hiện tại) if (e.parameter.action === 'get_config') { refreshConfigCache(); return jsonResponse({ ok: true, config: _configCache || {} }); } const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME); const data = sheet.getDataRange().getValues(); if (data.length < 2) return jsonResponse({ ok: true, data: [] }); const headers = data[0]; const rows = data.slice(1); let result = rows.map((row, idx) => { const obj = { _row: idx + 2 }; headers.forEach((h, i) => obj[h] = row[i]); return obj; }).filter(r => r['Tên ứng viên']); // Lọc theo tên nếu có if (e.parameter.search) { const q = e.parameter.search.toLowerCase(); result = result.filter(r => ((r['Tên ứng viên'] || '') + '').toLowerCase().includes(q) || ((r['SĐT/Email'] || '') + '').toLowerCase().includes(q) ); } // Trả về 50 dòng mới nhất result.sort((a, b) => new Date(b.Timestamp) - new Date(a.Timestamp)); if (result.length > 50) result = result.slice(0, 50); return jsonResponse({ ok: true, data: result, total: result.length }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } function findExistingRow(sheet, name, exportType) { if (!name) return 0; const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { if (data[i][1] === name && data[i][5] === exportType) return i + 1; } return 0; } function jsonResponse(obj) { return ContentService.createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); } // ============== TEST LARK WEBHOOK (Apps Script proxy — bypass CORS) ============== function testLarkWebhook(data) { const webhook = (data.webhook || '').trim() || getConfig('lark_webhook'); if (!webhook) { return jsonResponse({ ok: false, error: 'Chưa có Lark webhook URL' }); } const card = { msg_type: 'interactive', card: { header: { title: { tag: 'plain_text', content: '🧪 TEST từ KITA Interview System' }, template: 'turquoise' }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: '✅ **Bot hoạt động!**\n\nNếu bạn thấy tin nhắn này → cấu hình Lark đã ĐÚNG.\nTừ giờ mọi thay đổi trạng thái CV/PV sẽ tự động gửi notify vào group này.' } }, { tag: 'hr' }, { tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: '📋 Mở hệ thống' }, url: getConfig('system_url') || 'https://phongvanmkt.kitagroupvn.com', type: 'primary' }] } ] } }; try { const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', payload: JSON.stringify(card), muteHttpExceptions: true }); const code = res.getResponseCode(); const body = res.getContentText(); if (code === 200) { return jsonResponse({ ok: true, message: 'Đã gửi tin test vào Lark thành công' }); } return jsonResponse({ ok: false, error: 'Lark trả về HTTP ' + code + ': ' + body.slice(0, 200) }); } catch(e) { return jsonResponse({ ok: false, error: e.message }); } } // ============== UPDATE CONFIG (qua /quan-tri/ web) ============== function updateConfig(data) { try { let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG_SHEET); if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(CONFIG_SHEET); sheet.appendRow(['Key', 'Value', 'Description']); // Pre-fill các key thông dụng sheet.appendRow(['cv_drive_folder_id', '', 'ID folder Drive lưu CV']); sheet.appendRow(['lark_webhook', '', 'Lark Bot webhook URL']); sheet.appendRow(['lark_email_hr', '', 'Email Lark của HR']); sheet.appendRow(['lark_email_tpmkt', '', 'Email Lark của TPMKT']); sheet.appendRow(['lark_email_director', '', 'Email Lark của GĐ']); sheet.appendRow(['system_url', '', 'URL hệ thống (vd: https://phongvanmkt.kitagroupvn.com)']); } const allData = sheet.getDataRange().getValues(); const existing = {}; for (let i = 1; i < allData.length; i++) { if (allData[i][0]) existing[String(allData[i][0]).trim()] = i + 1; } Object.entries(data.config || {}).forEach(([key, val]) => { if (existing[key]) { sheet.getRange(existing[key], 2).setValue(val); } else { sheet.appendRow([key, val, '']); } }); // Xoá cache để lần đọc tiếp theo lấy giá trị mới _configCache = null; return jsonResponse({ ok: true, message: 'Đã lưu. Có hiệu lực ngay (cache làm mới mỗi 60s).' }); } catch (err) { return jsonResponse({ ok: false, error: err.message }); } } // ============== LARK BOT NOTIFICATION ============== function notifyLark(eventType, data) { const webhook = getConfig('lark_webhook'); if (!webhook) return; // Chưa cấu hình → bỏ qua const sysUrl = getConfig('system_url') || 'https://phongvanmkt.kitagroupvn.com'; const emailHr = getConfig('lark_email_hr'); const emailTpmkt = getConfig('lark_email_tpmkt'); const emailDirector = getConfig('lark_email_director'); const events = { 'NEW_CV': { title: '📥 CV mới chờ TPMKT duyệt', template: 'blue', mention: emailTpmkt, url: sysUrl + '/cv/', btn: '✍️ Vào duyệt CV' }, 'TPMKT_REJECTED': { title: '❌ TPMKT đã loại CV', template: 'red', mention: emailHr, url: sysUrl + '/cv/', btn: '📋 Xem chi tiết' }, 'TPMKT_POTENTIAL': { title: '🟡 CV được đánh dấu Tiềm năng', template: 'yellow', mention: emailHr, url: sysUrl + '/cv/', btn: '📋 Xem chi tiết' }, 'TPMKT_SCHEDULED': { title: '🟢 TPMKT hẹn PV V1 - HR triển khai', template: 'green', mention: emailHr, url: sysUrl + '/hr/', btn: '👤 Vào phỏng vấn V1' }, 'TEST_SENT': { title: '📝 Đã gửi bài test cho ứng viên', template: 'purple', mention: '', url: sysUrl + '/cv/', btn: '📋 Xem chi tiết' }, 'TEST_SUBMITTED': { title: '✅ Ứng viên ĐÃ NỘP BÀI TEST', template: 'green', mention: [emailTpmkt, emailHr].filter(Boolean).join(','), url: sysUrl + '/', btn: '📊 Vào chấm bài' } }; const ev = events[eventType]; if (!ev) return; const cand = data.candidate || {}; const lines = []; lines.push('**👤 Ứng viên:** ' + (cand.name || '?')); if (cand.position) lines.push('**💼 Vị trí:** ' + cand.position); if (cand.contact) lines.push('**📞 Liên hệ:** ' + cand.contact); if (data.note) lines.push('**📝 Ghi chú:** ' + data.note); if (data.interviewDate) lines.push('**📅 Lịch PV:** ' + data.interviewDate); if (data.deadline) lines.push('**⏰ Deadline test:** ' + data.deadline); // Build mention(s) let mentionStr = ''; if (ev.mention) { const emails = ev.mention.split(',').map(e => e.trim()).filter(Boolean); mentionStr = emails.map(e => ``).join(' ') + '\n\n'; } const card = { msg_type: 'interactive', card: { header: { title: { tag: 'plain_text', content: ev.title }, template: ev.template }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: mentionStr + lines.join('\n') } }, { tag: 'hr' }, { tag: 'action', actions: [ { tag: 'button', text: { tag: 'plain_text', content: ev.btn }, url: ev.url, type: 'primary' } ] } ] } }; try { UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', payload: JSON.stringify(card), muteHttpExceptions: true }); } catch(e) { Logger.log('Lark notify failed: ' + e.message); } }
3

Đổi API_KEY ở dòng 5 thành chuỗi bí mật của bạn

VD: KITA-MyTeam-2026 — chỉ chia sẻ với người được phép truy cập database

4

Lưu file (Ctrl+S) → đặt tên project: KITA Interview API

🌐 BƯỚC 3: Deploy Web App

1

Bấm nút Deploy (góc phải trên) → New deployment

2

Chọn loại: bấm icon ⚙️ bên trái → Web app

3

Cấu hình:

  • Description: KITA Interview v1
  • Execute as: Me (your-email@gmail.com)
  • Who has access: Anyone ⚠️ QUAN TRỌNG
⚠️ "Anyone" có nghĩa bất kỳ ai có URL đều POST/GET được. Bảo mật bằng API_KEY đã set ở bước 2.
4

Bấm Deploy → cấp quyền truy cập Sheet (Allow)

Lần đầu Google sẽ cảnh báo "Unverified app" → bấm Advanced → Go to KITA Interview API (unsafe)Allow

5

Copy Web App URL

URL có dạng: https://script.google.com/macros/s/AKfy.../exec — copy lại để dùng ở bước sau

🔌 BƯỚC 4: Test kết nối

Paste URL + API key vào ô dưới để test thử + lưu config vào trình duyệt:

✅ BƯỚC 5: Hoàn tất - Sử dụng

🎉 Sau khi lưu cấu hình:
• Mở hệ thống chính (phongvanmkt.kitagroupvn.com) hoặc trang HR (/hr/)
• Bấm nút ☁️ Lưu Cloud → data tự lưu lên Google Sheet
• Bấm 🔍 Tải từ Cloud → tìm kiếm hồ sơ cũ theo tên/SĐT

📊 Cách xem dữ liệu trong Google Sheet:

📥 (BẮT BUỘC nếu dùng /cv/) Setup Drive folder cho CV

Trang /cv/ upload CV ứng viên lên Google Drive + lưu thông tin vào sheet "CVQueue". Cần:

1

Tạo folder mới trên Google Drive

Vào drive.google.com → New → Folder → đặt tên KITA CV Storage

2

Lấy Folder ID

Mở folder vừa tạo → URL có dạng: drive.google.com/drive/folders/1ABCxyz123...

Copy phần ID (sau /folders/)

3

Mở Apps Script → tìm dòng const CV_DRIVE_FOLDER_ID = '';

Paste ID vào trong nháy: const CV_DRIVE_FOLDER_ID = '1ABCxyz123...';

4

Tạo tab sheet mới tên CVQueue (hoặc để Apps Script tự tạo)

Apps Script sẽ tự tạo tab + header khi upload CV đầu tiên. Hoặc tạo tay với header:

Timestamp Tên SĐT/Email Vị trí Nguồn CV file HR upload Note HR Status Lead reviewer Note Lead Lịch PV Reviewed at
5

Save Apps Script → Deploy → Manage Deployments → Edit → New version → Deploy

Cấp quyền Drive nếu Google hỏi (do thêm DriveApp API)

✅ Workflow: HR vào /cv/ upload CV → file lưu Drive + thông tin lưu CVQueue sheet → Lead vào /cv/ tab "Chờ duyệt" → review từng CV → quyết định Hẹn PV hoặc Loại

🛠️ Khắc phục sự cố thường gặp

LỗiNguyên nhânCách sửa
"Sai API key"Key trong HTML khác Apps ScriptKiểm tra lại 2 chỗ phải khớp
"Không tìm thấy sheet PhongVan"Tab sheet không đặt tên đúngĐổi tên tab thành đúng PhongVan
CORS errorDeploy chưa chọn "Anyone"Re-deploy với access = Anyone
403 ForbiddenChưa Authorize quyềnMở Apps Script → chạy thử doGet → Allow