const APP = { SHEETS: { CONFIG: 'CAU_HINH', SUBJECTS: 'MON_HOC', CLASSES: 'LOP_HOC', PROGRAM: 'CHUONG_TRINH', DEMAND: 'NHU_CAU_LOP_MON', TEACHERS: 'GIAO_VIEN', REDUCTIONS: 'KIEM_NHIEM', ASSIGNMENTS: 'PHAN_CONG', CONVERSIONS: 'QUY_DOI', REPORT_SUBJECT: 'BAO_CAO_MON', REPORT_TEACHER: 'BAO_CAO_GV', SUBJECT_PLAN: 'DIEU_TIET_MON', CROSS_SUBJECT: 'DIEU_TIET_LIEN_MON', REPORT_SCHOOL: 'CAN_DOI_TOAN_TRUONG', VERSIONS: 'PHIEN_BAN_PHAN_CONG', VERSION_DETAILS: 'CHI_TIET_PHIEN_BAN', LOG: 'NHAT_KY' }, HEADERS: { CAU_HINH: ['KHOA','GIA_TRI','MO_TA'], MON_HOC: ['MA_MON','TEN_MON','TO_CHUYEN_MON','KICH_HOAT','GHI_CHU'], LOP_HOC: ['MA_LOP','TEN_LOP','KHOI','SO_HOC_SINH','GVCN','KICH_HOAT','GHI_CHU'], CHUONG_TRINH: ['ID_CT','KHOI','MA_MON','TEN_MON','LOAI','TIET_CA_NAM','TIET_HK1','TIET_HK2','TINH_NHAN_SU','GHI_CHU'], NHU_CAU_LOP_MON: ['ID_NHU_CAU','MA_LOP','KHOI','MA_MON','TEN_MON','LOAI','TIET_CA_NAM','TIET_HK1','TIET_HK2','TINH_NHAN_SU','GHI_CHU'], GIAO_VIEN: ['MA_GV','HO_TEN','TO_CHUYEN_MON','MON_CHINH','MON_CO_THE_DAY','CHUC_VU','DINH_MUC_TUAN','KICH_HOAT','GHI_CHU'], KIEM_NHIEM: ['ID_KN','MA_GV','NHIEM_VU','GIAM_TIET_TUAN','GHI_CHU'], PHAN_CONG: ['ID_PC','ID_NHU_CAU','MA_GV','MA_LOP','KHOI','MA_MON','TEN_MON','TIET_CA_NAM','TIET_HK1','TIET_HK2','GHI_CHU'], QUY_DOI: ['ID_QD','MA_GV','HOAT_DONG','SO_TIET_THUC','HE_SO','TIET_QUY_DOI','GHI_CHU'], BAO_CAO_MON: ['MA_MON','TEN_MON','NHU_CAU_TIET_NAM','SO_GV_HIEN_CO','GV_QUY_DOI_CAN','NANG_LUC_SAU_KIEM_NHIEM','CHENH_LECH_TIET','TRANG_THAI'], BAO_CAO_GV: ['MA_GV','HO_TEN','MON_CHINH','DINH_MUC_TUAN','GIAM_KIEM_NHIEM_TUAN','DINH_MUC_SAU_GIAM_NAM','TIET_DA_PHAN_CONG','TIET_QUY_DOI_KHAC','TONG_TIET_QUY_DOI','CHENH_LECH','TB_TUAN','TRANG_THAI'], DIEU_TIET_MON: ['MA_MON','SO_GV_DU_KIEN','SO_GVCN','HDTN_TIET_TUAN','GDDP_TIET_TUAN','KN_KHAC_TIET_TUAN','GHI_CHU','LOAI_DIEU_CHINH','SO_LOP_DIEU_CHINH','CHI_TIET_LOP_DIEU_CHINH','SO_GV_HIEN_CO_DIEU_CHINH','HDTN_TIET_NAM_DIEU_CHINH','GDDP_TIET_NAM_DIEU_CHINH'], DIEU_TIET_LIEN_MON: ['ID_DTLM','MA_GV','MON_NGUON','MON_DICH','TIET_CHUYEN_NAM','GHI_CHU'], CAN_DOI_TOAN_TRUONG: ['MA_MON','TEN_MON','CA_NAM_1_LOP','LOAI','HK1_1_LOP','HK2_1_LOP','TIET_TUAN_HK1','TIET_TUAN_HK2','SO_LOP','CHI_TIET_LOP','SO_GV_HIEN_CO','SO_GV_CO_THE_DAY','SO_GV_DU_KIEN','NHU_CAU_TIET_NAM','NANG_LUC_CO_SO','TIET_CHUYEN_DI_LIEN_MON','TIET_NHAN_LIEN_MON','NANG_LUC_SAU_LIEN_MON','THIEU_THUA_TRUOC','SO_GVCN','QUY_GVCN_NAM','HDTN_TIET_TUAN','QUY_HDTN_NAM','GDDP_TIET_TUAN','QUY_GDDP_NAM','KN_KHAC_TIET_TUAN','QUY_KN_KHAC_NAM','TONG_DIEU_CHINH_NAM','THIEU_THUA_SAU','GV_QUY_DOI_SAU_DIEU_CHINH','TRANG_THAI','GOI_Y'], PHIEN_BAN_PHAN_CONG: ['ID_PHIEN_BAN','MA_PHIEN_BAN','AP_DUNG_TU','THOI_GIAN_GHI','GHI_CHU','SO_DONG_CAN_DOI','SO_DONG_DIEU_TIET_MON','SO_DONG_LIEN_MON','SO_DONG_PHAN_CONG'], CHI_TIET_PHIEN_BAN: ['ID_CHI_TIET','ID_PHIEN_BAN','NHOM_DU_LIEU','STT','DU_LIEU_JSON'], NHAT_KY: ['THOI_GIAN','HANH_DONG','DOI_TUONG','MA','NOI_DUNG'] } }; function onOpen() { SpreadsheetApp.getUi() .createMenu('Kho tiện ích số') .addItem('Khởi tạo / kiểm tra CSDL', 'setupDatabase') .addItem('Cập nhật báo cáo', 'rebuildReportSheets') .addToUi(); } function doGet(e) { const params = (e && e.parameter) ? e.parameter : {}; const callback = String(params.callback || '').trim(); const action = String(params.action || 'ping').trim(); let response; try { const payload = params.data ? JSON.parse(params.data) : {}; response = { ok: true, data: apiDispatch_(action, payload) }; } catch (err) { response = { ok: false, error: err && err.message ? err.message : String(err) }; } // Tránh chuỗi HTML bất ngờ trong phản hồi JSONP. const json = JSON.stringify(response).replace(/ ensureSheet_(ss, name, APP.HEADERS[name])); seedConfig_(); ensureConfigDefaults_(); seedSubjects_(); ensureOverviewSubjects_(); seedProgram_(); rebuildReportSheets_(); ss.toast('Đã khởi tạo/kiểm tra đầy đủ các sheet.', 'Hoàn tất', 5); } function getAppData() { const config = getConfig_(); const subjects = getRows_(APP.SHEETS.SUBJECTS); const classes = getRows_(APP.SHEETS.CLASSES); const program = getRows_(APP.SHEETS.PROGRAM); const demands = getRows_(APP.SHEETS.DEMAND); const teachers = getRows_(APP.SHEETS.TEACHERS); const reductions = getRows_(APP.SHEETS.REDUCTIONS); const assignments = getRows_(APP.SHEETS.ASSIGNMENTS); const conversions = getRows_(APP.SHEETS.CONVERSIONS); const subjectPlans = getRows_(APP.SHEETS.SUBJECT_PLAN); const crossSubjectTransfers = getRows_(APP.SHEETS.CROSS_SUBJECT); const reports = buildReports_(config, subjects, demands, teachers, reductions, assignments, conversions, crossSubjectTransfers); const schoolOverview = buildSchoolOverview_(config, subjects, classes, demands, teachers, reductions, subjectPlans, crossSubjectTransfers); const crossSubjectTeachers = buildCrossSubjectTeachers_(config, subjects, teachers, reductions, crossSubjectTransfers); return { config, subjects, classes, program, demands, teachers, reductions, assignments, conversions, subjectPlans, crossSubjectTransfers, crossSubjectTeachers, schoolOverview, versioning: { nextCode: getNextVersionCode_(config), recent: getRecentVersions_(5) }, reportSubjects: reports.subjects, reportTeachers: reports.teachers, summary: reports.summary, updatedAt: Utilities.formatDate(new Date(), Session.getScriptTimeZone() || 'Asia/Ho_Chi_Minh', 'dd/MM/yyyy HH:mm:ss') }; } function saveClass(data) { data = data || {}; if (!String(data.TEN_LOP || '').trim()) throw new Error('Chưa nhập tên lớp.'); if (!String(data.KHOI || '').trim()) throw new Error('Chưa chọn khối.'); data.KICH_HOAT = data.KICH_HOAT !== false; const result = upsert_(APP.SHEETS.CLASSES, 'MA_LOP', data, 'L'); log_('LƯU', 'Lớp học', result.MA_LOP, result.TEN_LOP); rebuildReportSheets_(); return result; } function saveTeacher(data) { data = data || {}; if (!String(data.HO_TEN || '').trim()) throw new Error('Chưa nhập họ tên giáo viên.'); if (!String(data.MON_CHINH || '').trim()) throw new Error('Chưa chọn môn chính.'); data.DINH_MUC_TUAN = num_(data.DINH_MUC_TUAN) || getDefaultNorm_(data.CHUC_VU); data.KICH_HOAT = data.KICH_HOAT !== false; // Luôn giữ môn chính trong danh sách môn có thể dạy và chuẩn hóa bằng dấu phẩy. const allowed = []; const seenAllowed = {}; [data.MON_CHINH].concat(String(data.MON_CO_THE_DAY || '').split(/[;,|]/)).forEach(code => { code = String(code || '').trim().toUpperCase(); if (!code || seenAllowed[code]) return; seenAllowed[code] = true; allowed.push(code); }); data.MON_CO_THE_DAY = allowed.join(','); const result = upsert_(APP.SHEETS.TEACHERS, 'MA_GV', data, 'GV'); log_('LƯU', 'Giáo viên', result.MA_GV, result.HO_TEN); rebuildReportSheets_(); return result; } function saveReduction(data) { data = data || {}; if (!String(data.MA_GV || '').trim()) throw new Error('Chưa chọn giáo viên.'); if (!String(data.NHIEM_VU || '').trim()) throw new Error('Chưa nhập nhiệm vụ kiêm nhiệm.'); if (num_(data.GIAM_TIET_TUAN) < 0) throw new Error('Số tiết giảm không hợp lệ.'); const result = upsert_(APP.SHEETS.REDUCTIONS, 'ID_KN', data, 'KN'); log_('LƯU', 'Kiêm nhiệm', result.ID_KN, result.MA_GV + ' - ' + result.NHIEM_VU); rebuildReportSheets_(); return result; } function saveDemand(data) { data = data || {}; const cls = findBy_(APP.SHEETS.CLASSES, 'MA_LOP', data.MA_LOP); if (!cls) throw new Error('Không tìm thấy lớp học.'); if (!String(data.MA_MON || '').trim()) throw new Error('Chưa chọn môn/hoạt động.'); const subject = findBy_(APP.SHEETS.SUBJECTS, 'MA_MON', data.MA_MON); data.KHOI = cls.KHOI; data.TEN_MON = subject ? subject.TEN_MON : (data.TEN_MON || data.MA_MON); data.TIET_CA_NAM = num_(data.TIET_CA_NAM); data.TIET_HK1 = num_(data.TIET_HK1); data.TIET_HK2 = num_(data.TIET_HK2); data.TINH_NHAN_SU = bool_(data.TINH_NHAN_SU); if (Math.abs((data.TIET_HK1 + data.TIET_HK2) - data.TIET_CA_NAM) > 0.001) { throw new Error('Tiết HK I + HK II phải bằng tiết cả năm.'); } const result = upsert_(APP.SHEETS.DEMAND, 'ID_NHU_CAU', data, 'NC'); log_('LƯU', 'Nhu cầu lớp-môn', result.ID_NHU_CAU, result.MA_LOP + ' - ' + result.TEN_MON); rebuildReportSheets_(); return result; } function createDemandForClass(maLop) { const cls = findBy_(APP.SHEETS.CLASSES, 'MA_LOP', maLop); if (!cls) throw new Error('Không tìm thấy lớp.'); const existing = getRows_(APP.SHEETS.DEMAND).filter(r => String(r.MA_LOP) === String(maLop)); if (existing.length) throw new Error('Lớp đã có dữ liệu nhu cầu. Hãy chỉnh trực tiếp thay vì tạo lại.'); const template = getRows_(APP.SHEETS.PROGRAM).filter(r => String(r.KHOI) === String(cls.KHOI)); if (!template.length) throw new Error('Chưa có chương trình mẫu cho khối ' + cls.KHOI + '.'); const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.DEMAND); const headers = APP.HEADERS[APP.SHEETS.DEMAND]; const rows = template.map(r => headers.map(h => { if (h === 'ID_NHU_CAU') return makeId_('NC'); if (h === 'MA_LOP') return cls.MA_LOP; if (h === 'KHOI') return cls.KHOI; if (h === 'GHI_CHU') return r.GHI_CHU || ''; return r[h] !== undefined ? r[h] : ''; })); if (rows.length) sh.getRange(sh.getLastRow() + 1, 1, rows.length, headers.length).setValues(rows); log_('TẠO', 'Nhu cầu lớp-môn', maLop, 'Sao chép từ chương trình mẫu khối ' + cls.KHOI); rebuildReportSheets_(); return {ok: true, count: rows.length}; } function saveAssignment(data) { data = data || {}; const demand = findBy_(APP.SHEETS.DEMAND, 'ID_NHU_CAU', data.ID_NHU_CAU); const teacher = findBy_(APP.SHEETS.TEACHERS, 'MA_GV', data.MA_GV); if (!demand) throw new Error('Không tìm thấy nhu cầu lớp-môn.'); if (!teacher) throw new Error('Không tìm thấy giáo viên.'); if (!bool_(demand.TINH_NHAN_SU)) throw new Error('Dòng này đang là tổng/chưa phân bổ, chưa được dùng để phân công giáo viên.'); const canTeach = String(teacher.MON_CO_THE_DAY || teacher.MON_CHINH || '') .split(',').map(s => s.trim().toUpperCase()).filter(Boolean); if (canTeach.length && canTeach.indexOf(String(demand.MA_MON).toUpperCase()) === -1) { throw new Error('Giáo viên chưa được khai báo có thể dạy môn ' + demand.TEN_MON + '.'); } const all = getRows_(APP.SHEETS.ASSIGNMENTS); const old = all.find(r => String(r.ID_NHU_CAU) === String(demand.ID_NHU_CAU)); const payload = { ID_PC: old ? old.ID_PC : '', ID_NHU_CAU: demand.ID_NHU_CAU, MA_GV: teacher.MA_GV, MA_LOP: demand.MA_LOP, KHOI: demand.KHOI, MA_MON: demand.MA_MON, TEN_MON: demand.TEN_MON, TIET_CA_NAM: num_(demand.TIET_CA_NAM), TIET_HK1: num_(demand.TIET_HK1), TIET_HK2: num_(demand.TIET_HK2), GHI_CHU: data.GHI_CHU || '' }; const result = upsert_(APP.SHEETS.ASSIGNMENTS, 'ID_PC', payload, 'PC'); log_('PHÂN CÔNG', 'Phân công', result.ID_PC, teacher.HO_TEN + ' - ' + demand.MA_LOP + ' - ' + demand.TEN_MON); rebuildReportSheets_(); return result; } function clearAssignment(idNhuCau) { const rows = getRows_(APP.SHEETS.ASSIGNMENTS); const row = rows.find(r => String(r.ID_NHU_CAU) === String(idNhuCau)); if (!row) return {ok: true}; deleteRecord('PHAN_CONG', row.ID_PC); return {ok: true}; } function saveConversion(data) { data = data || {}; if (!String(data.MA_GV || '').trim()) throw new Error('Chưa chọn giáo viên.'); if (!String(data.HOAT_DONG || '').trim()) throw new Error('Chưa nhập hoạt động quy đổi.'); data.SO_TIET_THUC = num_(data.SO_TIET_THUC); data.HE_SO = num_(data.HE_SO) || 1; data.TIET_QUY_DOI = data.SO_TIET_THUC * data.HE_SO; const result = upsert_(APP.SHEETS.CONVERSIONS, 'ID_QD', data, 'QD'); log_('LƯU', 'Quy đổi', result.ID_QD, result.MA_GV + ' - ' + result.HOAT_DONG); rebuildReportSheets_(); return result; } function saveSubjectPlan(data) { data = data || {}; const maMon = String(data.MA_MON || '').trim(); if (!maMon) throw new Error('Chưa xác định môn học cần điều tiết.'); const subject = findBy_(APP.SHEETS.SUBJECTS, 'MA_MON', maMon) || getOverviewCurriculumCatalog_().find(x => String(x.MA_MON) === maMon); if (!subject) throw new Error('Không tìm thấy môn học: ' + maMon); const payload = { MA_MON: maMon, SO_GV_DU_KIEN: String(data.SO_GV_DU_KIEN == null ? '' : data.SO_GV_DU_KIEN).trim() === '' ? '' : Math.max(0, num_(data.SO_GV_DU_KIEN)), SO_GVCN: Math.max(0, num_(data.SO_GVCN)), HDTN_TIET_TUAN: Math.max(0, num_(data.HDTN_TIET_TUAN)), GDDP_TIET_TUAN: Math.max(0, num_(data.GDDP_TIET_TUAN)), KN_KHAC_TIET_TUAN: Math.max(0, num_(data.KN_KHAC_TIET_TUAN)), // HĐTN và GDĐP có thể phân công không đều theo tuần/học kỳ. // Hai trường dưới đây là số tiết NĂM do lãnh đạo nhập trực tiếp và là // giá trị ưu tiên khi tính cân đối. Để trống thì hệ thống mới lấy tiết/tuần × số tuần. HDTN_TIET_NAM_DIEU_CHINH: String(data.HDTN_TIET_NAM_DIEU_CHINH == null ? '' : data.HDTN_TIET_NAM_DIEU_CHINH).trim() === '' ? '' : Math.max(0, num_(data.HDTN_TIET_NAM_DIEU_CHINH)), GDDP_TIET_NAM_DIEU_CHINH: String(data.GDDP_TIET_NAM_DIEU_CHINH == null ? '' : data.GDDP_TIET_NAM_DIEU_CHINH).trim() === '' ? '' : Math.max(0, num_(data.GDDP_TIET_NAM_DIEU_CHINH)), GHI_CHU: String(data.GHI_CHU || '').trim(), // Các cột lãnh đạo được phép nhập trực tiếp tại menu Tổng quan. // Để trống = hệ thống tiếp tục lấy giá trị tự động từ CSDL hiện có. LOAI_DIEU_CHINH: String(data.LOAI_DIEU_CHINH == null ? '' : data.LOAI_DIEU_CHINH).trim(), SO_LOP_DIEU_CHINH: String(data.SO_LOP_DIEU_CHINH == null ? '' : data.SO_LOP_DIEU_CHINH).trim() === '' ? '' : Math.max(0, num_(data.SO_LOP_DIEU_CHINH)), CHI_TIET_LOP_DIEU_CHINH: String(data.CHI_TIET_LOP_DIEU_CHINH == null ? '' : data.CHI_TIET_LOP_DIEU_CHINH).trim(), SO_GV_HIEN_CO_DIEU_CHINH: String(data.SO_GV_HIEN_CO_DIEU_CHINH == null ? '' : data.SO_GV_HIEN_CO_DIEU_CHINH).trim() === '' ? '' : Math.max(0, num_(data.SO_GV_HIEN_CO_DIEU_CHINH)) }; const result = upsert_(APP.SHEETS.SUBJECT_PLAN, 'MA_MON', payload, 'DT'); log_('LƯU', 'Điều tiết môn', maMon, subject.TEN_MON + ' - kế hoạch cân đối toàn trường'); if (!bool_(data.NO_REBUILD)) rebuildReportSheets_(); return result; } function saveCrossSubjectPlan(data) { data = data || {}; const maGv = String(data.MA_GV || '').trim(); if (!maGv) throw new Error('Chưa xác định giáo viên cần điều tiết liên môn.'); const teacher = findBy_(APP.SHEETS.TEACHERS, 'MA_GV', maGv); if (!teacher) throw new Error('Không tìm thấy giáo viên ' + maGv + '.'); const main = String(teacher.MON_CHINH || '').trim(); if (!main) throw new Error('Giáo viên chưa có môn chính.'); const allowed = parseTeachingSubjects_(teacher); const allocations = Array.isArray(data.ALLOCATIONS) ? data.ALLOCATIONS : []; const hasPositiveAllocation = allocations.some(a => Math.max(0, num_(a && a.TIET_CHUYEN_NAM)) > 0); if (allowed.length < 2 && hasPositiveAllocation) { throw new Error('Giáo viên này chưa được khai báo dạy từ 2 môn trở lên.'); } const config = getConfig_(); const weeks = num_(config.SO_TUAN_CA_NAM) || 35; const defaultNorm = num_(config.DINH_MUC_GV_THPT) || 17; const reductions = getRows_(APP.SHEETS.REDUCTIONS); const reduction = reductions .filter(r => String(r.MA_GV || '') === maGv) .reduce((s,r) => s + num_(r.GIAM_TIET_TUAN), 0); const norm = num_(teacher.DINH_MUC_TUAN) || defaultNorm; const annualCapacity = Math.max(0, norm - reduction) * weeks; const clean = []; let total = 0; const seen = {}; allocations.forEach(a => { const target = String((a && a.MA_MON) || '').trim(); const periods = Math.max(0, num_(a && a.TIET_CHUYEN_NAM)); if (!target || periods <= 0) return; if (target === main) throw new Error('Không cần điều chuyển về chính môn chính ' + main + '.'); if (allowed.indexOf(target) === -1) { throw new Error('Giáo viên chưa được khai báo có thể dạy môn ' + target + '.'); } if (seen[target]) throw new Error('Môn đích bị lặp: ' + target + '.'); seen[target] = true; total += periods; clean.push({MA_MON: target, TIET_CHUYEN_NAM: periods}); }); if (total > annualCapacity + 0.001) { throw new Error('Tổng số tiết điều chuyển (' + round_(total,2) + ') vượt năng lực năm của giáo viên (' + round_(annualCapacity,2) + ').'); } const lock = LockService.getDocumentLock(); lock.waitLock(30000); try { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.CROSS_SUBJECT); const headers = APP.HEADERS[APP.SHEETS.CROSS_SUBJECT]; if (!sh) throw new Error('Chưa có sheet ' + APP.SHEETS.CROSS_SUBJECT + '. Hãy chạy setupDatabase().'); // Xóa toàn bộ phương án cũ của giáo viên này rồi ghi lại phương án hiện tại. if (sh.getLastRow() > 1) { const maGvCol = headers.indexOf('MA_GV') + 1; const values = sh.getRange(2, maGvCol, sh.getLastRow()-1, 1).getValues().flat().map(String); for (let i = values.length - 1; i >= 0; i--) { if (values[i] === maGv) sh.deleteRow(i + 2); } } if (clean.length) { const rows = clean.map(a => headers.map(h => { if (h === 'ID_DTLM') return makeId_('LM'); if (h === 'MA_GV') return maGv; if (h === 'MON_NGUON') return main; if (h === 'MON_DICH') return a.MA_MON; if (h === 'TIET_CHUYEN_NAM') return a.TIET_CHUYEN_NAM; if (h === 'GHI_CHU') return String(data.GHI_CHU || '').trim(); return ''; })); sh.getRange(sh.getLastRow()+1, 1, rows.length, headers.length).setValues(rows); } } finally { lock.releaseLock(); } log_('LƯU', 'Điều tiết liên môn', maGv, 'Từ ' + main + ' chuyển ' + round_(total,2) + ' tiết/năm sang môn khác'); if (!bool_(data.NO_REBUILD)) rebuildReportSheets_(); return {ok:true, MA_GV:maGv, TONG_TIET_CHUYEN:round_(total,2), NANG_LUC_NAM:round_(annualCapacity,2)}; } /** * Ghi một phiên bản phân công tại thời điểm lãnh đạo chốt phương án. * Phiên bản lưu metadata và snapshot theo từng dòng để không phụ thuộc * vào trạng thái dữ liệu sẽ bị sửa ở các lần sau. */ function savePlanningVersion(data) { data = data || {}; const config = getConfig_(); const maPhienBan = String(data.MA_PHIEN_BAN || '').trim() || getNextVersionCode_(config); const apDungTu = String(data.AP_DUNG_TU || '').trim(); const ghiChu = String(data.GHI_CHU || '').trim(); if (!apDungTu) throw new Error('Hãy nhập thời gian Áp dụng từ cho phiên bản phân công.'); const existing = getRows_(APP.SHEETS.VERSIONS).find(r => String(r.MA_PHIEN_BAN || '').trim().toUpperCase() === maPhienBan.toUpperCase()); if (existing) throw new Error('Mã phiên bản "' + maPhienBan + '" đã tồn tại. Hãy dùng mã phiên bản khác.'); // Đảm bảo snapshot lấy đúng báo cáo mới nhất sau các lần điều chỉnh. rebuildReportSheets_(); const snapshotGroups = [ {name: APP.SHEETS.REPORT_SCHOOL, rows: getRows_(APP.SHEETS.REPORT_SCHOOL)}, {name: APP.SHEETS.SUBJECT_PLAN, rows: getRows_(APP.SHEETS.SUBJECT_PLAN)}, {name: APP.SHEETS.CROSS_SUBJECT, rows: getRows_(APP.SHEETS.CROSS_SUBJECT)}, {name: APP.SHEETS.ASSIGNMENTS, rows: getRows_(APP.SHEETS.ASSIGNMENTS)} ]; const idPhienBan = makeId_('PB'); const nowText = Utilities.formatDate(new Date(), Session.getScriptTimeZone() || 'Asia/Ho_Chi_Minh', 'dd/MM/yyyy HH:mm:ss'); const lock = LockService.getDocumentLock(); lock.waitLock(30000); try { const ss = getSpreadsheet_(); const metaSh = ss.getSheetByName(APP.SHEETS.VERSIONS); const detailSh = ss.getSheetByName(APP.SHEETS.VERSION_DETAILS); if (!metaSh || !detailSh) throw new Error('Chưa có sheet lưu phiên bản. Hãy chạy setupDatabase() một lần.'); const metaHeaders = APP.HEADERS[APP.SHEETS.VERSIONS]; const counts = {}; snapshotGroups.forEach(g => counts[g.name] = g.rows.length); const metaObj = { ID_PHIEN_BAN: idPhienBan, MA_PHIEN_BAN: maPhienBan, AP_DUNG_TU: apDungTu, THOI_GIAN_GHI: nowText, GHI_CHU: ghiChu, SO_DONG_CAN_DOI: counts[APP.SHEETS.REPORT_SCHOOL] || 0, SO_DONG_DIEU_TIET_MON: counts[APP.SHEETS.SUBJECT_PLAN] || 0, SO_DONG_LIEN_MON: counts[APP.SHEETS.CROSS_SUBJECT] || 0, SO_DONG_PHAN_CONG: counts[APP.SHEETS.ASSIGNMENTS] || 0 }; metaSh.getRange(metaSh.getLastRow() + 1, 1, 1, metaHeaders.length) .setValues([metaHeaders.map(h => metaObj[h] !== undefined ? metaObj[h] : '')]); const detailHeaders = APP.HEADERS[APP.SHEETS.VERSION_DETAILS]; const detailRows = []; snapshotGroups.forEach(group => { group.rows.forEach((row, idx) => { const obj = { ID_CHI_TIET: makeId_('CTPB'), ID_PHIEN_BAN: idPhienBan, NHOM_DU_LIEU: group.name, STT: idx + 1, DU_LIEU_JSON: JSON.stringify(row) }; detailRows.push(detailHeaders.map(h => obj[h] !== undefined ? obj[h] : '')); }); }); if (detailRows.length) { detailSh.getRange(detailSh.getLastRow() + 1, 1, detailRows.length, detailHeaders.length).setValues(detailRows); } } finally { lock.releaseLock(); } log_('GHI PHIÊN BẢN', 'Phân công chuyên môn', maPhienBan, 'Áp dụng từ ' + apDungTu + (ghiChu ? ' - ' + ghiChu : '')); return { ID_PHIEN_BAN: idPhienBan, MA_PHIEN_BAN: maPhienBan, AP_DUNG_TU: apDungTu, THOI_GIAN_GHI: nowText, NEXT_CODE: getNextVersionCode_(config) }; } /** * Xuất toàn bộ Google Sheet hiện tại thành XLSX và trả Base64 về Blogspot. * Frontend chuyển Base64 thành Blob để tải thẳng về máy tính người dùng. */ function exportExcelWorkbook(data) { data = data || {}; rebuildReportSheets_(); SpreadsheetApp.flush(); const ss = getSpreadsheet_(); // Gọi DriveApp để Apps Script xin đúng quyền đọc/xuất file khi triển khai phiên bản mới. DriveApp.getFileById(ss.getId()).getName(); const token = ScriptApp.getOAuthToken(); const url = 'https://docs.google.com/spreadsheets/d/' + encodeURIComponent(ss.getId()) + '/export?format=xlsx'; const res = UrlFetchApp.fetch(url, { method: 'get', headers: { Authorization: 'Bearer ' + token }, muteHttpExceptions: true }); if (res.getResponseCode() !== 200) { throw new Error('Không thể tạo file Excel. Mã phản hồi: ' + res.getResponseCode()); } const config = getConfig_(); const versionCode = safeFilePart_(String(data.MA_PHIEN_BAN || '').trim()); const school = safeFilePart_(String(config.TEN_TRUONG || 'Nha_truong')); const stamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone() || 'Asia/Ho_Chi_Minh', 'yyyyMMdd_HHmmss'); const filename = 'Phan_cong_chuyen_mon_' + school + (versionCode ? '_' + versionCode : '') + '_' + stamp + '.xlsx'; const bytes = res.getBlob().getBytes(); return { filename: filename, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base64: Utilities.base64Encode(bytes), size: bytes.length }; } function getRecentVersions_(limit) { const rows = getRows_(APP.SHEETS.VERSIONS); return rows.slice(Math.max(0, rows.length - (limit || 5))).reverse(); } function getNextVersionCode_(config) { config = config || getConfig_(); const year = String(config.NAM_HOC || '').trim().replace(/[^0-9A-Za-z-]+/g, '-'); const prefix = year ? 'PC-' + year + '-V' : 'PC-V'; let maxNo = 0; getRows_(APP.SHEETS.VERSIONS).forEach(r => { const code = String(r.MA_PHIEN_BAN || '').trim(); const m = code.match(/-V(\d+)$/i); if (m) maxNo = Math.max(maxNo, Number(m[1]) || 0); }); return prefix + String(maxNo + 1).padStart(2, '0'); } function safeFilePart_(text) { return String(text || '') .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/đ/g, 'd').replace(/Đ/g, 'D') .replace(/[^0-9A-Za-z._-]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 70); } function deleteRecord(type, id) { const map = { LOP_HOC: [APP.SHEETS.CLASSES, 'MA_LOP'], GIAO_VIEN: [APP.SHEETS.TEACHERS, 'MA_GV'], NHU_CAU_LOP_MON: [APP.SHEETS.DEMAND, 'ID_NHU_CAU'], KIEM_NHIEM: [APP.SHEETS.REDUCTIONS, 'ID_KN'], PHAN_CONG: [APP.SHEETS.ASSIGNMENTS, 'ID_PC'], QUY_DOI: [APP.SHEETS.CONVERSIONS, 'ID_QD'], DIEU_TIET_LIEN_MON: [APP.SHEETS.CROSS_SUBJECT, 'ID_DTLM'] }; if (!map[type]) throw new Error('Loại dữ liệu không được phép xóa.'); deleteBy_(map[type][0], map[type][1], id); log_('XÓA', type, id, ''); rebuildReportSheets_(); return {ok: true}; } function rebuildReportSheets() { rebuildReportSheets_(); getSpreadsheet_().toast('Đã cập nhật báo cáo, cân đối toàn trường và điều tiết liên môn.', 'Hoàn tất', 4); } function rebuildReportSheets_() { const config = getConfig_(); const subjects = getRows_(APP.SHEETS.SUBJECTS); const classes = getRows_(APP.SHEETS.CLASSES); const demands = getRows_(APP.SHEETS.DEMAND); const teachers = getRows_(APP.SHEETS.TEACHERS); const reductions = getRows_(APP.SHEETS.REDUCTIONS); const crossSubjectTransfers = getRows_(APP.SHEETS.CROSS_SUBJECT); const reports = buildReports_( config, subjects, demands, teachers, reductions, getRows_(APP.SHEETS.ASSIGNMENTS), getRows_(APP.SHEETS.CONVERSIONS), crossSubjectTransfers ); const schoolOverview = buildSchoolOverview_( config, subjects, classes, demands, teachers, reductions, getRows_(APP.SHEETS.SUBJECT_PLAN), crossSubjectTransfers ); writeObjects_(APP.SHEETS.REPORT_SUBJECT, APP.HEADERS[APP.SHEETS.REPORT_SUBJECT], reports.subjects); writeObjects_(APP.SHEETS.REPORT_TEACHER, APP.HEADERS[APP.SHEETS.REPORT_TEACHER], reports.teachers); writeObjects_(APP.SHEETS.REPORT_SCHOOL, APP.HEADERS[APP.SHEETS.REPORT_SCHOOL], schoolOverview.rows); formatSchoolOverviewSheet_(); } function buildReports_(config, subjects, demands, teachers, reductions, assignments, conversions, crossSubjectTransfers) { const weeks = num_(config.SO_TUAN_CA_NAM) || 35; const defaultNorm = num_(config.DINH_MUC_GV_THPT) || 17; const subjectName = {}; subjects.forEach(s => subjectName[String(s.MA_MON)] = s.TEN_MON); const reductionByTeacher = {}; reductions.forEach(r => { const k = String(r.MA_GV || ''); reductionByTeacher[k] = (reductionByTeacher[k] || 0) + num_(r.GIAM_TIET_TUAN); }); const demandBySubject = {}; let totalDemand = 0; let unallocated = 0; demands.forEach(d => { const periods = num_(d.TIET_CA_NAM); if (bool_(d.TINH_NHAN_SU)) { const k = String(d.MA_MON || ''); demandBySubject[k] = (demandBySubject[k] || 0) + periods; totalDemand += periods; } else { unallocated += periods; } }); const teachersActive = teachers.filter(t => bool_(t.KICH_HOAT)); const teachersBySubject = {}; const capacityBySubject = {}; teachersActive.forEach(t => { const k = String(t.MON_CHINH || ''); if (!k) return; teachersBySubject[k] = (teachersBySubject[k] || 0) + 1; const norm = num_(t.DINH_MUC_TUAN) || defaultNorm; const adjusted = Math.max(0, norm - (reductionByTeacher[String(t.MA_GV)] || 0)); capacityBySubject[k] = (capacityBySubject[k] || 0) + adjusted * weeks; }); // Điều tiết liên môn chỉ CHUYỂN năng lực từ môn chính sang môn thứ hai, // không tạo thêm năng lực mới và vì vậy không bị đếm trùng giáo viên. (crossSubjectTransfers || []).forEach(x => { const source = String(x.MON_NGUON || ''); const target = String(x.MON_DICH || ''); const periods = Math.max(0, num_(x.TIET_CHUYEN_NAM)); if (!source || !target || periods <= 0) return; capacityBySubject[source] = (capacityBySubject[source] || 0) - periods; capacityBySubject[target] = (capacityBySubject[target] || 0) + periods; }); const crossCodes = []; (crossSubjectTransfers || []).forEach(x => { if (x.MON_NGUON) crossCodes.push(String(x.MON_NGUON)); if (x.MON_DICH) crossCodes.push(String(x.MON_DICH)); }); const allSubjectCodes = Array.from(new Set(Object.keys(demandBySubject).concat(Object.keys(teachersBySubject), crossCodes))); const subjectReports = allSubjectCodes.map(code => { const demand = demandBySubject[code] || 0; const current = teachersBySubject[code] || 0; const capacity = capacityBySubject[code] || 0; const delta = capacity - demand; return { MA_MON: code, TEN_MON: subjectName[code] || code, NHU_CAU_TIET_NAM: round_(demand, 2), SO_GV_HIEN_CO: current, GV_QUY_DOI_CAN: round_(demand / (defaultNorm * weeks), 2), NANG_LUC_SAU_KIEM_NHIEM: round_(capacity, 2), CHENH_LECH_TIET: round_(delta, 2), TRANG_THAI: delta < -0.01 ? 'THIẾU' : (delta > 0.01 ? 'THỪA' : 'CÂN BẰNG') }; }).sort((a,b) => a.CHENH_LECH_TIET - b.CHENH_LECH_TIET); const assignedByTeacher = {}; assignments.forEach(a => { const k = String(a.MA_GV || ''); assignedByTeacher[k] = (assignedByTeacher[k] || 0) + num_(a.TIET_CA_NAM); }); const convertedByTeacher = {}; conversions.forEach(q => { const k = String(q.MA_GV || ''); convertedByTeacher[k] = (convertedByTeacher[k] || 0) + num_(q.TIET_QUY_DOI); }); const teacherReports = teachersActive.map(t => { const code = String(t.MA_GV || ''); const norm = num_(t.DINH_MUC_TUAN) || defaultNorm; const reduction = reductionByTeacher[code] || 0; const annualQuota = Math.max(0, norm - reduction) * weeks; const assigned = assignedByTeacher[code] || 0; const converted = convertedByTeacher[code] || 0; const total = assigned + converted; const delta = total - annualQuota; const avg = weeks ? total / weeks : 0; let status = 'ĐỦ'; if (delta < -0.01) status = 'THIẾU TIẾT'; if (delta > 0.01) status = 'VƯỢT ĐỊNH MỨC'; if (avg > norm * 1.5 + 0.001) status = 'CẢNH BÁO >150%/TUẦN'; return { MA_GV: code, HO_TEN: t.HO_TEN, MON_CHINH: subjectName[String(t.MON_CHINH)] || t.MON_CHINH, DINH_MUC_TUAN: norm, GIAM_KIEM_NHIEM_TUAN: round_(reduction, 2), DINH_MUC_SAU_GIAM_NAM: round_(annualQuota, 2), TIET_DA_PHAN_CONG: round_(assigned, 2), TIET_QUY_DOI_KHAC: round_(converted, 2), TONG_TIET_QUY_DOI: round_(total, 2), CHENH_LECH: round_(delta, 2), TB_TUAN: round_(avg, 2), TRANG_THAI: status }; }).sort((a,b) => a.CHENH_LECH - b.CHENH_LECH); return { subjects: subjectReports, teachers: teacherReports, summary: { totalDemand: round_(totalDemand, 2), unallocated: round_(unallocated, 2), shortageSubjects: subjectReports.filter(r => r.TRANG_THAI === 'THIẾU').length, surplusSubjects: subjectReports.filter(r => r.TRANG_THAI === 'THỪA').length, overloadedTeachers: teacherReports.filter(r => r.CHENH_LECH > 0.01).length, underloadedTeachers: teacherReports.filter(r => r.CHENH_LECH < -0.01).length } }; } function parseTeachingSubjects_(teacher) { const main = String((teacher && teacher.MON_CHINH) || '').trim(); const raw = String((teacher && teacher.MON_CO_THE_DAY) || main || ''); const out = []; const seen = {}; function add(code) { code = String(code || '').trim().toUpperCase(); if (!code || seen[code]) return; seen[code] = true; out.push(code); } add(main); raw.split(/[;,|]/).forEach(add); return out; } function buildCrossSubjectTeachers_(config, subjects, teachers, reductions, crossSubjectTransfers) { const weeks = num_(config.SO_TUAN_CA_NAM) || 35; const defaultNorm = num_(config.DINH_MUC_GV_THPT) || 17; const subjectName = {}; subjects.forEach(s => subjectName[String(s.MA_MON || '')] = s.TEN_MON || s.MA_MON); getOverviewCurriculumCatalog_().forEach(s => { if (!subjectName[String(s.MA_MON)]) subjectName[String(s.MA_MON)] = s.TEN_MON; }); const reductionByTeacher = {}; (reductions || []).forEach(r => { const k = String(r.MA_GV || ''); reductionByTeacher[k] = (reductionByTeacher[k] || 0) + num_(r.GIAM_TIET_TUAN); }); const transferByTeacher = {}; (crossSubjectTransfers || []).forEach(x => { const k = String(x.MA_GV || ''); if (!k) return; if (!transferByTeacher[k]) transferByTeacher[k] = {}; const target = String(x.MON_DICH || ''); transferByTeacher[k][target] = (transferByTeacher[k][target] || 0) + Math.max(0, num_(x.TIET_CHUYEN_NAM)); }); return (teachers || []) .filter(t => bool_(t.KICH_HOAT)) .map(t => { const allowed = parseTeachingSubjects_(t); const maGv = String(t.MA_GV || ''); const current = transferByTeacher[maGv] || {}; if (allowed.length < 2 && !Object.keys(current).length) return null; const main = String(t.MON_CHINH || '').trim().toUpperCase(); const norm = num_(t.DINH_MUC_TUAN) || defaultNorm; const reduction = reductionByTeacher[maGv] || 0; const annualCapacity = Math.max(0, norm - reduction) * weeks; const targetCodes = allowed.filter(code => code !== main); Object.keys(current).forEach(code => { if (code && code !== main && targetCodes.indexOf(code) === -1) targetCodes.push(code); }); const allocations = targetCodes.map(code => ({ MA_MON: code, TEN_MON: subjectName[code] || code, TIET_CHUYEN_NAM: round_(current[code] || 0, 2), HOP_LE: allowed.indexOf(code) !== -1 })); const transferred = allocations.reduce((s,a) => s + num_(a.TIET_CHUYEN_NAM), 0); return { MA_GV: maGv, HO_TEN: t.HO_TEN, TO_CHUYEN_MON: t.TO_CHUYEN_MON, MON_CHINH: main, MON_CHINH_TEN: subjectName[main] || main, MON_CO_THE_DAY: allowed, MON_CO_THE_DAY_TEXT: allowed.map(code => subjectName[code] || code).join(', '), DINH_MUC_TUAN: round_(norm, 2), GIAM_KIEM_NHIEM_TUAN: round_(reduction, 2), NANG_LUC_NAM: round_(annualCapacity, 2), DA_CHUYEN_LIEN_MON: round_(transferred, 2), CON_LAI_MON_CHINH: round_(annualCapacity - transferred, 2), ALLOCATIONS: allocations, TRANG_THAI: transferred > annualCapacity + 0.001 ? 'VƯỢT NĂNG LỰC' : (transferred > 0.001 ? 'ĐÃ ĐIỀU TIẾT' : 'CHƯA ĐIỀU TIẾT') }; }) .filter(Boolean) .sort((a,b) => String(a.HO_TEN).localeCompare(String(b.HO_TEN), 'vi')); } function getOverviewCurriculumCatalog_() { // Danh mục chuẩn dùng riêng cho bảng Tổng quan toàn trường. // Mục tiêu: bảng luôn hiển thị ĐẦY ĐỦ các môn/hoạt động, kể cả khi // NHU_CAU_LOP_MON chưa nhập đủ từng lớp – môn. // PHAM_VI = ALL: áp dụng tự động cho toàn bộ lớp đang kích hoạt. // PHAM_VI = SELECTED: số lớp lấy từ các dòng nhu cầu thực tế đã khai báo. return [ {MA_MON:'NGU_VAN', TEN_MON:'Ngữ văn', LOAI:'Bắt buộc', TIET_CA_NAM:105, TIET_HK1:54, TIET_HK2:51, PHAM_VI:'ALL'}, {MA_MON:'TOAN', TEN_MON:'Toán', LOAI:'Bắt buộc', TIET_CA_NAM:105, TIET_HK1:54, TIET_HK2:51, PHAM_VI:'ALL'}, {MA_MON:'ANH_1', TEN_MON:'Ngoại ngữ 1 (Tiếng Anh)', LOAI:'Bắt buộc', TIET_CA_NAM:105, TIET_HK1:54, TIET_HK2:51, PHAM_VI:'ALL'}, {MA_MON:'LICH_SU', TEN_MON:'Lịch sử', LOAI:'Bắt buộc', TIET_CA_NAM:52, TIET_HK1:27, TIET_HK2:25, PHAM_VI:'ALL'}, {MA_MON:'GDTC', TEN_MON:'Giáo dục thể chất', LOAI:'Bắt buộc', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'ALL'}, {MA_MON:'GDQPAN', TEN_MON:'Giáo dục quốc phòng và an ninh', LOAI:'Bắt buộc', TIET_CA_NAM:35, TIET_HK1:18, TIET_HK2:17, PHAM_VI:'ALL'}, {MA_MON:'VAT_LY', TEN_MON:'Vật lí', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'HOA_HOC', TEN_MON:'Hóa học', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'SINH_HOC', TEN_MON:'Sinh học', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'CONG_NGHE_CN', TEN_MON:'Công nghệ - Công nghiệp', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'CONG_NGHE_NN', TEN_MON:'Công nghệ - Nông nghiệp', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'TIN_HOC', TEN_MON:'Tin học', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'DIA_LY', TEN_MON:'Địa lí', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'GDKTPL', TEN_MON:'Giáo dục kinh tế và pháp luật', LOAI:'Tự chọn', TIET_CA_NAM:70, TIET_HK1:36, TIET_HK2:34, PHAM_VI:'SELECTED'}, {MA_MON:'CD_HT_1', TEN_MON:'Chuyên đề học tập 1', LOAI:'Lựa chọn', TIET_CA_NAM:35, TIET_HK1:18, TIET_HK2:17, PHAM_VI:'SELECTED'}, {MA_MON:'CD_HT_2', TEN_MON:'Chuyên đề học tập 2', LOAI:'Lựa chọn', TIET_CA_NAM:35, TIET_HK1:18, TIET_HK2:17, PHAM_VI:'SELECTED'}, {MA_MON:'CD_HT_3', TEN_MON:'Chuyên đề học tập 3', LOAI:'Lựa chọn', TIET_CA_NAM:35, TIET_HK1:18, TIET_HK2:17, PHAM_VI:'SELECTED'}, {MA_MON:'HDTN_HN', TEN_MON:'Hoạt động trải nghiệm, hướng nghiệp', LOAI:'Bắt buộc', TIET_CA_NAM:105, TIET_HK1:54, TIET_HK2:51, PHAM_VI:'ALL'}, {MA_MON:'GDDP', TEN_MON:'Nội dung giáo dục địa phương', LOAI:'Bắt buộc', TIET_CA_NAM:35, TIET_HK1:18, TIET_HK2:17, PHAM_VI:'ALL'} ]; } function buildSchoolOverview_(config, subjects, classes, demands, teachers, reductions, subjectPlans, crossSubjectTransfers) { const weeks = num_(config.SO_TUAN_CA_NAM) || 35; const weeks1 = num_(config.SO_TUAN_HK1) || 18; const weeks2 = num_(config.SO_TUAN_HK2) || 17; const norm = num_(config.DINH_MUC_GV_THPT) || 17; const gvcnReduction = num_(config.GIAM_GVCN_TUAN) || 4; const catalog = getOverviewCurriculumCatalog_(); const catalogMap = {}; catalog.forEach((x,i) => { catalogMap[String(x.MA_MON)] = Object.assign({THU_TU:i}, x); }); const subjectMap = {}; subjects.forEach(s => subjectMap[String(s.MA_MON || '')] = s); const activeClasses = classes.filter(c => bool_(c.KICH_HOAT)); const classMap = {}; activeClasses.forEach(c => classMap[String(c.MA_LOP || '')] = c); const allClassCodes = activeClasses.map(c => String(c.MA_LOP || '')).filter(Boolean); const allClassNames = activeClasses.map(c => String(c.TEN_LOP || c.MA_LOP || '')).filter(Boolean) .sort((a,b) => String(a).localeCompare(String(b), 'vi')); const planMap = {}; (subjectPlans || []).forEach(p => planMap[String(p.MA_MON || '')] = p); const teacherCount = {}; const eligibleTeacherCount = {}; const activeTeachers = teachers.filter(t => bool_(t.KICH_HOAT)); activeTeachers.forEach(t => { const main = String(t.MON_CHINH || ''); if (main) teacherCount[main] = (teacherCount[main] || 0) + 1; parseTeachingSubjects_(t).forEach(code => { eligibleTeacherCount[code] = (eligibleTeacherCount[code] || 0) + 1; }); }); const transferOut = {}; const transferIn = {}; (crossSubjectTransfers || []).forEach(x => { const source = String(x.MON_NGUON || ''); const target = String(x.MON_DICH || ''); const periods = Math.max(0, num_(x.TIET_CHUYEN_NAM)); if (!source || !target || periods <= 0) return; transferOut[source] = (transferOut[source] || 0) + periods; transferIn[target] = (transferIn[target] || 0) + periods; }); // Gom nhu cầu thực tế. Các mã tổng hợp "chưa phân bổ" không đưa vào bảng // vì bảng Tổng quan đã hiển thị từng môn lựa chọn/chuyên đề riêng. const demandAgg = {}; demands.forEach(d => { if (!bool_(d.TINH_NHAN_SU)) return; const code = String(d.MA_MON || ''); if (!code || code === 'LC_CHUA_PB' || code === 'CD_CHUA_PB') return; const maLop = String(d.MA_LOP || ''); if (Object.keys(classMap).length && !classMap[maLop]) return; if (!demandAgg[code]) { demandAgg[code] = { totalAnnual:0,totalHk1:0,totalHk2:0,classes:{},loai:{}, TEN_MON:d.TEN_MON || (subjectMap[code] ? subjectMap[code].TEN_MON : code) }; } const a = demandAgg[code]; a.totalAnnual += num_(d.TIET_CA_NAM); a.totalHk1 += num_(d.TIET_HK1); a.totalHk2 += num_(d.TIET_HK2); a.loai[String(d.LOAI || 'Khác')] = true; if (maLop) { const cls = classMap[maLop]; a.classes[maLop] = cls ? (cls.TEN_LOP || maLop) : maLop; } }); // Danh sách mã cần hiển thị = toàn bộ danh mục chuẩn + môn thực tế phát sinh // + môn đang có giáo viên. Nhờ vậy không còn tình trạng chỉ hiện Ngữ văn. const codes = []; const seen = {}; function pushCode_(code) { code = String(code || ''); if (!code || code === 'LC_CHUA_PB' || code === 'CD_CHUA_PB' || seen[code]) return; seen[code] = true; codes.push(code); } catalog.forEach(x => pushCode_(x.MA_MON)); Object.keys(demandAgg).forEach(pushCode_); Object.keys(teacherCount).forEach(pushCode_); Object.keys(eligibleTeacherCount).forEach(pushCode_); Object.keys(transferOut).forEach(pushCode_); Object.keys(transferIn).forEach(pushCode_); const rows = codes.map(code => { const ref = catalogMap[code] || null; const a = demandAgg[code] || {totalAnnual:0,totalHk1:0,totalHk2:0,classes:{},loai:{}}; const demandClassCodes = Object.keys(a.classes || {}); const demandClassNames = demandClassCodes.map(k => a.classes[k]) .sort((x,y) => String(x).localeCompare(String(y), 'vi')); const appliesAll = !!ref && ref.PHAM_VI === 'ALL'; const p = planMap[code] || {}; const autoSoLop = appliesAll ? allClassCodes.length : demandClassCodes.length; const hasClassOverride = String(p.SO_LOP_DIEU_CHINH == null ? '' : p.SO_LOP_DIEU_CHINH).trim() !== ''; const soLop = hasClassOverride ? Math.max(0, num_(p.SO_LOP_DIEU_CHINH)) : autoSoLop; const autoClassNames = appliesAll ? allClassNames : demandClassNames; const classDetailOverride = String(p.CHI_TIET_LOP_DIEU_CHINH == null ? '' : p.CHI_TIET_LOP_DIEU_CHINH).trim(); const classDetail = classDetailOverride || autoClassNames.join(', '); let annualPerClass, hk1PerClass, hk2PerClass, totalAnnual; if (ref) { annualPerClass = num_(ref.TIET_CA_NAM); hk1PerClass = num_(ref.TIET_HK1); hk2PerClass = num_(ref.TIET_HK2); // Khi lãnh đạo nhập trực tiếp Số lớp ở Tổng quan, số tiết môn học phải // thay đổi ngay theo: số tiết/1 lớp × số lớp đã nhập. // Nếu chưa nhập đè, vẫn giữ nguyên logic tự động của phiên bản trước. totalAnnual = (appliesAll || hasClassOverride) ? annualPerClass * soLop : a.totalAnnual; } else { const divisor = autoSoLop || soLop; annualPerClass = divisor ? a.totalAnnual / divisor : 0; hk1PerClass = divisor ? a.totalHk1 / divisor : 0; hk2PerClass = divisor ? a.totalHk2 / divisor : 0; totalAnnual = hasClassOverride ? annualPerClass * soLop : a.totalAnnual; } const weekly1 = weeks1 ? hk1PerClass / weeks1 : 0; const weekly2 = weeks2 ? hk2PerClass / weeks2 : 0; const autoActualTeachers = teacherCount[code] || 0; const hasActualTeacherOverride = String(p.SO_GV_HIEN_CO_DIEU_CHINH == null ? '' : p.SO_GV_HIEN_CO_DIEU_CHINH).trim() !== ''; const actualTeachers = hasActualTeacherOverride ? Math.max(0, num_(p.SO_GV_HIEN_CO_DIEU_CHINH)) : autoActualTeachers; const plannedTeachers = String(p.SO_GV_DU_KIEN == null ? '' : p.SO_GV_DU_KIEN).trim() === '' ? actualTeachers : Math.max(0, num_(p.SO_GV_DU_KIEN)); const baseCapacity = plannedTeachers * norm * weeks; const crossOut = transferOut[code] || 0; const crossIn = transferIn[code] || 0; const effectiveCapacity = baseCapacity - crossOut + crossIn; // Quy ước của bảng Tổng quan: dương = thiếu, âm = thừa. // Trước khi tính GVCN/HĐTN/GDĐP/KN khác, năng lực đã được điều tiết liên môn. const before = totalAnnual - effectiveCapacity; const soGvcn = Math.max(0, num_(p.SO_GVCN)); const hdtnWeek = Math.max(0, num_(p.HDTN_TIET_TUAN)); const gddpWeek = Math.max(0, num_(p.GDDP_TIET_TUAN)); const otherWeek = Math.max(0, num_(p.KN_KHAC_TIET_TUAN)); const gvcnAnnual = soGvcn * gvcnReduction * weeks; // HĐTN/GDĐP có thể dạy không đều giữa các tuần và học kỳ. // Nếu người dùng đã nhập số tiết phân công cả năm thì dùng đúng số đó; // chỉ khi để trống mới suy ra theo tiết/tuần × số tuần. const hasHdtnAnnualManual = String(p.HDTN_TIET_NAM_DIEU_CHINH == null ? '' : p.HDTN_TIET_NAM_DIEU_CHINH).trim() !== ''; const hasGddpAnnualManual = String(p.GDDP_TIET_NAM_DIEU_CHINH == null ? '' : p.GDDP_TIET_NAM_DIEU_CHINH).trim() !== ''; const hdtnAnnual = hasHdtnAnnualManual ? Math.max(0, num_(p.HDTN_TIET_NAM_DIEU_CHINH)) : hdtnWeek * weeks; const gddpAnnual = hasGddpAnnualManual ? Math.max(0, num_(p.GDDP_TIET_NAM_DIEU_CHINH)) : gddpWeek * weeks; const otherAnnual = otherWeek * weeks; const totalAdjust = gvcnAnnual + hdtnAnnual + gddpAnnual + otherAnnual; const after = before + totalAdjust; const gvEquivalent = norm * weeks ? (totalAnnual + totalAdjust) / (norm * weeks) : 0; let status = 'CÂN BẰNG'; let suggestion = 'Cân đối phù hợp.'; if (after > 0.01) { status = 'THIẾU'; suggestion = 'Thiếu ' + round_(after, 2) + ' tiết/năm; cần bổ sung/điều tiết khoảng ' + round_(after / (norm * weeks), 2) + ' GV quy đổi.'; const eligible = eligibleTeacherCount[code] || 0; if (eligible > actualTeachers) { suggestion += ' Có ' + eligible + ' GV được khai báo có thể dạy môn này; nên kiểm tra ma trận điều tiết liên môn.'; } } else if (after < -0.01) { status = 'THỪA'; suggestion = 'Còn dư ' + round_(Math.abs(after), 2) + ' tiết/năm; có thể giao thêm nhiệm vụ/điều tiết khoảng ' + round_(Math.abs(after) / (norm * weeks), 2) + ' GV quy đổi.'; } const name = ref ? ref.TEN_MON : (a.TEN_MON || (subjectMap[code] ? subjectMap[code].TEN_MON : code)); const autoLoai = ref ? ref.LOAI : Object.keys(a.loai || {}).join(', '); const loaiOverride = String(p.LOAI_DIEU_CHINH == null ? '' : p.LOAI_DIEU_CHINH).trim(); const loai = loaiOverride || autoLoai; return { MA_MON: code, TEN_MON: name, CA_NAM_1_LOP: round_(annualPerClass, 2), LOAI: loai, HK1_1_LOP: round_(hk1PerClass, 2), HK2_1_LOP: round_(hk2PerClass, 2), TIET_TUAN_HK1: round_(weekly1, 2), TIET_TUAN_HK2: round_(weekly2, 2), SO_LOP: soLop, CHI_TIET_LOP: classDetail, SO_GV_HIEN_CO: actualTeachers, SO_GV_CO_THE_DAY: eligibleTeacherCount[code] || 0, SO_GV_DU_KIEN: round_(plannedTeachers, 2), NHU_CAU_TIET_NAM: round_(totalAnnual, 2), NANG_LUC_CO_SO: round_(baseCapacity, 2), NANG_LUC_THEO_GV: round_(effectiveCapacity, 2), TIET_CHUYEN_DI_LIEN_MON: round_(crossOut, 2), TIET_NHAN_LIEN_MON: round_(crossIn, 2), NANG_LUC_SAU_LIEN_MON: round_(effectiveCapacity, 2), THIEU_THUA_TRUOC: round_(before, 2), SO_GVCN: round_(soGvcn, 2), QUY_GVCN_NAM: round_(gvcnAnnual, 2), HDTN_TIET_TUAN: round_(hdtnWeek, 2), QUY_HDTN_NAM: round_(hdtnAnnual, 2), GDDP_TIET_TUAN: round_(gddpWeek, 2), QUY_GDDP_NAM: round_(gddpAnnual, 2), KN_KHAC_TIET_TUAN: round_(otherWeek, 2), QUY_KN_KHAC_NAM: round_(otherAnnual, 2), TONG_DIEU_CHINH_NAM: round_(totalAdjust, 2), THIEU_THUA_SAU: round_(after, 2), GV_QUY_DOI_SAU_DIEU_CHINH: round_(gvEquivalent, 2), TRANG_THAI: status, GOI_Y: suggestion, THU_TU_TONG_QUAN: ref ? ref.THU_TU : 9999 }; }).sort((a,b) => { return (num_(a.THU_TU_TONG_QUAN) - num_(b.THU_TU_TONG_QUAN)) || String(a.TEN_MON).localeCompare(String(b.TEN_MON), 'vi'); }); const summary = rows.reduce((s,r) => { s.totalDemand += num_(r.NHU_CAU_TIET_NAM); s.totalCapacity += num_(r.NANG_LUC_SAU_LIEN_MON); s.totalAdjustment += num_(r.TONG_DIEU_CHINH_NAM); s.plannedTeachers += num_(r.SO_GV_DU_KIEN); s.actualTeachers += num_(r.SO_GV_HIEN_CO); if (r.TRANG_THAI === 'THIẾU') { s.shortageSubjects++; s.shortagePeriods += num_(r.THIEU_THUA_SAU); } if (r.TRANG_THAI === 'THỪA') { s.surplusSubjects++; s.surplusPeriods += Math.abs(num_(r.THIEU_THUA_SAU)); } return s; }, { totalDemand: 0, totalCapacity: 0, totalAdjustment: 0, plannedTeachers: 0, actualTeachers: 0, shortageSubjects: 0, surplusSubjects: 0, shortagePeriods: 0, surplusPeriods: 0 }); summary.finalGap = summary.totalDemand + summary.totalAdjustment - summary.totalCapacity; Object.keys(summary).forEach(k => summary[k] = round_(summary[k], 2)); return { rows: rows, summary: summary, rules: { weeks: weeks, weeks1: weeks1, weeks2: weeks2, norm: norm, gvcnReduction: gvcnReduction, programAnnual: 997, programHk1: 513, programHk2: 484 } }; } function formatSchoolOverviewSheet_() { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.REPORT_SCHOOL); if (!sh) return; const headers = APP.HEADERS[APP.SHEETS.REPORT_SCHOOL]; if (sh.getMaxColumns() < headers.length) sh.insertColumnsAfter(sh.getMaxColumns(), headers.length - sh.getMaxColumns()); sh.setFrozenRows(1); sh.setFrozenColumns(2); sh.getRange(1,1,1,headers.length) .setFontWeight('bold') .setFontColor('#ffffff') .setBackground('#1f4e78') .setWrap(true) .setVerticalAlignment('middle'); if (sh.getLastRow() > 1) { sh.getRange(2,1,sh.getLastRow()-1,headers.length).setVerticalAlignment('middle').setWrap(true); } sh.setColumnWidth(2, 190); sh.setColumnWidth(10, 260); sh.setColumnWidth(headers.length, 320); for (let c = 3; c < headers.length; c++) { if (c !== 10) sh.setColumnWidth(c, 105); } } function getConfig_() { const out = {}; getRows_(APP.SHEETS.CONFIG).forEach(r => out[String(r.KHOA)] = r.GIA_TRI); return out; } function getDefaultNorm_(position) { const p = String(position || '').toLowerCase(); if (p.indexOf('hiệu trưởng') !== -1 && p.indexOf('phó') === -1) return 2; if (p.indexOf('phó hiệu trưởng') !== -1) return 4; return num_(getConfig_().DINH_MUC_GV_THPT) || 17; } function seedConfig_() { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.CONFIG); if (sh.getLastRow() > 1) return; const rows = [ ['TEN_TRUONG', 'TRƯỜNG THPT ...', 'Tên đơn vị'], ['NAM_HOC', '2026-2027', 'Năm học'], ['CAP_HOC', 'THPT', 'Phiên bản hiện tối ưu cho THPT'], ['SO_TUAN_HK1', 18, 'Số tuần học kỳ I'], ['SO_TUAN_HK2', 17, 'Số tuần học kỳ II'], ['SO_TUAN_CA_NAM', 35, 'Tổng số tuần thực dạy'], ['DINH_MUC_GV_THPT', 17, 'Định mức tiết dạy trung bình/tuần của GV THPT'], ['DINH_MUC_HIEU_TRUONG', 2, 'Định mức tiết dạy trung bình/tuần của Hiệu trưởng'], ['DINH_MUC_PHO_HIEU_TRUONG', 4, 'Định mức tiết dạy trung bình/tuần của Phó hiệu trưởng'], ['GIAM_GVCN_TUAN', 4, 'Số tiết/tuần quy đổi giảm cho 01 giáo viên chủ nhiệm trong bảng điều tiết'] ]; sh.getRange(2,1,rows.length,3).setValues(rows); } function ensureConfigDefaults_() { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.CONFIG); const existing = {}; getRows_(APP.SHEETS.CONFIG).forEach(r => existing[String(r.KHOA)] = true); const defaults = [ ['SO_TUAN_HK1', 18, 'Số tuần học kỳ I'], ['SO_TUAN_HK2', 17, 'Số tuần học kỳ II'], ['SO_TUAN_CA_NAM', 35, 'Tổng số tuần thực dạy'], ['DINH_MUC_GV_THPT', 17, 'Định mức chuẩn dùng cho bảng cân đối toàn trường'], ['GIAM_GVCN_TUAN', 4, 'Số tiết/tuần quy đổi giảm cho 01 giáo viên chủ nhiệm trong bảng điều tiết'] ]; const missing = defaults.filter(r => !existing[String(r[0])]); if (missing.length) sh.getRange(sh.getLastRow()+1,1,missing.length,3).setValues(missing); } function seedSubjects_() { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.SUBJECTS); if (sh.getLastRow() > 1) return; const rows = [ ['NGU_VAN','Ngữ văn','Ngữ văn',true,''], ['TOAN','Toán','Toán',true,''], ['ANH_1','Ngoại ngữ 1 (Tiếng Anh)','Ngoại ngữ',true,'Có thể đổi theo ngoại ngữ nhà trường tổ chức'], ['LICH_SU','Lịch sử','Khoa học xã hội',true,''], ['DIA_LY','Địa lí','Khoa học xã hội',true,''], ['GDKTPL','Giáo dục kinh tế và pháp luật','Khoa học xã hội',true,''], ['VAT_LY','Vật lí','Khoa học tự nhiên',true,''], ['HOA_HOC','Hóa học','Khoa học tự nhiên',true,''], ['SINH_HOC','Sinh học','Khoa học tự nhiên',true,''], ['TIN_HOC','Tin học','Tin học',true,''], ['CONG_NGHE','Công nghệ','Công nghệ',true,''], ['AM_NHAC','Âm nhạc','Nghệ thuật',true,'Nếu nhà trường tổ chức'], ['MY_THUAT','Mĩ thuật','Nghệ thuật',true,'Nếu nhà trường tổ chức'], ['GDTC','Giáo dục thể chất','GDTC',true,''], ['GDQPAN','Giáo dục quốc phòng và an ninh','GDQP-AN',true,''], ['HDTN_HN','Hoạt động trải nghiệm, hướng nghiệp','Hoạt động giáo dục',true,''], ['GDDP','Nội dung giáo dục địa phương','Hoạt động giáo dục',true,''], ['LC_CHUA_PB','04 môn học lựa chọn - chưa phân bổ','Tổng hợp',true,'Chỉ dùng để kiểm tổng số tiết; TINH_NHAN_SU = FALSE'], ['CD_CHUA_PB','03 chuyên đề học tập - chưa phân bổ','Tổng hợp',true,'Chỉ dùng để kiểm tổng số tiết; TINH_NHAN_SU = FALSE'] ]; sh.getRange(2,1,rows.length,5).setValues(rows); } function ensureOverviewSubjects_() { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.SUBJECTS); if (!sh) return; const existing = {}; getRows_(APP.SHEETS.SUBJECTS).forEach(r => existing[String(r.MA_MON || '')] = true); const catalog = getOverviewCurriculumCatalog_(); const missing = []; catalog.forEach(x => { if (existing[String(x.MA_MON)]) return; let to = 'Khác'; if (['NGU_VAN'].indexOf(x.MA_MON) >= 0) to = 'Ngữ văn'; else if (['TOAN','TIN_HOC'].indexOf(x.MA_MON) >= 0) to = 'Toán - Tin'; else if (['VAT_LY','HOA_HOC','SINH_HOC'].indexOf(x.MA_MON) >= 0) to = 'Khoa học tự nhiên'; else if (['CONG_NGHE_CN','CONG_NGHE_NN'].indexOf(x.MA_MON) >= 0) to = 'Công nghệ'; else if (['LICH_SU','DIA_LY','GDKTPL'].indexOf(x.MA_MON) >= 0) to = 'Khoa học xã hội'; else if (x.MA_MON === 'ANH_1') to = 'Ngoại ngữ'; else if (x.MA_MON === 'GDTC') to = 'GDTC'; else if (x.MA_MON === 'GDQPAN') to = 'GDQP-AN'; else if (['HDTN_HN','GDDP'].indexOf(x.MA_MON) >= 0) to = 'Hoạt động giáo dục'; else if (String(x.MA_MON).indexOf('CD_HT_') === 0) to = 'Chuyên đề'; missing.push([x.MA_MON, x.TEN_MON, to, true, 'Bổ sung tự động để dùng trong Tổng quan và điều tiết liên môn']); }); if (missing.length) sh.getRange(sh.getLastRow()+1,1,missing.length,5).setValues(missing); } function seedProgram_() { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.PROGRAM); if (sh.getLastRow() > 1) return; const base = [ ['NGU_VAN','Ngữ văn','Bắt buộc',105,54,51,true,'Theo bảng chương trình người dùng cung cấp'], ['TOAN','Toán','Bắt buộc',105,54,51,true,''], ['ANH_1','Ngoại ngữ 1 (Tiếng Anh)','Bắt buộc',105,54,51,true,''], ['LICH_SU','Lịch sử','Bắt buộc',52,27,25,true,''], ['GDTC','Giáo dục thể chất','Bắt buộc',70,36,34,true,''], ['GDQPAN','Giáo dục quốc phòng và an ninh','Bắt buộc',35,18,17,true,''], ['LC_CHUA_PB','04 môn học lựa chọn - chưa phân bổ','Chưa phân bổ',280,144,136,false,'Phải tách thành 4 môn cụ thể của từng lớp trước khi tính nhân sự'], ['CD_CHUA_PB','03 chuyên đề học tập - chưa phân bổ','Chưa phân bổ',105,54,51,false,'Phải tách thành các chuyên đề/môn cụ thể trước khi tính nhân sự'], ['HDTN_HN','Hoạt động trải nghiệm, hướng nghiệp','Hoạt động',105,54,51,true,''], ['GDDP','Nội dung giáo dục địa phương','Hoạt động',35,18,17,true,''] ]; const rows = []; [10,11,12].forEach(grade => base.forEach(r => rows.push([ makeId_('CT'), grade, r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7] ]))); sh.getRange(2,1,rows.length,10).setValues(rows); } function ensureSheet_(ss, name, headers) { let sh = ss.getSheetByName(name); if (!sh) sh = ss.insertSheet(name); if (sh.getMaxColumns() < headers.length) { sh.insertColumnsAfter(sh.getMaxColumns(), headers.length - sh.getMaxColumns()); } if (sh.getLastRow() === 0) sh.getRange(1,1,1,headers.length).setValues([headers]); const current = sh.getRange(1,1,1,headers.length).getValues()[0]; if (current.join('|') !== headers.join('|')) sh.getRange(1,1,1,headers.length).setValues([headers]); sh.setFrozenRows(1); sh.getRange(1,1,1,headers.length).setFontWeight('bold').setBackground('#e8eef8'); sh.autoResizeColumns(1, headers.length); } function getSpreadsheet_() { const props = PropertiesService.getScriptProperties(); const id = props.getProperty('SPREADSHEET_ID'); if (id) return SpreadsheetApp.openById(id); const ss = SpreadsheetApp.getActive(); if (!ss) throw new Error('Chưa xác định Google Sheet. Hãy chạy setupDatabase() một lần từ file Sheet.'); props.setProperty('SPREADSHEET_ID', ss.getId()); return ss; } function getRows_(sheetName) { const sh = getSpreadsheet_().getSheetByName(sheetName); if (!sh) return []; const values = sh.getDataRange().getValues(); if (values.length < 2) return []; const headers = values[0].map(String); return values.slice(1).filter(row => row.some(v => v !== '' && v !== null)).map(row => { const obj = {}; headers.forEach((h,i) => obj[h] = serialize_(row[i])); return obj; }); } function findBy_(sheetName, field, value) { return getRows_(sheetName).find(r => String(r[field]) === String(value)); } function upsert_(sheetName, idField, data, prefix) { const lock = LockService.getDocumentLock(); lock.waitLock(30000); try { const sh = getSpreadsheet_().getSheetByName(sheetName); const headers = APP.HEADERS[sheetName]; if (!sh || !headers) throw new Error('Sheet chưa được khởi tạo: ' + sheetName); const obj = Object.assign({}, data); if (!String(obj[idField] || '').trim()) obj[idField] = makeId_(prefix); const idCol = headers.indexOf(idField) + 1; const lastRow = sh.getLastRow(); let target = 0; if (lastRow > 1) { const ids = sh.getRange(2,idCol,lastRow - 1,1).getValues().flat().map(String); const idx = ids.indexOf(String(obj[idField])); if (idx >= 0) target = idx + 2; } const row = headers.map(h => obj[h] !== undefined ? obj[h] : ''); if (target) sh.getRange(target,1,1,headers.length).setValues([row]); else sh.getRange(lastRow + 1,1,1,headers.length).setValues([row]); return obj; } finally { lock.releaseLock(); } } function deleteBy_(sheetName, idField, id) { const sh = getSpreadsheet_().getSheetByName(sheetName); const headers = APP.HEADERS[sheetName]; const idCol = headers.indexOf(idField) + 1; if (sh.getLastRow() < 2) return; const ids = sh.getRange(2,idCol,sh.getLastRow()-1,1).getValues().flat().map(String); const idx = ids.indexOf(String(id)); if (idx >= 0) sh.deleteRow(idx + 2); } function writeObjects_(sheetName, headers, objects) { const sh = getSpreadsheet_().getSheetByName(sheetName); if (!sh) return; if (sh.getLastRow() > 1) sh.getRange(2,1,sh.getLastRow()-1,headers.length).clearContent(); if (objects.length) { sh.getRange(2,1,objects.length,headers.length).setValues(objects.map(o => headers.map(h => o[h] !== undefined ? o[h] : ''))); } } function log_(action, object, code, content) { const sh = getSpreadsheet_().getSheetByName(APP.SHEETS.LOG); if (!sh) return; sh.appendRow([new Date(), action, object, code, content]); } function serialize_(v) { if (Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v)) { return Utilities.formatDate(v, Session.getScriptTimeZone() || 'Asia/Ho_Chi_Minh', 'yyyy-MM-dd'); } return v; } function makeId_(prefix) { return String(prefix || 'ID') + '-' + new Date().getTime().toString(36).toUpperCase() + '-' + Utilities.getUuid().slice(0,6).toUpperCase(); } function num_(v) { if (typeof v === 'number') return isNaN(v) ? 0 : v; const n = Number(String(v == null ? '' : v).replace(/,/g,'.')); return isNaN(n) ? 0 : n; } function bool_(v) { if (v === true || v === 1) return true; const s = String(v == null ? '' : v).trim().toLowerCase(); return ['true','1','x','yes','có','co'].indexOf(s) >= 0; } function round_(v, digits) { const p = Math.pow(10, digits || 0); return Math.round((Number(v) + Number.EPSILON) * p) / p; }