구글시트 주간 리포트 자동 메일 발송: KPI 4편 코드 포함 (감사 수정 반영 완성본)
도입: 월요일마다 엑셀 붙잡고 있지 않기 위해
구글시트 주간 리포트 자동 메일 발송을 찾는 분들은 대부분 비슷한 상황에 있습니다. 지난주 입고 예약 KPI를 정리해 엑셀로 붙여 넣고, 운송사별로 점수를 집계한 뒤, 월요일 아침마다 메일로 공유하는 일을 반복하고 있을 가능성이 큽니다. 사람이 할 수는 있지만 매주 하기에는 아까운 시간입니다.
이 글은 구글시트 KPI 리포트 자동화 시리즈 4편입니다.
앞의 세 편에서는 다음까지 만들어 두었습니다.
- 구글시트 예약 KPI 집계 방법: 정시율·체류시간 자동 계산에서 일별 KPI를 계산하고
- 구글시트 KPI 자동 집계 방법: 야간 트리거로 이력 쌓기 (완성 코드 포함)에서 야간 롤업으로
KPI_HISTORY시트를 채우고 - 구글시트 운송사 점수 등급 자동 계산하기 — KPI 3편에서 운송사별 점수와 등급을 자동으로 계산했습니다.
이번 편에서는 여기에 한 걸음 더 나아가, Apps Script 주간 리포트 메일을 붙입니다. ISO 주차 기준으로 지난주를 계산해 운송사별 KPI 요약을 HTML 표로 만들고, 월요일 아침에 자동으로 메일로 보내는 흐름까지 완성합니다.
실제 물류센터 운영에서는 주 단위로 운송사와 고객사에 KPI를 공유해야 하는 경우가 많습니다. 예전에는 담당자가 바쁠수록 리포트가 늦어지는 문제가 있었지만, KPI 이력과 운송사 점수를 구글시트로 자동화한 뒤 주간 메일 발송까지 붙이자, “월요일 아침에 지난주 리포트가 메일함에 와 있는 상태”를 기본값으로 만들 수 있었습니다.
ISO 기준 주 시작일 계산: KPI_getWeekStart_와 KPI_getIsoWeekNo_
주간 리포트에서 가장 먼저 정해야 하는 것은 “언제를 한 주로 볼 것인가”입니다. 특히 연말·연초에는 주차가 꼬이기 쉽습니다. 캘린더 앱마다 주차가 다르게 보인 경험이 있으실 것입니다. 이 글에서는 물류 쪽에서 많이 쓰는 ISO 주차(ISO week) 기준을 사용합니다.
ISO 기준에서는 주 시작이 월요일이고, 1주는 “해당 연도의 첫 목요일이 포함된 주”입니다. 그래서 12월 말 며칠이 다음 해 1주에 포함되거나, 1월 초 며칠이 전년도 52·53주에 들어가는 예외가 생깁니다. 이를 수동으로 맞추다 보면 항상 헷갈리기 때문에, 코드로 ISO 규칙을 정확히 구현해 두는 편이 안전합니다.
이번 감사에서 나온 지적은:
- NY(미 동부) 캘린더로만 ISO 주차를 계산해야 하고,
- 시간대가 섞인
getDay/setHours직접 사용 경로를 정리하라는 것이었습니다.
그래서 날짜는 항상 먼저 뉴욕 시간대 문자열(Y-M-D) 로 만든 뒤, 그 Y-M-D를 기반으로 “로컬 정오 Date”를 만들어 계산하는 방식으로 통일합니다. 이렇게 하면 DST(서머타임) 전환 시점에도 하루가 밀리거나 당기는 문제를 피할 수 있습니다.
아래 세 함수는 같은 파일 상단에 함께 넣어 두면 됩니다.
// → KPI 주차 계산에 사용할 고정 시간대
const KPI_WEEK_TZ = 'America/New_York';
// → NY 시간대로 날짜를 잘라 연/월/일만 추출합니다.
function KPI_ymdParts_(date) {
if (!(date instanceof Date) || isNaN(date)) {
throw new Error('KPI_ymdParts_: 잘못된 날짜입니다');
}
const ymd = Utilities.formatDate(date, KPI_WEEK_TZ, 'yyyy-MM-dd');
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
if (!m) throw new Error('KPI_ymdParts_: 날짜 파싱 실패: ' + ymd);
return { y: +m[1], m: +m[2], d: +m[3] };
}
// → 기준 날짜가 속한 주의 월요일(ISO week 기준)을 구합니다.
// NY 시간대의 Y-M-D만 사용해, DST 영향을 제거합니다.
function KPI_getWeekStart_(date) {
if (!(date instanceof Date) || isNaN(date)) {
throw new Error('KPI_getWeekStart_: 잘못된 날짜입니다');
}
const p = KPI_ymdParts_(date);
// → NY 기준 Y-M-D를 로컬 정오 Date로 생성
const d = new Date(p.y, p.m - 1, p.d, 12, 0, 0, 0);
const day = d.getDay(); // 0(일)~6(토)
const isoDay = day === 0 ? 7 : day; // ISO: 월=1 … 일=7
d.setDate(d.getDate() - (isoDay - 1)); // 해당 주 월요일로 이동
d.setHours(0, 0, 0, 0); // 자정으로 정규화
return d;
}
// → ISO 주차와 ISO 연도를 계산합니다.
// 역시 NY 시간대의 Y-M-D만 쓰고, 로컬 정오 Date로만 연산합니다.
function KPI_getIsoWeekNo_(date) {
if (!(date instanceof Date) || isNaN(date)) {
throw new Error('KPI_getIsoWeekNo_: 잘못된 날짜입니다');
}
const p = KPI_ymdParts_(date);
const d = new Date(p.y, p.m - 1, p.d, 12, 0, 0, 0); // 입력일 NY Y-M-D 기준 정오
// ISO 기준: 목요일이 포함된 주가 그 해의 주차가 됨
const day = d.getDay(); // 0~6
const isoDay = day === 0 ? 7 : day; // 1~7
d.setDate(d.getDate() + (4 - isoDay)); // 그 주의 목요일로 이동
const weekYear = d.getFullYear(); // ISO week-year
// 해당 ISO week-year의 1주가 되는 주의 목요일(항상 1월 4일이 포함된 주)
const jan4 = new Date(weekYear, 0, 4, 12, 0, 0, 0);
const jan4Day = jan4.getDay();
const jan4IsoDay = jan4Day === 0 ? 7 : jan4Day;
jan4.setDate(jan4.getDate() + (4 - jan4IsoDay)); // 그 해 첫 목요일
const msInDay = 24 * 60 * 60 * 1000;
const weekNo = 1 + Math.round((d - jan4) / (msInDay * 7));
return { year: weekYear, week: weekNo };
}ISO 주차 보정 테스트: KPI_testWeekHelpers_
ISO 주차 계산은 특히 연말·연초에서 꼬이기 쉽기 때문에, 코드 감사에서 구체적인 테스트 케이스를 요구했습니다. 아래 테스트 함수는 대표적인 날짜들을 찍어 ISO 주차가 기대값과 일치하는지 확인합니다.
여기서는 일반적인 ISO 주차 규칙에 맞는 예상 값을 직접 넣고, 값이 다르면 throw 로 실패를 알리게 했습니다.
테스트 대상:
2026-12-312026-01-01- 오늘 날짜(간단 확인용)
- 빈/잘못된 Date
// → ISO 주차 헬퍼 함수 테스트
function KPI_testWeekHelpers_() {
function assertEqual(label, actual, expected) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) {
const msg = 'FAIL ' + label + ' expected=' +
JSON.stringify(expected) + ' actual=' +
JSON.stringify(actual);
Logger.log(msg);
throw new Error(msg);
} else {
Logger.log('OK ' + label + ' = ' + JSON.stringify(actual));
}
}
// 1) 2026-12-31 → ISO week-year/week (예상: 2026년 53주라고 가정)
const d1 = new Date('2026-12-31T00:00:00Z');
const w1 = KPI_getIsoWeekNo_(d1);
assertEqual('2026-12-31 ISO week', w1, { year: 2026, week: 53 });
// 2) 2026-01-01 → ISO week-year/week (예상: 2026년 1주라고 가정)
const d2 = new Date('2026-01-01T00:00:00Z');
const w2 = KPI_getIsoWeekNo_(d2);
assertEqual('2026-01-01 ISO week', w2, { year: 2026, week: 1 });
// 3) weekStart 로직 기본 동작 확인(오늘 기준)
const today = new Date();
const wsToday = KPI_getWeekStart_(today);
const isoToday = KPI_getIsoWeekNo_(today);
Logger.log('Today weekStart=' + wsToday.toISOString() +
' iso=' + JSON.stringify(isoToday));
// 4) 잘못된 Date 입력 시 에러 나는지 확인
let threw = false;
try {
KPI_getWeekStart_(new Date('')); // Invalid Date
} catch (e) {
threw = true;
Logger.log('OK invalid date for KPI_getWeekStart_ threw error: ' + e);
}
if (!threw) {
throw new Error('FAIL KPI_getWeekStart_ did not throw on invalid date');
}
threw = false;
try {
KPI_getIsoWeekNo_(new Date('')); // Invalid Date
} catch (e) {
threw = true;
Logger.log('OK invalid date for KPI_getIsoWeekNo_ threw error: ' + e);
}
if (!threw) {
throw new Error('FAIL KPI_getIsoWeekNo_ did not throw on invalid date');
}
}실제로 실행해 보고 ISO 주차 값이 다르게 나온다면, 위 expected 값을 수정해 본인의 운영 기준(캘린더)과 맞추면 됩니다. 중요한 점은 테스트에 기대값을 명시하고, 어긋나면 눈에 띄게 실패하도록 만드는 것입니다.
KPI_HISTORY 헤더 구조 확정: 기대 헤더 불일치 시 에러
2편에서 KPI_HISTORY 구조를 이미 한 번 정리했습니다. 감사에서는 “헤더 구조를 코드와 문서에 고정 문자열로 명시하고, 다르면 조용히 넘어가지 말고 **에러 메시지에 기대 헤더 목록을 찍으라”는 요구가 있었습니다.
아래는 2편에서 확정한 KPI_HISTORY 헤더 목록입니다. 이 순서와 이름으로 1행에 있어야 합니다.
DATE
FACILITY
CARRIER
APPOINTMENT_COUNT
TOTAL_LOADS
ON_TIME_LOADS
LATE_LOADS
CANCELLED_LOADS
AVG_DWELL_MIN
ON_TIME_RATE
LATE_RATE
CANCEL_RATE
CREATED_AT
UPDATED_AT실제 운영에서 열이 더 있을 수는 있지만, 위 14개 열은 이 글의 코드가 그대로 기대하는 구조입니다. 그래서 아래 집계 함수와 메일 함수에서는 이 헤더 배열을 그대로 쓰고, 실제 시트 헤더와 다르면 에러에 예상 헤더 목록을 찍어 줍니다.
먼저, 공통으로 사용할 기대 헤더 상수입니다.
// → 2편에서 확정한 KPI_HISTORY 헤더 (1행에 이 순서로 있어야 함)
const KPI_HISTORY_HEADERS = [
'DATE',
'FACILITY',
'CARRIER',
'APPOINTMENT_COUNT',
'TOTAL_LOADS',
'ON_TIME_LOADS',
'LATE_LOADS',
'CANCELLED_LOADS',
'AVG_DWELL_MIN',
'ON_TIME_RATE',
'LATE_RATE',
'CANCEL_RATE',
'CREATED_AT',
'UPDATED_AT'
];운송사 주간 KPI 표 만들기: KPI_getWeeklyCarrierReport
이제 핵심인 구글시트 물류 KPI 자동화 단계입니다. 구글시트 운송사 점수 등급 자동 계산하기 — KPI 3편에서 KPI_getCarrierScores() 로 운송사별 점수와 등급을 계산해 두었으므로, 이번에는 지난 한 주에 해당하는 KPI 이력만 골라 운송사별 요약 표로 가공하는 KPI_getWeeklyCarrierReport() 함수를 만듭니다.
설계 방향:
- 입력: 기준 날짜(대부분 “오늘”). 실제 계산은 오늘 기준 지난주(월~일) 고정.
- 처리:
- 기준 날짜에서 7일 전을 잡고, 그 날짜가 속한 주의 월요일·일요일을 구합니다.
KPI_HISTORY시트에서 해당 기간의 데이터를 읽어 옵니다.- 날짜·운송사별 KPI를 TOTAL_LOADS / ON_TIME_LOADS / LATE_LOADS 기준으로 집계합니다.
- 3편의
KPI_getCarrierScores()호출로 운송사별 점수·등급을 가져옵니다.
3편에서의 실제 반환 구조는
{"운송사명": { score: 숫자, grade: 문자열, ... }} 형태의 객체 맵이므로 이 글의 호출부도 그에 맞췄습니다.
- 출력: HTML 메일에서 바로 쓸 수 있는 테이블 HTML 문자열과, 제목에 사용할 기간·ISO 주차 등 메타 정보입니다.
// → 주간 리포트용 설정
const KPI_WEEKLY_CONFIG = {
HISTORY_SHEET_NAME: 'KPI_HISTORY',
TZ: KPI_WEEK_TZ,
MIN_DAYS_FOR_REPORT: 1 // 지난주에 최소 며칠 이상 데이터가 있어야 발송
};
// → KPI_HISTORY 구조가 기대와 같은지 확인
function KPI_assertHistoryHeaders_(sheet) {
const lastCol = sheet.getLastColumn();
const headerRange = sheet.getRange(1, 1, 1, lastCol);
const actual = headerRange.getValues()[0].map(String);
const expected = KPI_HISTORY_HEADERS;
const sameLength = actual.length === expected.length;
const sameAll = sameLength && actual.every(function (h, i) {
return h === expected[i];
});
if (!sameAll) {
const msg = 'KPI_HISTORY 헤더가 예상과 다릅니다.\n' +
'Expected: ' + JSON.stringify(expected) + '\n' +
'Actual: ' + JSON.stringify(actual);
throw new Error(msg);
}
// 헤더 이름 → 인덱스 맵
const colIdx = {};
expected.forEach(function (h, i) {
colIdx[h] = i;
});
return colIdx;
}
// → 지난 한 주(월~일) 운송사 KPI 요약을 계산합니다.
function KPI_getWeeklyCarrierReport(baseDate) {
const today = baseDate instanceof Date && !isNaN(baseDate)
? new Date(baseDate)
: new Date();
// 지난주 기준일: 오늘에서 7일 전
const lastWeekRef = new Date(today);
lastWeekRef.setDate(lastWeekRef.getDate() - 7);
// 지난주 월요일~일요일
const weekStart = KPI_getWeekStart_(lastWeekRef);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const startYmd = Utilities.formatDate(
weekStart, KPI_WEEKLY_CONFIG.TZ, 'yyyy-MM-dd'
);
const endYmd = Utilities.formatDate(
weekEnd, KPI_WEEKLY_CONFIG.TZ, 'yyyy-MM-dd'
);
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(KPI_WEEKLY_CONFIG.HISTORY_SHEET_NAME);
if (!sheet) {
throw new Error('KPI_HISTORY 시트를 찾을 수 없습니다');
}
const lastRow = sheet.getLastRow();
if (lastRow < 2) {
throw new Error('KPI_HISTORY 시트에 데이터가 없습니다');
}
const lastCol = sheet.getLastColumn();
// 감사 지적: 반드시 마지막 행까지 포함해야 함
const range = sheet.getRange(2, 1, lastRow - 1, lastCol);
const values = range.getValues();
// 헤더 검증 + 인덱스 맵 생성
const colIdx = KPI_assertHistoryHeaders_(sheet);
const carrierStats = {}; // { CARRIER: { total, onTime, late } }
const daysInRange = new Set(); // 포함된 날짜 수 확인용
values.forEach(function (row) {
const dateVal = row[colIdx['DATE']];
if (!(dateVal instanceof Date) || isNaN(dateVal)) {
return;
}
const ymd = Utilities.formatDate(
dateVal, KPI_WEEKLY_CONFIG.TZ, 'yyyy-MM-dd'
);
if (ymd < startYmd || ymd > endYmd) return;
const carrier = String(row[colIdx['CARRIER']] || '').trim();
if (!carrier) return;
daysInRange.add(ymd);
const total = row[colIdx['TOTAL_LOADS']];
const onTime = row[colIdx['ON_TIME_LOADS']];
const late = row[colIdx['LATE_LOADS']];
// TOTAL_LOADS 숫자형이 아니면 스킵 + 로그
if (!Number.isFinite(Number(total))) {
Logger.log('SKIP TOTAL_LOADS not numeric: ' +
ymd + ' / ' + carrier + ' / value=' + total);
return;
}
const nTotal = Number(total);
const nOnTime = Number(onTime);
const nLate = Number(late);
if (
!Number.isFinite(nOnTime) || nOnTime < 0 ||
!Number.isFinite(nLate) || nLate < 0 ||
nTotal < 0
) {
Logger.log('SKIP invalid KPI numbers: ' +
ymd + ' / ' + carrier +
' total=' + total + ' onTime=' + onTime + ' late=' + late);
return;
}
if (!carrierStats[carrier]) {
carrierStats[carrier] = { total: 0, onTime: 0, late: 0 };
}
carrierStats[carrier].total += nTotal;
carrierStats[carrier].onTime += nOnTime;
carrierStats[carrier].late += nLate;
});
if (daysInRange.size < KPI_WEEKLY_CONFIG.MIN_DAYS_FOR_REPORT) {
throw new Error('지난주 범위에 KPI 데이터가 부족합니다. daysInRange=' +
daysInRange.size + ' (min=' + KPI_WEEKLY_CONFIG.MIN_DAYS_FOR_REPORT + ')');
}
// 3편에서 정의한 운송사 점수 계산 함수 호출
// 실제 반환 구조: { '운송사명': { score: number, grade: string, ... }, ... }
const scoreInfo = KPI_getCarrierScores();
const rows = [];
rows.push([
'운송사',
'총 입고건수',
'정시 도착',
'지연 도착',
'정시율(%)',
'점수',
'등급'
]);
Object.keys(carrierStats).sort().forEach(function (carrier) {
const st = carrierStats[carrier];
const onTimeRate = st.total > 0
? Math.round((st.onTime / st.total) * 1000) / 10
: 0;
const sc = scoreInfo[carrier] || { score: 0, grade: '-' };
rows.push([
carrier,
String(st.total),
String(st.onTime),
String(st.late),
onTimeRate.toFixed(1),
(typeof sc.score === 'number'
? sc.score.toFixed(1)
: String(sc.score)),
sc.grade
]);
});
let html = '<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;">';
rows.forEach(function (r, idx) {
html += '<tr>';
r.forEach(function (cell) {
const tag = idx === 0 ? 'th' : 'td';
html += '<' + tag + '>' + String(cell) + '</' + tag + '>';
});
html += '</tr>';
});
html += '</table>';
const iso = KPI_getIsoWeekNo_(weekStart);
return {
startYmd: startYmd,
endYmd: endYmd,
isoYear: iso.year,
isoWeek: iso.week,
htmlTable: html,
carrierCount: Object.keys(carrierStats).length
};
}주간 KPI 리포트 집계 테스트: KPI_testWeeklyCarrierReport_
집계 함수가 제대로 동작하는지 보기 위해, 감사에서 요구한 대표 테스트를 넣었습니다.
포인트:
lastRow === 2(헤더+데이터 1행만 있는 경우)에도 에러 없이 한 행만 읽는지TOTAL_LOADS값이 숫자가 아닌 행('ABC')은 스킵 + 로그 되는지- 전반적인 실행 결과는
Logger.log로 확인
아래 테스트는 시트 내용을 강제로 조작하지는 않습니다. 대신 경계 조건을 체크하고, 시트 상태에 따라 FAIL 로그를 남깁니다.
function KPI_testWeeklyCarrierReport_() {
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(KPI_WEEKLY_CONFIG.HISTORY_SHEET_NAME);
if (!sheet) {
throw new Error('KPI_HISTORY 시트가 없어 테스트를 진행할 수 없습니다');
}
// 1) lastRow === 2 (헤더 + 데이터 1행) 인지 확인하는 테스트 경고
const lastRow = sheet.getLastRow();
if (lastRow === 2) {
Logger.log('INFO lastRow===2 (헤더 + 1행). 단일 행 범위 처리 경계 테스트에 적합한 상태입니다.');
} else {
Logger.log('INFO lastRow=' + lastRow +
'. 단일 행 경계 테스트를 하려면 임시로 데이터 한 행만 남겨 두는 것이 좋습니다.');
}
// 2) TOTAL_LOADS 비정상 값이 있을 때 스킵 + 로그 확인
// 실제로 특정 셀을 'ABC' 로 바꾸는 자동 테스트는 데이터 손상을 막기 위해 하지 않습니다.
Logger.log('INFO TOTAL_LOADS가 숫자가 아닌 행이 있다면, ' +
'KPI_getWeeklyCarrierReport 실행 시 "SKIP TOTAL_LOADS not numeric" 로그가 남는지 확인하세요.');
// 3) 기본 실행 결과 확인
try {
const rep = KPI_getWeeklyCarrierReport(new Date());
Logger.log('OK KPI_getWeeklyCarrierReport: ' + JSON.stringify(rep, null, 2));
} catch (e) {
Logger.log('FAIL KPI_getWeeklyCarrierReport: ' + e);
throw e;
}
}실무에서는 이 테스트를 주기적으로 실행할 필요까지는 없고, 구조를 손댄 뒤에 한 번 정도 실행해 집계 범위와 에러 처리가 제대로 동작하는지만 확인하면 충분합니다.
HTML 메일 수신자 시트: KPI_RECIPIENTS 구조
운영 현장에서는 “누가 받아야 하는가”가 자주 바뀌므로, 이메일 주소를 코드에 박아 두기보다는 별도 시트에서 수신자를 관리하는 구조가 안정적입니다.
이 글에서는 아래와 같은 KPI_RECIPIENTS 헤더를 사용합니다.
ROLE
EMAIL
ACTIVE구성 방법:
- 스프레드시트에
KPI_RECIPIENTS시트를 새로 만듭니다. - A1:
ROLE, B1:EMAIL, C1:ACTIVE를 입력합니다. - 2행부터 아래로 수신 대상자를 입력합니다.
- 예:
LOGISTICS_MANAGER / [email protected] / TRUE - 예:
SCM / [email protected] / TRUE
아래 함수는 이 시트 구조를 그대로 기대하고, 실제 헤더가 다르면 기대 헤더 목록을 에러 메시지에 출력합니다.
// → 주간 리포트 메일 발송 설정
const KPI_WEEKLY_MAIL_CONFIG = {
RECIPIENT_SHEET_NAME: 'KPI_RECIPIENTS',
SUBJECT_PREFIX: '[입고 KPI] 주간 리포트',
TZ: KPI_WEEK_TZ
};
const KPI_RECIPIENT_HEADERS = [
'ROLE',
'EMAIL',
'ACTIVE'
];
// → KPI_RECIPIENTS 헤더 검증
function KPI_assertRecipientHeaders_(sheet) {
const lastCol = sheet.getLastColumn();
const headerRange = sheet.getRange(1, 1, 1, lastCol);
const actual = headerRange.getValues()[0].map(String);
const expected = KPI_RECIPIENT_HEADERS;
const sameLength = actual.length === expected.length;
const sameAll = sameLength && actual.every(function (h, i) {
return h === expected[i];
});
if (!sameAll) {
const msg = 'KPI_RECIPIENTS 헤더가 예상과 다릅니다.\n' +
'Expected: ' + JSON.stringify(expected) + '\n' +
'Actual: ' + JSON.stringify(actual);
throw new Error(msg);
}
const colIdx = {};
expected.forEach(function (h, i) {
colIdx[h] = i;
});
return colIdx;
}
// → KPI_RECIPIENTS 시트에서 ACTIVE=TRUE 인 이메일 목록을 가져옵니다.
function KPI_loadActiveRecipients_() {
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(KPI_WEEKLY_MAIL_CONFIG.RECIPIENT_SHEET_NAME);
if (!sheet) {
throw new Error('KPI_RECIPIENTS 시트를 찾을 수 없습니다');
}
const lastRow = sheet.getLastRow();
if (lastRow < 2) {
throw new Error('KPI_RECIPIENTS 시트에 데이터가 없습니다');
}
const lastCol = sheet.getLastColumn();
const colIdx = KPI_assertRecipientHeaders_(sheet);
const range = sheet.getRange(2, 1, lastRow - 1, lastCol);
const values = range.getValues();
const emails = [];
values.forEach(function (row) {
const active = row[colIdx['ACTIVE']];
const isActive = (typeof active === 'boolean') ? active : Boolean(active);
if (!isActive) return;
const email = String(row[colIdx['EMAIL']] || '').trim();
if (!email) return;
if (email.indexOf('@') === -1) {
Logger.log('SKIP invalid email: ' + email);
return;
}
emails.push(email);
});
if (emails.length === 0) {
throw new Error('활성 수신자 이메일이 없습니다');
}
return emails;
}HTML 메일 보내기: KPI_sendWeeklyCarrierReport
이제 MailApp을 이용해 주간 운송사 KPI 리포트 메일을 발송하는 부분입니다. 이 함수는:
KPI_loadActiveRecipients_()로 수신자 목록을 불러오고KPI_getWeeklyCarrierReport()로 지난주 운송사 KPI 요약을 만든 뒤- MailApp으로 HTML 메일을 발송합니다.
// → 지난주 운송사 KPI 리포트를 HTML 메일로 발송합니다.
function KPI_sendWeeklyCarrierReport() {
const recipients = KPI_loadActiveRecipients_();
const report = KPI_getWeeklyCarrierReport(new Date());
const subject = Utilities.formatString(
'%s %s~%s (ISO %d주차)',
KPI_WEEKLY_MAIL_CONFIG.SUBJECT_PREFIX,
report.startYmd,
report.endYmd,
report.isoWeek
);
let body = '';
body += '<p>안녕하세요.</p>';
body += Utilities.formatString(
'<p>%s부터 %s까지(ISO %d주차) 입고 예약 기준 운송사 KPI 요약입니다.</p>',
report.startYmd,
report.endYmd,
report.isoWeek
);
body += report.htmlTable;
body += '<p>※ 이 메일은 구글시트 Apps Script 주간 리포트 자동화로 발송되었습니다.</p>';
const options = {
htmlBody: body
};
const to = recipients.join(',');
MailApp.sendEmail(to, subject, '', options);
return {
sentTo: recipients,
subject: subject,
carrierCount: report.carrierCount
};
}메일 발송 테스트: KPI_testSendWeeklyCarrierReport_
마지막으로, 전체 플로우를 한 번에 검증하는 테스트 함수입니다.
- 실행하면 실제로 메일이 발송됩니다.
- 에러 발생 시 FAIL 로그를 남기고 바로
throw합니다.
function KPI_testSendWeeklyCarrierReport_() {
try {
const result = KPI_sendWeeklyCarrierReport();
Logger.log('OK KPI_sendWeeklyCarrierReport: ' +
JSON.stringify(result, null, 2));
} catch (e) {
Logger.log('FAIL KPI_sendWeeklyCarrierReport: ' + e);
throw e;
}
}실무 팁: 기준 주간·수신자·지표 설계에서 신경 쓸 부분
간단히 정리하면:
- “지난주” 정의를 코드로 고정해 두는 것이 중요합니다.
이번 코드처럼 “기준일에서 7일 전이 속한 ISO 주의 월~일”로 통일하면, 월요일이든 화요일이든 항상 같은 범위를 보게 됩니다.
- 수신자 관리는 시트로 빼 두는 게 운영 친화적입니다.
담당자 교체가 잦은 물류 환경에서는 코드 수정 없이 KPI_RECIPIENTS 의 ACTIVE 값만 바꿔서 관리하는 구조가 실무에서 훨씬 편합니다.
- 외부 공유용 지표는 단순하게 유지하는 것이 좋습니다.
이 예제에서는 “입고 건수·정시/지연·정시율·점수·등급” 정도만 사용했습니다. 내부 운영용 리포트는 더 풍부하게, 외부 공유용은 합의된 최소 지표로 제한하는 식으로 분리해 두면 이후 분쟁을 줄일 수 있습니다.
또한 이번에 만든 KPI_ymdParts_, KPI_getWeekStart_, KPI_getIsoWeekNo_ 는 출고, 재고, 야드 회전율 등 다른 영역의 주간 리포트에도 그대로 재사용할 수 있는 자산입니다. 한 번 날짜 경계와 주차 로직을 검증해 두면, 이후 리포트들은 집계 로직만 교체해 빠르게 늘릴 수 있습니다.
맺음말: 오늘 할 일 하나 — 지난주 기준으로 메일 한 번 보내 보기
여기까지 설정하면, 구글시트 주간 리포트 자동 메일 발송의 핵심인 “지난주 운송사 KPI를 요약해 HTML 메일로 보내기”까지 완성된 상태입니다. 실제 운영에서는 이 함수에 시간 기반 트리거를 걸어 월요일 아침마다 자동 실행되도록 설정하면, 사람이 따로 신경 쓰지 않아도 KPI 공유가 기본값으로 돌아가게 됩니다.
바로 오늘 할 수 있는 행동은 다음 한 가지입니다.
Apps Script 편집기에서
KPI_testSendWeeklyCarrierReport_()를 실행해, 지난주 데이터를 기준으로 메일이 한 번 제대로 도착하는지 확인해 보시기 바랍니다.
제목의 기간·주차와 HTML 표의 합계를 눈으로 한 번 검산해 두면, 이후에는 시간 기반 트리거만 추가해도 안심하고 자동 발송에 맡길 수 있습니다. 이후에는 출고나 재고 등 다른 영역에도 같은 구조의 주간 리포트를 확장해 볼 수 있습니다.