구글시트 출고 서머리 자동화 방법
도입: Load별 출고 검증을 구글시트로 자동화하기
창고·물류 현장에서 구글시트로 출고를 관리하다 보면, 트럭 한 대(Load)에 무엇이 얼마나 실리는지 한눈에 보기 어렵습니다. Load별 요약 시트가 없어 주문 목록과 피킹 리스트를 수동으로 대조하며 검증해야 하고, 팔레트 수·총중량·모델별 수량을 계산해 보면 작은 오차가 자주 발생합니다. 이 글에서는 구글시트 출고 서머리 자동화 방법을 통해, 구글시트와 Apps Script를 이용해 Load별 출고 내역을 자동 집계하고 검증하는 과정을 단계별로 정리합니다.
이번 글은 앞선 재고·출고 자동화 스크립트 위에 “Load별 서머리”를 추가하는 단계입니다. 핵심은 OUTBOUND 데이터에 있는 Load 번호를 기준으로 모델·팔레트·중량을 자동 집계하고, SUMMARY 시트에 보기 좋게 모아 주는 것입니다. 여기에 Load 상태를 LOADED로 마감하는 함수까지 묶으면, 적재 전후 검증을 한 세트의 스크립트로 처리할 수 있습니다.
이 글의 목표 흐름은 다음과 같습니다.
1) LOADS 시트에 트럭 한 대당 Load 정보를 등록합니다.
2) OUTBOUND 시트에서 각 출고 행에 Load 번호를 붙입니다.
3) buildLoadSummary(loadNo) 함수가 같은 Load 번호의 행을 모아 ITEM·팔레트·중량을 집계합니다.
4) SUMMARY 시트에 Load 한 건당 한 블록으로 출력하고, 이상 징후는 경고로 남깁니다.
5) closeLoad(loadNo) 함수로 LOADS STATUS를 LOADED로 바꿔 마무리합니다.
이 과정을 통해 구글시트 Apps Script 출고 집계를 실무에서 바로 쓸 수 있는 수준으로 만들 수 있습니다.
시트 구조 설계: LOADS·OUTBOUND·SUMMARY 역할 나누기
구글시트 창고 관리 자동화를 안정적으로 운용하려면, 먼저 시트 구조를 명확히 나누는 것이 중요합니다. 이 글에서는 실무에서 가장 많이 사용하는 세 장을 기준으로 설명합니다. 실제 현장에서는 열을 더 추가해도 되지만, 기본 뼈대는 유지하는 편이 좋습니다.
첫째, LOADS 시트는 트럭 한 대당 한 행으로 관리합니다. Load 번호·도어·운송사·상태 정도만 있어도 기본적인 관리가 가능합니다. 예시 열 구성은 다음과 같이 둡니다. A열은 LOAD_NO, B열은 DOOR, C열은 CARRIER, D열은 STATUS로 사용합니다. 출고 계획이 잡히면 이 시트에 Load 번호를 발급하고 도어를 배정해 두고, 상태는 처음에 OPEN으로 설정합니다.
둘째, OUTBOUND 시트는 실제 출고 스캔 데이터가 쌓이는 곳입니다. 바코드 스캔이든 수동 입력이든 모든 출고 행이 이 시트에 레코드로 남습니다. 여기서는 A열 DATETIME, B열 LOAD_NO, C열 ORDER_NO, D열 ITEM, E열 QTY, F열 PALLET, G열 WEIGHT를 기준으로 설명합니다. 이후 Apps Script에서는 이 열 위치를 바탕으로 Load별 출고 집계를 수행합니다.
셋째, SUMMARY 시트는 Load 단위 요약을 모아 놓는 용도입니다. Load 한 건당 하나의 블록을 아래로 쌓아 가는 구조로 두면, 하루에 여러 Load를 떠도 과거 이력을 그대로 남길 수 있습니다. 각 블록은 “Load 서머리 / Load 번호 / 생성 시간”으로 시작하고, 그 아래에 ITEM별 QTY·PALLET·WEIGHT 합계, 마지막에 Load 전체 합계와 경고 메시지를 붙이는 형태로 구성합니다.
실무에서 흔히 발생하는 문제는 시트 구조를 중간에 바꾸는 바람에 스크립트가 더 이상 맞지 않는 상황입니다. 이 글의 코드는 시트 이름과 열 번호를 상단 상수(LOAD_CONFIG)에 모아 두어, 나중에 구조를 조정해도 상수만 수정하면 전체 코드가 따라오도록 설계합니다. 이 방식이 구글시트 창고 관리 자동화에서 유지 보수를 쉽게 하는 핵심입니다.
Apps Script 기본 설정과 공통 상수 정의
이제 구글시트 Apps Script를 열고, 공통으로 사용할 설정과 유틸 함수를 정의합니다. 출고 서머리 스크립트를 여러 함수로 나누어도, 시트 이름과 열 구조는 한 곳에서 관리하는 편이 확실합니다.
이 코드는 전체 스크립트에서 공통으로 사용하는 시트 이름과 열 번호를 정의하고, 간단한 날짜 포맷 함수를 제공합니다.
붙여넣을 위치: 구글시트 → 확장 프로그램 → Apps Script → Code.gs 맨 위.
붙여넣은 뒤 할 일: 저장 후 별도 실행은 필요하지 않습니다.
// 여기만 본인 시트 구조에 맞게 바꾸세요
const LOAD_CONFIG = { // → 설정 모음
SHEET_LOADS: 'LOADS', // → Load 정보 시트 이름
SHEET_OUTBOUND: 'OUTBOUND', // → 출고 시트 이름
SHEET_SUMMARY: 'SUMMARY', // → 서머리 시트 이름
COL_LOADS: { // → LOADS 열 위치
LOAD_NO: 1, // → A열
DOOR: 2, // → B열
CARRIER: 3, // → C열
STATUS: 4 // → D열
},
COL_OUTBOUND: { // → OUTBOUND 열 위치
DATETIME: 1, // → A열
LOAD_NO: 2, // → B열
ORDER_NO: 3, // → C열
ITEM: 4, // → D열
QTY: 5, // → E열
PALLET: 6, // → F열
WEIGHT: 7 // → G열
},
STATUS: { // → Load 상태 값
OPEN: 'OPEN', // → 적재 전
LOADED: 'LOADED', // → 적재 완료
CANCELLED: 'CANCELLED' // → 취소
}
};
function getSheet_(name) { // → 시트 객체 가져오기
const sh = SpreadsheetApp.getActive().getSheetByName(name); // → 현재 문서에서 찾기
if (!sh) { // → 없으면
throw new Error('시트를 찾을 수 없습니다: ' + name); // → 오류 발생
}
return sh; // → 시트 반환
}
function formatDateTime_(date) { // → 날짜 문자열로 변환
if (!(date instanceof Date)) { // → 형식 확인
return ''; // → 아니면 빈 값
}
const tz = Session.getScriptTimeZone(); // → 스크립트 시간대
return Utilities.formatDate(date, tz, 'yyyy-MM-dd HH:mm'); // → 지정 형식
}제대로 됐는지 확인하는 법: getSheet_는 시트 이름을 인자로 받는 함수라 ▶ 버튼으로 바로 실행하면 인자가 없어 오류가 납니다. 아래처럼 인자를 넣어 부르는 테스트 함수를 맨 아래에 붙여넣고 그것을 실행한 뒤, 실행 로그(보기 → 실행 로그)에 시트 이름이 찍히는지 확인하시기 바랍니다.
function testGetSheet() { // → 편집기에서 실행할 테스트 함수
const sh = getSheet_(LOAD_CONFIG.SHEET_LOADS); // → 인자를 넣어 호출
Logger.log('찾은 시트: ' + sh.getName()); // → 실행 로그에 출력
}Load별 출고 집계 설계: 무엇을 자동으로 검증할 것인가
구글시트 Load별 출고 자동화를 설계할 때는 단순 합계뿐 아니라, 현장에서 반드시 확인해야 하는 검증 항목을 함께 넣는 것이 중요합니다. 제가 실제 운영하면서 최소한 포함하는 항목은 다음과 같습니다.
첫째, ITEM별 수량 합계입니다. 같은 ITEM 코드가 여러 행으로 나뉘어 있을 때 총 QTY를 합산해 보여 주면, 판매 오더와 피킹 리스트와의 대조가 쉬워집니다. 이때 수량이 0이거나 음수인 행은 스캔 오류일 가능성이 높으므로 경고로 잡습니다.
둘째, 팔레트 수 합계입니다. 트럭 도어 앞에서 가장 먼저 묻는 것이 “몇 팔레트인가”이기 때문에, ITEM별 팔레트와 Load 전체 팔레트 수를 함께 보여 주면 현장 작업자와 운전자가 바로 이해할 수 있습니다. 부분 팔레트는 소수점으로 합산해도 무방합니다.
셋째, 중량 합계입니다. 트럭 허용 중량을 넘지 않는지 확인해야 하기 때문에, ITEM별 중량과 Load 전체 중량을 함께 집계하는 것이 좋습니다. 중량 정보가 없는 품목이라면 WEIGHT 열을 0으로 두고, 나중에 중량 데이터를 추가할 수 있습니다.
넷째, Load 내부 주문번호 중복 여부입니다. 같은 ORDER_NO가 한 Load 안에서 여러 번 스캔된 경우, 실제로 중복 피킹인지, 한 주문을 두 팔레트로 나눈 것인지 구분할 필요가 있습니다. 이 글에서 제공하는 코드는 한 Load 안에서의 중복만 검출하고, 교차 Load 간 중복은 다루지 않습니다. 교차 Load까지 확인하고 싶다면 OUTBOUND 전체를 대상으로 추가 검증 로직을 만들어야 합니다.
이 기준을 바탕으로, Apps Script buildLoadSummary 함수는 특정 Load 번호 하나를 입력받아 OUTBOUND의 같은 Load 행을 모으고, ITEM별 QTY·PALLET·WEIGHT를 집계한 뒤 SUMMARY 시트에 출력하는 역할을 합니다.
Load별 출고 서머리 생성: buildLoadSummary 구현
이제 핵심 함수인 buildLoadSummary(loadNo)를 구현합니다. 이 함수는 Load 번호 하나를 받아, OUTBOUND에서 해당 Load의 행만 필터링해 ITEM 단위로 집계하고, SUMMARY 시트에 “Load 서머리 블록”을 추가합니다. 동시에 수량 0, 음수, ITEM 누락, Load 내부 주문번호 중복을 경고로 모읍니다.
한 가지 반드시 넣어야 하는 검사가 있습니다. QTY·PALLET·WEIGHT 칸에 ABC나 1,000개 같은 값이 들어가면 Number()가 NaN을 돌려주는데, NaN은 더하는 순간 합계 전체를 NaN으로 만듭니다. 한 행의 오타 하나로 ITEM별 합계와 Load 총합이 전부 NaN으로 출력되는 것입니다. 게다가 NaN은 qty === 0도 qty < 0도 아니어서 기존 경고에도 걸리지 않습니다. 그래서 아래 코드는 세 값이 모두 유한한 숫자인지 Number.isFinite로 먼저 확인하고, 아니면 몇 행이 문제인지 경고에 남긴 뒤 그 행을 합계에서 제외합니다.
이 코드는 특정 Load 번호 하나에 대한 서머리를 생성합니다.
붙여넣을 위치: Code.gs 파일, CONFIG와 유틸 함수 아래.
붙여넣은 뒤 할 일: 저장 후 testBuildLoadSummary를 한 번 실행합니다.
function buildLoadSummary(loadNo) { // → Load 서머리 생성
if (!loadNo) { // → 입력값 확인
throw new Error('Load 번호를 입력하세요.'); // → 오류 안내
}
const ss = SpreadsheetApp.getActive(); // → 현재 문서
const shOutbound = getSheet_(LOAD_CONFIG.SHEET_OUTBOUND); // → OUTBOUND 시트
const shSummary = getSheet_(LOAD_CONFIG.SHEET_SUMMARY); // → SUMMARY 시트
const dataRange = shOutbound.getDataRange(); // → 전체 범위
const values = dataRange.getValues(); // → 2차원 배열
if (values.length < 2) { // → 헤더만 있는 경우
throw new Error('OUTBOUND에 데이터가 없습니다.'); // → 오류
}
const header = values[0]; // → 헤더 행
const rows = values.slice(1); // → 데이터 행들
const cLoad = LOAD_CONFIG.COL_OUTBOUND.LOAD_NO; // → Load 열
const cItem = LOAD_CONFIG.COL_OUTBOUND.ITEM; // → 모델 열
const cQty = LOAD_CONFIG.COL_OUTBOUND.QTY; // → 수량 열
const cPallet = LOAD_CONFIG.COL_OUTBOUND.PALLET; // → 팔레트 열
const cWeight = LOAD_CONFIG.COL_OUTBOUND.WEIGHT; // → 중량 열
const cOrder = LOAD_CONFIG.COL_OUTBOUND.ORDER_NO; // → 주문번호 열
const itemMap = {}; // → ITEM별 집계
const orderSet = {}; // → 주문번호 기록
const duplicatedOrders = []; // → 중복 주문 목록
const warnings = []; // → 경고 메시지
let totalWeight = 0; // → Load 총중량
let totalPallet = 0; // → Load 총팔레트
let rowCount = 0; // → 해당 Load 행 수
rows.forEach((row, idx) => { // → 각 행 반복
const rowLoad = String(row[cLoad - 1] || '').trim(); // → Load값
if (rowLoad !== String(loadNo).trim()) { // → 다른 Load면
return; // → 건너뜀
}
rowCount++; // → 행 개수 증가
const item = String(row[cItem - 1] || '').trim(); // → ITEM
const qty = Number(row[cQty - 1] || 0); // → 수량 숫자
const pallet = Number(row[cPallet - 1] || 0); // → 팔레트
const weight = Number(row[cWeight - 1] || 0); // → 중량
const orderNo = String(row[cOrder - 1] || '').trim(); // → 주문번호
if (!item) { // → 모델 없음
warnings.push('ITEM 누락: OUTBOUND ' + (idx + 2) + '행'); // → 위치 기록
}
if (!Number.isFinite(qty) || // → 수량이 숫자가 아니거나
!Number.isFinite(pallet) || // → 팔레트가 숫자가 아니거나
!Number.isFinite(weight)) { // → 중량이 숫자가 아니면
warnings.push('숫자로 읽을 수 없는 값: OUTBOUND ' + (idx + 2) +
'행 — 수량·팔레트·중량 확인'); // → 위치와 원인 기록
return; // → 합계가 NaN이 되지 않게 이 행은 제외
}
if (qty === 0) { // → 수량 0
warnings.push('수량 0: OUTBOUND ' + (idx + 2) + '행'); // → 위치 기록
}
if (qty < 0) { // → 음수 수량
warnings.push('음수 수량: OUTBOUND ' + (idx + 2) + '행'); // → 위치 기록
}
if (orderNo) { // → 주문번호 있으면
if (orderSet[orderNo]) { // → 이미 있으면
duplicatedOrders.push(orderNo); // → 중복으로 기록
} else { // → 처음이면
orderSet[orderNo] = true; // → 세트에 추가
}
}
if (!itemMap[item]) { // → 처음 보는 ITEM
itemMap[item] = { // → 집계 객체 생성
qty: 0, // → 수량
pallet: 0, // → 팔레트
weight: 0 // → 중량
};
}
itemMap[item].qty += qty; // → 수량 합산
itemMap[item].pallet += pallet; // → 팔레트 합산
itemMap[item].weight += weight; // → 중량 합산
totalWeight += weight; // → Load 총중량 합산
totalPallet += pallet; // → Load 총팔레트 합산
});
if (rowCount === 0) { // → 해당 Load 없음
throw new Error('OUTBOUND에 Load ' + loadNo + ' 데이터가 없습니다.'); // → 오류
}
// SUMMARY에 출력할 데이터 구성
const now = formatDateTime_(new Date()); // → 생성 시간
const output = []; // → 출력 배열
output.push(['Load 서머리', loadNo, now, '']); // → 제목 행
output.push(['ITEM', 'TOTAL_QTY', 'TOTAL_PALLET', 'TOTAL_WEIGHT']); // → 헤더
Object.keys(itemMap).sort().forEach(item => { // → ITEM 정렬
const rec = itemMap[item]; // → ITEM 집계
output.push([ // → 한 행 추가
item, // → 모델
rec.qty, // → 수량 합계
rec.pallet, // → 팔레트 합계
rec.weight // → 중량 합계
]);
});
output.push(['', '', '', '']); // → 빈 줄
output.push(['Load 합계', '', totalPallet, totalWeight]); // → Load 전체 합계
if (duplicatedOrders.length > 0) { // → 중복 주문 있으면
warnings.push('중복 주문번호(동일 Load 내): ' + duplicatedOrders.join(', ')); // → 메시지 추가
}
if (warnings.length > 0) { // → 경고 있으면
output.push(['', '', '', '']); // → 빈 줄
output.push(['경고', '내용', '', '']); // → 경고 헤더
warnings.forEach(msg => { // → 각 경고
output.push(['', msg, '', '']); // → 행 추가
});
}
// SUMMARY 시트에 붙여 넣기 (Load 한 건당 한 블록)
const lastRow = shSummary.getLastRow(); // → 기존 마지막 행
const startRow = lastRow === 0 ? 1 : lastRow + 2; // → 블록 시작 행
const range = shSummary.getRange(startRow, 1, output.length, 4); // → 출력 범위
range.setValues(output); // → 값 쓰기
return { rowCount, totalPallet, totalWeight, warnings, duplicatedOrders }; // → 결과 요약
}
function testBuildLoadSummary() { // → 테스트용 함수
const testLoadNo = 'L20260809-01'; // → 시험 Load 번호
const result = buildLoadSummary(testLoadNo); // → 서머리 생성 실행
Logger.log(JSON.stringify(result)); // → 결과 로그 출력
}제대로 됐는지 확인하는 법: LOADS·OUTBOUND 시트에 L20260809-01 Load로 3~5행 정도 샘플 데이터를 넣고 testBuildLoadSummary를 실행했을 때, SUMMARY 시트 맨 아래에 새로운 서머리 블록이 생성되면 정상입니다. 한 행의 QTY 칸에 일부러 ABC를 넣고 다시 실행해 보세요. 합계가 NaN이 되지 않고 경고에 숫자로 읽을 수 없는 값: OUTBOUND 3행 같은 줄이 나오면 검증이 제대로 걸린 것입니다.
Load 마감 처리: closeLoad로 STATUS를 LOADED로 변경
서머리만 만들고 Load 상태를 바꾸지 않으면, 나중에 어떤 Load가 이미 나간 것인지 구분이 잘 되지 않습니다. Load별 출고 자동화를 실무에 올리려면, Load 마감 절차까지 스크립트로 묶어 두는 것이 좋습니다. 여기서는 closeLoad(loadNo) 함수로 LOADS 시트의 STATUS를 LOADED로 바꾸고, 동시에 여러 사용자가 실행해도 충돌하지 않도록 잠금을 적용합니다.
이 코드는 특정 Load의 STATUS를 LOADED로 바꿔 적재 완료를 기록합니다.
붙여넣을 위치: Code.gs, buildLoadSummary 아래.
붙여넣은 뒤 할 일: 저장 후 testCloseLoad를 실행합니다.
function closeLoad(loadNo) { // → Load 상태 닫기
if (!loadNo) { // → 입력값 확인
throw new Error('Load 번호를 입력하세요.'); // → 오류 안내
}
const lock = LockService.getScriptLock(); // → 스크립트 잠금 객체
try { // → 잠금 시도
lock.waitLock(5000); // → 최대 5초 대기
const shLoads = getSheet_(LOAD_CONFIG.SHEET_LOADS); // → LOADS 시트
const dataRange = shLoads.getDataRange(); // → 전체 범위
const values = dataRange.getValues(); // → 2차원 배열
if (values.length < 2) { // → 헤더만 있을 때
throw new Error('LOADS에 데이터가 없습니다.'); // → 오류
}
const cLoad = LOAD_CONFIG.COL_LOADS.LOAD_NO; // → Load 열
const cStatus = LOAD_CONFIG.COL_LOADS.STATUS; // → 상태 열
let targetRow = -1; // → 찾은 행 번호
for (let i = 1; i < values.length; i++) { // → 2행부터 순회
const rowLoad = String(values[i][cLoad - 1] || '').trim(); // → Load값
if (rowLoad === String(loadNo).trim()) { // → 일치하면
targetRow = i + 1; // → 실제 행 번호
break; // → 반복 종료
}
}
if (targetRow === -1) { // → 못 찾으면
throw new Error('LOADS에서 Load ' + loadNo + '을 찾을 수 없습니다.'); // → 오류
}
const statusCell = shLoads.getRange(targetRow, cStatus); // → STATUS 셀
const currentStatus = String(statusCell.getValue() || '').trim(); // → 현재 상태
if (currentStatus === LOAD_CONFIG.STATUS.LOADED) { // → 이미 LOADED면
Logger.log('이미 LOADED 상태입니다: ' + loadNo); // → 로그만 남김
return; // → 종료
}
if (currentStatus !== LOAD_CONFIG.STATUS.OPEN) { // → OPEN에서만 마감 허용
throw new Error('OPEN 상태만 마감할 수 있습니다. 현재: ' + currentStatus); // → 취소 건 보호
}
statusCell.setValue(LOAD_CONFIG.STATUS.LOADED); // → LOADED로 변경
Logger.log('Load 상태 변경 완료: ' + loadNo); // → 로그 출력
} catch (e) { // → 오류 처리
throw e; // → 오류 다시 던짐
} finally { // → 항상 실행
lock.releaseLock(); // → 잠금 해제
}
}
function testCloseLoad() { // → closeLoad 테스트
const testLoadNo = 'L20260809-01'; // → 시험 Load 번호
closeLoad(testLoadNo); // → 상태 변경 실행
}제대로 됐는지 확인하는 법: LOADS 시트에 L20260809-01 행을 만들고 STATUS를 OPEN으로 둔 뒤 testCloseLoad를 실행했을 때, STATUS가 LOADED로 변경되면 성공입니다.
메뉴에서 한 번에 실행: 현장용 구글시트 출고 서머리 메뉴 만들기
Apps Script 편집기에서 매번 테스트 함수를 실행하는 방식은 현장 운영에는 맞지 않습니다. 창고 직원이 직접 사용할 수 있도록, 시트를 열었을 때 상단 창고 도구 메뉴에 “Load 서머리 생성” 항목을 만들고, 여기서 Load 번호를 입력받아 서머리 생성까지 진행하는 것이 실무에 더 적합합니다.
이 코드는 시트를 열 때 커스텀 메뉴를 추가하고, 메뉴에서 Load 번호를 입력받아 buildLoadSummary를 실행합니다. 필요하면 주석을 해제해 closeLoad까지 한 번에 묶을 수 있습니다.
붙여넣을 위치: Code.gs, 위 코드들 아래.
붙여넣은 뒤 할 일: 저장 후 시트를 새로 고칩니다.
function onOpen() { // → 시트 열릴 때 실행
const menu = SpreadsheetApp.getUi() // → 사용자 인터페이스
.createMenu('창고 도구'); // → 시리즈 공용 메뉴 이름
addLoadMenu_(menu); // → 이 편 항목 붙이기
menu.addToUi(); // → 메뉴 표시
}
function addLoadMenu_(menu) { // → 합칠 때는 이 함수만 부른다
menu.addItem('Load 서머리 생성', 'menuBuildLoadSummary'); // → 메뉴 항목
}
function menuBuildLoadSummary() { // → 메뉴에서 호출
const ui = SpreadsheetApp.getUi(); // → UI 객체
const resp = ui.prompt( // → 입력창 표시
'Load 번호 입력', // → 제목
'서머리를 만들 Load 번호를 입력하세요.', // → 안내문
ui.ButtonSet.OK_CANCEL // → 버튼 구성
);
if (resp.getSelectedButton() !== ui.Button.OK) { // → 취소 시
return; // → 종료
}
const loadNo = resp.getResponseText().trim(); // → 입력값
if (!loadNo) { // → 빈 값이면
ui.alert('Load 번호가 비어 있습니다.'); // → 경고
return; // → 종료
}
try { // → 실행 시도
const result = buildLoadSummary(loadNo); // → 서머리 생성
// 필요 시 아래 주석을 풀어 자동으로 LOADED 처리
// closeLoad(loadNo); // → Load 닫기
let msg = 'Load 서머리 생성 완료: ' + loadNo; // → 기본 메시지
msg += '\n행 수: ' + result.rowCount; // → 행 개수
msg += '\n총 팔레트: ' + result.totalPallet; // → 팔레트 합계
msg += '\n총 중량: ' + result.totalWeight; // → 중량 합계
if (result.warnings.length > 0) { // → 경고 있으면
msg += '\n경고 건수: ' + result.warnings.length; // → 경고 수
}
ui.alert(msg); // → 결과 안내
} catch (e) { // → 오류 잡기
ui.alert('오류: ' + e.message); // → 오류 내용 표시
}
}제대로 됐는지 확인하는 법: 시트를 새로 열었을 때 상단 메뉴에 “창고 도구”가 보이고, “Load 서머리 생성”을 눌러 Load 번호를 입력했을 때 SUMMARY 시트에 새로운 블록이 생성되고, 완료 메시지 팝업이 나타나면 설정이 올바른 것입니다.
여러 편을 한 프로젝트에 합칠 때: 앞 편 코드와 같은 Apps Script 프로젝트에 그냥 붙이면 onOpen()이 두 번 선언되어 나중 것만 살아남고 앞 편 메뉴가 사라집니다. 그래서 이 시리즈는 메뉴 이름을 창고 도구 하나로 통일하고, 각 편의 항목을 addLoadMenu_(menu) 같은 도우미 함수로 떼어 두었습니다. 합칠 때는 이 편의 onOpen()만 지우고, 1편의 onOpen() 안에 addLoadMenu_(menu); 한 줄만 더하면 됩니다. 그러면 창고 도구 메뉴 하나에 입고·출고·재고·랙 배치도·바코드 스캔·Load 서머리가 모두 모입니다. 시트 이름 상수(SHEET_INBOUND 등)처럼 여러 편에 똑같이 나오는 줄도 한 벌만 남기고 지우세요 — const는 같은 프로젝트에서 두 번 선언하면 그 자체로 오류입니다.
시리즈를 전부 합쳤을 때의 onOpen() 완성본
1편부터 여기까지 한 프로젝트에 모았다면, 각 편의 onOpen()은 지우고 아래 한 벌만 남기면 됩니다. 각 편의 add○○Menu_ 함수는 그대로 두세요.
function onOpen() { // → 시트를 열 때 자동 실행
const menu = SpreadsheetApp.getUi().createMenu('창고 도구'); // → 메뉴는 하나만 만든다
addInboundMenu_(menu); // → 1편 · 입고 UI 열기
addStockMenu_(menu); // → 3편 · 재고 새로고침
addFloorMapMenu_(menu); // → 4편 · 랙 배치도 새로고침
addScanMenu_(menu); // → 6편 · 스캔 입력창 열기
addLoadMenu_(menu); // → 7편 · Load 서머리 생성
menu.addToUi(); // → 시트에 붙이기
}아직 일부 편만 넣었다면 그 편의 줄만 남기고 나머지는 지우면 됩니다. 2편(출고)은 1편 사이드바 안에 들어가고 5편(일일 리포트)은 시간 트리거로 돌기 때문에, 이 두 편은 메뉴 항목이 따로 없습니다.
실무 팁: 적용 전 체크리스트와 운영 요령
코드를 바로 현장에 적용하기 전에, 작은 샘플 데이터로 충분히 테스트하는 것이 안전합니다. 다음과 같은 간단한 체크리스트를 권장합니다.
첫째, 샘플 데이터 3~5행으로 시험 Load 만들기입니다. LOADS 시트에 LTEST-01 같은 가상의 Load를 하나 만들고 STATUS는 OPEN으로 둡니다. OUTBOUND 시트에는 같은 Load 번호로 ITEM 2~3개, 주문번호 2~3개를 섞어서 입력해 보고, 일부러 수량 0이나 ITEM 누락 행을 한두 개 넣어 둡니다. 그러면 경고 출력이 정상인지 확인할 수 있습니다.
둘째, 서머리 생성과 경고 재현입니다. 메뉴 또는 testBuildLoadSummary로 LTEST-01을 실행해 SUMMARY 시트에 출력된 블록을 확인합니다. ITEM별 합계가 수기로 계산한 값과 일치하는지, 0 수량·음수 수량·ITEM 누락·동일 Load 내 중복 주문번호가 경고 영역에 제대로 잡히는지를 차례로 점검합니다. 이 과정에서 구글시트 Apps Script 출고 집계가 실제 현장 데이터와 맞는지 검증할 수 있습니다.
셋째, closeLoad 동작 확인입니다. testCloseLoad 또는 메뉴 코드에서 closeLoad 호출 부분의 주석을 풀어 시험 Load를 LOADED로 바꾸어 봅니다. 동시에 두 사람이 같은 Load를 닫는 상황은 LockService로 막고 있으므로, STATUS가 의도한 대로 한 번만 LOADED로 바뀌는지만 보면 됩니다.
넷째, 운영 규칙 정리입니다. 서머리를 언제, 누가 실행할지에 대한 규칙을 간단히 문서로 만들어 팀과 공유하는 것이 좋습니다. 예를 들어 “피킹 완료 후 도어 담당자가 Load별 출고 서머리를 실행해 검증한 뒤, 이상이 없으면 트럭 적재를 시작한다”는 식으로 정해 두면, 스크립트가 팀 운영 프로세스 안에 자연스럽게 들어갑니다.
시트 설계와 기본 자동화가 아직 익숙하지 않다면, 재고 구조와 이동 로직을 먼저 다룬 글인 구글시트로 창고 재고관리 시스템 만들기 | Apps Script 자동화를 참고한 뒤 이 Load 서머리 기능을 붙이는 순서를 추천합니다.
맺음말: 오늘 시험 Load 한 건부터 자동화해 보기
구글시트 물류 서머리 스크립트를 한 번 만들어 두면, 이후에는 트럭이 나갈 때마다 같은 메뉴를 눌러 Load별 출고 요약을 자동으로 생성할 수 있습니다. 사람이 엑셀 필터와 계산기로 합계를 내며 검증하던 시간을 줄이고, 수량·팔레트·중량·주문 중복 같은 기본 검증을 동일한 기준으로 반복할 수 있다는 점이 가장 큰 장점입니다.
지금 바로 할 수 있는 행동은 LOADS·OUTBOUND·SUMMARY 세 장을 위 구조대로 만든 뒤, 시험 Load 한 건을 입력하고 Load 서머리 생성 메뉴를 눌러 보는 것입니다. 한 번 돌려 보면 어떤 항목을 더 집계하고 싶은지, 어떤 경고를 추가하고 싶은지가 자연스럽게 보이고, 그때부터는 CONFIG와 스크립트를 조금씩 확장해 나가면 됩니다.