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;
}
📢 Thông Báo Từ Hệ Thống
- Nâng cấp giao diện chính của trang blogMỚI Cập nhật: Hôm nay
- Bổ sung thêm công cụ hỗ trợ gõ chữ Dân tộc thiểu số Tây nguyênMỚI Cập nhật: Hôm nay
- Nâng cấp công cụ rà soát học bạ số: Tối ưu hóa bộ lọc kiểm tra danh mục hình thức trúng tuyển CSDL. Cập nhật: 02 tháng 06
- Bảo trì định kỳ và nâng cấp máy chủ lưu trữ dữ liệu tiện ích số DYS Series 2026. Cập nhật: 28 tháng 05
📰 Tin Tức & Chuyển ĐỔi Số
- Đẩy mạnh ứng dụng công nghệ thông tin và số hóa học bạ điện tử trong các trường THPT tỉnh Gia Lai.NEW Tin giáo dục tiêu điểm
- Tầm quan trọng của việc gìn giữ, phát triển ngôn ngữ và chữ viết chữ dân tộc thiểu số trên không gian số. Tin khoa học học đường
- Hướng dẫn tích hợp bộ quy tắc gõ TELEX mở rộng vào các phần mềm quản lý giáo dục. Cẩm nang công nghệ