Smart Life US

← 목록 · 2026-08-16 · 엑셀·업무 자동화

구글시트 도크 도어 자동 배정 방법: Apps Script로 예약 5편

구글시트 도크 도어 자동 배정 방법: Apps Script로 예약 5편

도입 – 입고 예약, 도어 배정이 늘 문제였습니다

구글시트로 입고 예약을 받다 보면 마지막에 항상 남는 일이 있습니다. 어느 도어에 붙일지 사람이 일일이 보고 배정하는 일입니다. 예약 시각·장비 유형·우선순위를 보고 손으로 골라 붙이다 보면, 바쁜 시간대에는 D01에만 몰리거나 같은 시간에 같은 도어에 두 번 배정되는 일이 생기기 쉽습니다. 실제 창고 현장에서도 “예약은 잘 받았는데 도어 스케줄이 꼬였다”가 하루를 흔드는 경우가 자주 발생했습니다.

도크 도어 자동 배정 흐름

이번 글은 이런 문제를 줄이기 위한 구글시트 도크 도어 자동 배정 방법을 다룹니다. 구글시트 입고 예약 도어 배정을 자동으로 처리하는 Apps Script를 만들고, 기존 예약 저장 흐름에 자연스럽게 녹여 넣는 과정을 단계별로 설명합니다.

이 글은 구글시트 창고 예약 시스템 만들기 · 예약 등록 5편입니다. 구글시트 입고 예약 중복 방지 방법: LockService로 안전하게 저장하기들에서는 운영시간·휴무일 설정, 예약 가능 기간 계산, 시간대별 용량 계산, 예약 입력 검증과 저장, 초과 예약 차단까지 구현했습니다. 이제 그 위에 “같은 시간에 예약이 여러 건 들어와도 D01, D02, D03… 순서로 겹치지 않게 붙는” Apps Script 도크 도어 자동 할당 기능을 얹는 단계입니다.


자동 도어 배정 설계 – 어떤 기준으로 도어를 고를 것인가

구글시트에서 도크 도어를 자동으로 배정하는 기본 아이디어는 단순합니다. 먼저 예약하려는 날짜·시간대·장비 유형을 기준으로 해당 슬롯에서 이미 사용 중인 도어를 계산합니다. 그다음 전체 도어 목록에서 사용 중인 도어를 제외하고, 남아 있는 도어 중 번호가 가장 앞선 도어부터 순서대로 배정합니다.

실제 물류센터 운영 경험상 도어를 랜덤으로 배정하는 것보다, 숫자 순서(예: D01→D02→D03)로 채워 가는 편이 동선 관리와 현장 커뮤니케이션에 유리했습니다. 특정 구간에 작업이 몰리는지 확인하기도 쉽습니다. 또한 운영팀이 도어별 작업량을 한눈에 파악할 수 있어 다음날 인력 배치 계획에도 도움이 됩니다.

이번 글에서 구현하는 흐름은 다음과 같습니다.

  • BOOK_getDoorUsageForSlotCore_()특정 날짜·시각·장비 유형에서 이미 점유 중인 도어 목록을 계산합니다.
  • autoAssignDoor_()가 위 정보를 바탕으로 사용 가능한 도어 목록 중 앞번호 도어를 선택합니다.
  • 기존 예약 저장 함수 BOOK_bookAppt() 흐름 중, 도어가 비어 있을 때만 autoAssignDoor_()를 불러 자동 배정을 시도하고, 배정에 성공하면 D열에 도어를 채우고 실패하면 D열을 빈 칸으로 둔 채 그대로 저장합니다.
  • 저장이 끝나면 notifyBookingConfirmed_()가 예약 내용과 도어 배정 정보를 포함한 확인 메일을 발송합니다.

업무 경험상 도어를 모두 사용해 더 이상 배정할 수 없는 경우에는 예약을 아예 거부하기보다 일단 저장해 두고, 교대 시작 전이나 한가한 시간에 담당자가 수동으로 도어를 채우는 방식이 더 현실적이었습니다. 예약 시트에는 상태 열이 따로 없으므로, 이 글에서는 D열(도어)이 빈 행이 곧 “배정 보류” 라는 규칙 하나로 처리합니다. 상태 문자열을 따로 만들지 않는 편이 뒤의 편들과도 어긋나지 않습니다.


도어 사용량 계산 함수 – BOOK_getDoorUsageForSlotCore_ 구현

도어 자동 배정을 위해 가장 먼저 필요한 기능은 “지금 이 시간대에 이미 어떤 도어가 쓰이고 있는지”를 정확히 세는 것입니다. 이를 위해 BOOK_getDoorUsageForSlotCore_() 라는 핵심 함수를 만듭니다. 이 함수는 앞 편에서 사용한 예약 본문 시트 APPT_MAIN 을 그대로 기준으로 삼고, 날짜·시각·장비 유형이 같은 행들만 모아 그 행에 적힌 도어 값을 집계합니다.

실무에서 테스트를 해 보면, 여기서 가장 많이 실수하는 지점이 세 가지였습니다. 첫째, 시트의 열 범위를 잘못 잡아 엉뚱한 값을 읽는 경우입니다. 둘째, 도어 열이 숫자 서식으로 되어 있어 문자열이 아닌 숫자 타입으로 들어왔을 때 점유 도어에서 빠져 버리는 문제입니다. 셋째, 장비 유형 오타나 공란을 그대로 통과시켜, 특정 슬롯이 전부 “가용”으로 잘못 보이는 상황입니다. 이번 코드는 이 세 가지를 모두 방지하도록 설계했습니다.

아래 단계부터는 실제 Apps Script를 작성합니다. 이미 앞 편에서 APPT_ymd_()APPT_hm_() 형식 함수가 있다고 가정합니다(날짜·시각을 키 문자열로 만드는 함수입니다). 이 글만 단독으로 사용한다면, 직접 동일한 규격의 함수를 별도로 만들어야 합니다.

1단계 — 도어 사용량 계산용 설정 상수 선언

  • 하는 일: 도어 사용량 계산에 필요한 시트 이름과 열 위치를 한 곳에 모아 둡니다.
  • 위치: Apps Script 편집기 Code.gs 맨 위, 기존 BOOK_ 계열 상수 아래에 추가합니다.
  • 붙인 뒤 할 일: 저장(⌘S/Ctrl+S) 후 오류가 없는지 확인합니다.
Apps Script (JavaScript)
// → 도어 자동 배정 전용 설정 상수
// 열 번호는 **4편 `BOOK_APPT_COLS` 와 반드시 같은 뜻**이어야 한다. 여기서 한 칸이라도
// 어긋나면 장비 유형 자리에서 도어를 읽어 점유 계산이 통째로 빗나간다.
// A 날짜 · B 시작시간 · C 장비유형 · D 도어 · E 컨테이너 · F 운송사 · G 고객사 · H 비고
// I 종료시간 · J 생성일시 · K 수량 · L 팔레트 · M 예약ID — **상태(status) 열은 없다.**
const BOOK_DOOR_CONFIG = {                         // → 이 편 전용 설정
  APPT_SHEET_NAME: 'APPT_MAIN',                   // → 예약 본문 시트명
  COL_DATE: 1,                                    // → A열: 예약 날짜
  COL_TIME: 2,                                    // → B열: 시작 시각
  COL_EQUIP_TYPE: 3,                              // → C열: 장비 유형
  COL_DOOR: 4,                                    // → D열: 도어 코드
  HEADER_ROWS: 1                                  // → 헤더 줄 수
};                                                //

제대로 됐는지 확인하는 법: 저장 시 “이미 선언된 식별자” 오류가 나지 않으면 됩니다. 기존 설정 상수와 이름만 다르면 값이 같아도 문제 없습니다.

2단계 — BOOK_getDoorUsageForSlotCore_ 함수와 테스트

  • 하는 일: 특정 날짜·시각·장비 유형의 슬롯에서 이미 배정된 도어 목록을 계산합니다.
  • 위치: Code.gs 하단, 기존 BOOK_ 계열 함수들 바로 아래에 붙입니다.
  • 붙인 뒤 할 일: 아래 테스트 함수 BOOK_testGetDoorUsageForSlotCore_()를 실행해 로그를 확인합니다.
Apps Script (JavaScript)
// → 특정 슬롯에서 사용 중인 도어 목록 계산
function BOOK_getDoorUsageForSlotCore_(dateObj, timeObj, equipType) {         // → 핵심 계산 함수
  if (!(dateObj instanceof Date) || isNaN(dateObj)) {                         // → 날짜 검증
    throw new Error('유효하지 않은 날짜입니다');                              // → 잘못된 입력 차단
  }                                                                           //
  if (!(timeObj instanceof Date) || isNaN(timeObj)) {                         // → 시각 검증
    throw new Error('유효하지 않은 시각입니다');                              // → 잘못된 입력 차단
  }                                                                           //
  if (typeof equipType !== 'string' || !equipType.trim()) {                   // → 장비 유형 검증
    throw new Error('장비 유형이 비었습니다');                                // → 필수값 확인
  }                                                                           //

  // 목록을 여기서 새로 만들면 3편 정원 함수가 모르는 값이 생겨 그 예약이 집계에서 빠진다.
  const allowedEquipTypes = BOOK_CAPACITY_CONFIG.VALID_TYPES;                 // → 3편 공용 목록 그대로
  const cleanEquipType = equipType.trim().toUpperCase();                      // → 대문자로 정리
  if (!allowedEquipTypes.includes(cleanEquipType)) {                          // → 허용 목록 검사
    throw new Error('허용되지 않은 장비 유형입니다: ' + equipType);           // → 오타·미등록 차단
  }                                                                           //

  const ss = SpreadsheetApp.getActive();                                      // → 현재 스프레드시트
  const sheet = ss.getSheetByName(BOOK_DOOR_CONFIG.APPT_SHEET_NAME);          // → 예약 시트
  if (!sheet) {                                                               // → 시트 존재 여부 확인
    throw new Error('APPT_MAIN 시트를 찾을 수 없습니다');                     // → 설정 오류 알림
  }                                                                           //

  const lastRow = sheet.getLastRow();                                         // → 마지막 행 번호
  if (lastRow <= BOOK_DOOR_CONFIG.HEADER_ROWS) {                              // → 데이터가 없을 때
    return {                                                                  // → 빈 결과 반환
      dateKey: APPT_ymd_(dateObj),                                           // → 날짜 키
      timeKey: APPT_hm_(timeObj),                                            // → 시각 키
      equipType: cleanEquipType,                                             // → 장비 유형
      usedDoors: []                                                          // → 사용 도어 없음
    };                                                                       //
  }                                                                           //

  const startRow = BOOK_DOOR_CONFIG.HEADER_ROWS + 1;                          // → 데이터 시작 행
  const numRows = lastRow - BOOK_DOOR_CONFIG.HEADER_ROWS;                     // → 데이터 행 수
  const numColumns = BOOK_DOOR_CONFIG.COL_DOOR - BOOK_DOOR_CONFIG.COL_DATE + 1;   // → A~D 4개 열
  const range = sheet.getRange(                                              // → 전체 데이터 범위
    startRow,                                                                 // → 시작 행
    BOOK_DOOR_CONFIG.COL_DATE,                                                // → 시작 열
    numRows,                                                                  // → 행 수
    numColumns                                                                // → 열 수
  );                                                                          //
  const values = range.getValues();                                           // → 2차원 배열로 읽기

  const targetDateKey = APPT_ymd_(dateObj);                                   // → 비교용 날짜 키
  const targetTimeKey = APPT_hm_(timeObj);                                    // → 비교용 시간 키

  const usedDoorSet = new Set();                                              // → 중복 제거용 집합
  const badRows = [];                                                         // → 형식이 깨진 도어 행

  for (let i = 0; i < values.length; i++) {                                   // → 각 행 반복
    const row = values[i];                                                    //
    const dt = row[BOOK_DOOR_CONFIG.COL_DATE - BOOK_DOOR_CONFIG.COL_DATE];    // → 날짜 값
    const tm = row[BOOK_DOOR_CONFIG.COL_TIME - BOOK_DOOR_CONFIG.COL_DATE];    // → 시각 값
    const type = row[BOOK_DOOR_CONFIG.COL_EQUIP_TYPE - BOOK_DOOR_CONFIG.COL_DATE]; // → 장비 유형
    const doorRaw = row[BOOK_DOOR_CONFIG.COL_DOOR - BOOK_DOOR_CONFIG.COL_DATE];    // → 도어 원본
                                                                              //
    // getLastRow() 는 값이 지워진 빈 행까지 포함한다. 그 행을 그대로 APPT_ymd_ 에
    // 넣으면 오류가 나서 **자동 배정 전체가 멈춘다.** 완전히 빈 행은 넘기고,
    // 일부만 채워진 행은 사람이 봐야 하므로 오류 행으로 모은다.
    if (!dt && !tm && !type && !doorRaw) {                                     // → 완전히 빈 행
      continue;                                                               //
    }                                                                         //

    let rowDateKey;                                                           // → 행 날짜 키
    let rowTimeKey;                                                           // → 행 시간 키
    try {                                                                     // → 날짜·시각 읽기
      rowDateKey = APPT_ymd_(dt);                                            // → 'YYYY-MM-DD'
      rowTimeKey = APPT_hm_(tm);                                             // → 'HH:mm'
    } catch (e) {                                                             // → 못 읽는 행
      badRows.push(i + BOOK_DOOR_CONFIG.HEADER_ROWS + 1);                     // → 행 번호 기록
      continue;                                                               //
    }                                                                         //

    if (rowDateKey !== targetDateKey) {                                       // → 다른 날짜
      continue;                                                               //
    }                                                                         //

    const rowType = String(type || '').trim().toUpperCase();                  // → 행 장비 유형
    if (rowType !== cleanEquipType) {                                         // → 유형 다르면 제외
      continue;                                                               //
    }                                                                         //

    if (rowTimeKey !== targetTimeKey) {                                       // → 다른 시간
      continue;                                                               //
    }                                                                         //

    const door = String(doorRaw || '').trim();                                // → 도어 문자열화
    if (!door) {                                                              // → 도어 비었으면 제외
      continue;                                                               //
    }                                                                         //

    // → 도어 코드 형식 검증 (예: D01, D02 형태만 인정)
    // 형식이 깨진 도어를 조용히 건너뛰면 그 도어가 '비어 있다'로 읽혀 **같은 도어에
    // 두 대를 배정**한다. 못 세는 행이 하나라도 있으면 배정을 멈추고 사람이 고치게 한다.
    const doorPattern = /^D\d{2}$/;                                           // → 도어 형식 패턴
    if (!doorPattern.test(door)) {                                            // → 패턴 불일치
      badRows.push(i + BOOK_DOOR_CONFIG.HEADER_ROWS + 1);                     // → 행 번호 기록
      continue;                                                               //
    }                                                                         //

    usedDoorSet.add(door);                                                    // → 사용 도어 집합에 추가
  }                                                                           //

  if (badRows.length > 0) {                                                   // → 못 센 행이 있으면
    throw new Error('읽을 수 없는 예약 행이 있습니다(행 ' +                     // → 배정 중단
                    badRows.slice(0, 10).join(', ') +
                    '). 날짜·시작 시간·도어 코드(D01 형식)를 확인한 뒤 다시 실행해 주십시오.');
  }                                                                           //

  return {                                                                    // → 결과 객체 반환
    dateKey: targetDateKey,                                                   // → 날짜 키
    timeKey: targetTimeKey,                                                   // → 시각 키
    equipType: cleanEquipType,                                                // → 장비 유형
    usedDoors: Array.from(usedDoorSet).sort()                                 // → 정렬된 도어 배열
  };                                                                          //
}                                                                             //

테스트용 함수입니다.

Apps Script (JavaScript)
// → 도어 사용량 계산 테스트
function BOOK_testGetDoorUsageForSlotCore_() {                                // → 테스트 함수
  const today = new Date();                                                   // → 오늘 날짜 기준
  const time = new Date();                                                    // → 현재 시각 기준
  time.setHours(9, 0, 0, 0);                                                  // → 09:00으로 고정
  const equipType = 'CONTAINER';                                              // → 예시 장비 유형

  const result = BOOK_getDoorUsageForSlotCore_(today, time, equipType);      // → 함수 실행
  Logger.log(JSON.stringify(result));                                         // → 결과 로그 출력

  if (!Array.isArray(result.usedDoors)) {                                     // → usedDoors 타입 확인
    throw new Error('usedDoors 가 배열이 아닙니다');                         // → 실패 처리
  }                                                                           //
}                                                                             //

제대로 됐는지 확인하는 법: Apps Script 편집기에서 BOOK_testGetDoorUsageForSlotCore_ 를 선택해 실행한 뒤, “실행 로그”에 {"dateKey":"...","timeKey":"...","usedDoors":[...]} 형태의 결과가 보이면 성공입니다. 시트에서 해당 시간대에 직접 세 본 도어 목록과 일치하는지 꼭 한 번 비교해 보시기 바랍니다.


자동 도어 배정 함수 – autoAssignDoor_로 빈 도어 찾기

이제 실제로 어느 도어를 배정할지 결정하는 함수를 만듭니다. 이 함수는 앞에서 만든 사용량 계산 결과를 바탕으로, 활성 도어 목록 중 사용되지 않은 도어만 골라 가장 앞 번호 도어를 선택합니다.

여기서는 앞 편에서 이미 구현했다고 가정한 listActiveDoors_() 함수를 사용합니다. 이 함수는 별도의 설정 시트에서 “사용 중(Y/N)” 으로 표시된 도어만 읽어 오는 역할을 합니다. 단일 글만 보고 구현한다면, 같은 규격으로 ['D01','D02',...] 배열을 반환하는 함수를 별도로 만들어 사용해야 합니다.

자동 배정 함수는 도어가 하나도 설정되어 있지 않거나, 이미 모두 사용 중인 경우에는 도어를 찾으려고 애쓰지 않고 isPending: true 를 반환해 상위 로직이 “도어 배정 보류”로 처리하도록 설계했습니다.

3단계 — autoAssignDoor_ 함수와 테스트

  • 하는 일: 활성 도어 목록에서 사용 중인 도어를 제외하고 가장 앞 번호 도어를 선택합니다.
  • 위치: BOOK_getDoorUsageForSlotCore_() 아래에 붙입니다.
  • 붙인 뒤 할 일: BOOK_testAutoAssignDoor_()를 실행해, 최소 한 건은 배정되고, 도어가 다 찼을 때는 보류 상태로 나오는지 확인합니다.
Apps Script (JavaScript)
// → 비어 있는 도어를 자동으로 선택
function autoAssignDoor_(dateObj, timeObj, equipType) {                       // → 자동 도어 배정
  const usage = BOOK_getDoorUsageForSlotCore_(dateObj, timeObj, equipType);  // → 사용 중 도어 조회
  const usedDoors = new Set(usage.usedDoors);                                 // → 빠른 포함 검사용

  const activeDoors = listActiveDoors_();                                     // → 활성 도어 목록
  if (!Array.isArray(activeDoors) || activeDoors.length === 0) {             // → 도어 미설정
    return {                                                                  // → 보류 상태 반환
      assignedDoor: null,                                                     // → 배정 실패
      isPending: true                                                         // → 배정 보류
    };                                                                        //
  }                                                                           //

  const allDoors = activeDoors                                                // → 전체 도어 목록
    .map(d => String(d || '').trim())                                        // → 문자열 정리
    .filter(d => d);                                                          // → 빈 값 제거

  // 후보 목록도 형식을 확인한다. DOORS 시트에 'D1' 같은 오타가 있으면 점유 계산에서는
  // 걸러지는데 후보에는 남아, **이미 쓰고 있는 도어를 다시 배정**할 수 있다.
  const badDoors = allDoors.filter(d => !/^D\d{2}$/.test(d));                 // → 형식이 깨진 도어
  if (badDoors.length > 0) {                                                  // → 하나라도 있으면
    throw new Error('활성 도어 목록에 형식이 잘못된 값이 있습니다: ' +          // → 배정 중단
                    badDoors.slice(0, 10).join(', ') +
                    '. DOORS 시트를 D01 형식으로 고친 뒤 다시 실행해 주십시오.');
  }                                                                           //

  const available = allDoors                                                  // → 후보 도어 목록
    .filter(d => !usedDoors.has(d))                                           // → 이미 사용 중인 도어 제외
    .sort();                                                                  // → 도어 코드 순 정렬

  if (available.length === 0) {                                               // → 남은 도어가 없을 때
    return {                                                                  // → 보류 상태 반환
      assignedDoor: null,                                                     // → 배정 실패
      isPending: true                                                         // → 배정 보류
    };                                                                        //
  }                                                                           //

  return {                                                                    // → 배정 성공
    assignedDoor: available[0],                                               // → 가장 앞 번호 도어
    isPending: false                                                          // → 정상 배정
  };                                                                          //
}                                                                             //

테스트용 함수입니다.

Apps Script (JavaScript)
// → 자동 도어 배정 테스트
function BOOK_testAutoAssignDoor_() {                                         // → 테스트 함수
  const today = new Date();                                                   // → 오늘 날짜
  const time = new Date();                                                    // → 현재 시각에서
  time.setHours(10, 0, 0, 0);                                                 // → 10:00 슬롯 가정
  const equipType = 'CONTAINER';                                              // → 예시 장비 유형

  const result = autoAssignDoor_(today, time, equipType);                     // → 배정 실행
  Logger.log(JSON.stringify(result));                                         // → 결과 로그 출력

  if (result.assignedDoor && result.isPending) {                              // → 모순 검증
    throw new Error('assignedDoor 와 isPending 상태가 모순입니다');          // → 로직 오류 방지
  }                                                                           //
}                                                                             //

제대로 됐는지 확인하는 법: 실행 로그에 {"assignedDoor":"D01","isPending":false} 처럼 도어가 하나 선택되거나, 활성 도어가 없을 때 {"assignedDoor":null,"isPending":true} 가 나오면 정상입니다. 실제 APPT_MAIN 시트에 같은 시간대 예약을 여러 건 만들어 두면, 도어가 순차적으로 배정되는지 추가로 검증할 수 있습니다.


예약 저장 흐름에 자동 배정과 메일 발송 연결하기

도어 점유 계산과 자동 선택 함수가 준비되었으면, 이제 이것을 예약 저장 함수 BOOK_bookAppt() 흐름에 연결해야 실제로 쓸 수 있습니다. 실무에서는 대략 이런 순서로 동작하는 것이 자연스럽습니다.

  1. 사용자가 폼이나 사이드바로 예약 정보를 입력합니다.
  2. BOOK_bookAppt(data) 안에서 BOOK_validateBookingInput_(data) 가 형식을 검증하고 validated 객체를 돌려줍니다. 이 객체의 필드 이름은 4편에서 정한 그대로 date·time·endTime·type·door·containerNo 입니다. dateObj·timeObj·equipType 같은 이름은 없습니다.
  3. validated.door 가 비어 있으면 autoAssignDoor_() 를 호출해 도어를 붙입니다.
  4. 도어를 못 찾은 경우에는 D열(도어)을 빈 칸으로 두고 그대로 저장합니다. 예약 시트는 A~M 13개 열뿐이고 상태 열이 따로 없으므로, “도어가 비어 있다”가 곧 배정 보류입니다. 별도의 doorStatus 값을 만들어 봐야 저장할 칸이 없어 그대로 사라집니다.
  5. 시트에 예약 행을 저장한 뒤에 notifyBookingConfirmed_() 로 확인 메일을 보냅니다. 메일 발송은 반드시 try/catch 로 감쌉니다 — 메일이 실패했다고 예약 저장까지 실패한 것처럼 응답하면, 사용자가 다시 눌러 중복 예약을 만듭니다.

여기서 중요한 것은 동시 실행을 잠그는 것입니다. 같은 시간대에 여러 사용자가 예약을 넣는 경우를 고려해, BOOK_bookAppt() 전체를 LockService.getScriptLock() 으로 감싸, 한 번에 한 요청만 도어 배정과 저장을 수행하도록 해야 합니다. 이미 앞 편에서 LockService 패턴을 사용했다면 같은 구조를 그대로 유지하면 됩니다.

4단계 — BOOK_bookAppt 안에 자동 배정 조각 넣기

이 단계는 기존 코드를 전부 갈아엎는 것이 아니라, 핵심 위치에 조각을 추가하는 방식입니다.

  • 하는 일: 도어가 비어 있을 때 자동으로 배정하고, 남은 도어가 없으면 도어 칸을 비운 채 저장합니다.
  • 위치: BOOK_bookAppt(data) 함수 안, BOOK_validateBookingInput_() 호출 다음 줄부터.
  • 먼저 해야 할 일: 4편 BOOK_validateBookingInput_() 은 도어를 필수로 막고 있습니다. 이 두 곳을 먼저 풀지 않으면 자동 배정까지 도달하지 못하고 “도어(또는 야드 슬롯)는 필수 항목입니다.” 로 끝납니다.
  • 붙인 뒤 할 일: 같은 날짜·시간대·장비 유형으로 여러 건 테스트 예약을 넣어, 도어가 순차 배정되는지 확인합니다.

먼저 4편 BOOK_validateBookingInput_() 을 두 가지 고칩니다. 도어 검사를 자동 배정이 가능한 형태로 바꾸고, 확인 메일을 보낼 주소를 반환 객체에 실어 줍니다. 4편 반환 객체에는 email 이 없어서, 이 한 줄이 없으면 아래 알림 함수는 아무 말 없이 그냥 끝납니다.

TEXT
// 4편 BOOK_validateBookingInput_() 안 — 아래 두 곳을 이렇게 바꿉니다.

// (1) 도어 필수 검사 → 삭제하거나 아래처럼 바꾼다
//   if (!door) {
//     throw new Error('도어(또는 야드 슬롯)는 필수 항목입니다.');
//   }
//   ↓ 빈 도어는 '자동 배정 요청'으로 받아들인다

// (2) 도어 형식 검사 → 값이 있을 때만 검사한다
//   if (!/^D\d{2}$/.test(door)) {
//     throw new Error('도어 형식이 올바르지 않습니다.');
//   }
//   ↓
  if (door && !/^D\d{2}$/.test(door)) {
    throw new Error('도어 형식이 올바르지 않습니다.');
  }

// (3) 확인 메일 주소를 반환 객체에 추가한다 — 없으면 메일이 조용히 안 나간다.
//     함수 위쪽 정리 구간에 한 줄:
  const email = String(data.email || '').trim();
//     그리고 return 객체에 한 줄 더:
//       pallet: pallet,
  email: email

그다음 BOOK_bookAppt() 안, 검증 직후에 아래 조각을 넣습니다.

TEXT
// BOOK_bookAppt(data) 함수 안 — BOOK_validateBookingInput_() 호출 바로 다음.

    const validated = BOOK_validateBookingInput_(data);   // ← 4편에 이미 있는 줄

    // 도어 자동 배정 — validated 의 필드 이름은 date/time/type 이다.
    if (!validated.door) {                                          // → 도어 미입력
      // autoAssignDoor_ 는 Date 두 개를 받는다. 4편 저장 함수가 쓰는 변환과 같은 방식.
      const dp = validated.date.split('-');                         // → 연·월·일
      const tp = validated.time.split(':');                         // → 시·분
      const slotDate = new Date(Number(dp[0]), Number(dp[1]) - 1, Number(dp[2]));
      const slotTime = new Date(1899, 11, 30, Number(tp[0]), Number(tp[1]));
      const auto = autoAssignDoor_(slotDate, slotTime, validated.type);
      validated.door = auto.assignedDoor || '';                     // → 못 찾으면 빈 칸(=보류)
    }

    BOOK_validateBusinessRules_(validated);                         // ← 4편에 이미 있는 줄
    BOOK_checkDuplicateContainer_(validated);                       // ← 4편에 이미 있는 줄
    BOOK_checkCapacityAndSave_(validated);                          // ← 4편에 이미 있는 줄

    // 저장이 끝난 뒤에만 메일을 보낸다. 메일 실패로 예약을 되돌리지 않는다.
    try {
      notifyBookingConfirmed_(validated);
    } catch (mailErr) {
      Logger.log('확인 메일 발송 실패(예약은 저장됨): ' + mailErr.message);
    }

제대로 됐는지 확인하는 법: APPT_MAIN 시트에서 같은 날짜·시간대·장비 유형으로 도어를 비워 둔 채 예약을 여러 개 넣어 보면, D열이 D01, D02, D03 순으로 채워집니다. 활성 도어 수를 넘기는 순간부터는 D열이 빈 칸인 채로 저장되면 정상입니다. 보류 건만 보고 싶으면 D열이 빈 행을 필터하면 됩니다 — 별도의 상태 열은 필요하지 않습니다.


예약 완료 알림 메일 – notifyBookingConfirmed_ 기본 틀

마지막으로, 예약이 정상 저장된 뒤 확인 메일을 자동으로 보내는 함수를 추가합니다. 현장에서는 이 메일이 운송사나 기사에게 바로 전달되어 “언제 어느 도어로 들어가야 하는지”를 안내하는 역할을 합니다. 이 글에서는 최소한의 형태만 구성하고, 나머지 필드는 각자의 환경에 맞게 확장하는 것을 전제로 합니다.

알림 함수는 예약 객체를 받아, 날짜·시간·장비 유형·도어 상태를 텍스트로 묶어 메일을 보내는 단순한 구조입니다. 메일 주소가 비어 있으면 조용히 아무것도 하지 않고 종료하도록 만들어, 테스트 환경에서도 굳이 더미 주소를 억지로 넣지 않아도 되도록 했습니다.

5단계 — notifyBookingConfirmed_와 테스트 함수 추가

  • 하는 일: 예약과 도어 정보를 포함한 확인 메일을 발송합니다.
  • 위치: autoAssignDoor_() 아래에 삽입합니다.
  • 붙인 뒤 할 일: BOOK_testNotifyBookingConfirmed_()를 실행해 자신의 메일함으로 테스트 메일을 받아 봅니다.
Apps Script (JavaScript)
// → 예약 확정 알림 메일 발송
function notifyBookingConfirmed_(booking) {                                  // → 메일 발송 함수
  // booking: 4편 BOOK_validateBookingInput_() 이 돌려준 객체를 그대로 받는다.
  // { date:'YYYY-MM-DD', time:'HH:mm', type, door, containerNo, carrier, client ... }
  // 도어가 빈 문자열이면 그게 곧 '배정 보류' 다 — 별도 상태 필드는 없다.
  if (!booking || typeof booking !== 'object') {                             // → 입력 검증
    throw new Error('booking 객체가 필요합니다');                           // → 필수값 확인
  }                                                                          //
  // email 은 4편 반환 객체에 위 (3) 처럼 추가해 두어야 들어온다. 주소가 없으면
  // 메일은 보내지 않고 조용히 끝난다 — 예약 저장은 이미 끝났으니 실패가 아니다.
  const to = String(booking.email || '').trim();                             // → 수신자 메일
  if (!to) {                                                                 // → 메일 없음
    return;                                                                  // → 조용히 종료
  }                                                                          //

  const dateStr = APPT_ymd_(booking.date)   ;                                // → 날짜 문자열
  const timeStr = APPT_hm_(booking.time)   ;                                 // → 시각 문자열
  const doorLabel = booking.door || '(도어 배정 보류)';                      // → 도어 표시
  const subject = '[입고 예약 확정] ' + dateStr + ' ' + timeStr;            // → 제목 구성

  let body = '';                                                             // → 본문 시작
  body += '입고 예약이 접수되었습니다.\n\n';                                  // → 안내 문구
  body += '예약일자: ' + dateStr + '\n';                                     // → 날짜
  body += '시간대: ' + timeStr + '\n';                                       // → 시각
  body += '장비유형: ' + (booking.type      || '') + '\n';                  // → 장비 유형
  body += '컨테이너: ' + (booking.containerNo || '') + '\n';                // → 컨테이너 번호
  body += '도어: ' + doorLabel + '\n';                                       // → 도어 정보
  if (!booking.door) {                                                       // → 도어가 비었으면
    body += '\n※ 도어는 아직 배정되지 않았습니다. 도착 전 다시 안내드립니다.\n';
  }                                                                          //
  body += '\n변경이나 취소가 필요하면 담당자에게 연락해 주십시오.\n';         // → 마무리 문구

  MailApp.sendEmail({                                                        // → 메일 발송 실행
    to: to,                                                                  // → 수신자
    subject: subject,                                                        // → 제목
    body: body                                                               // → 본문
  });                                                                        //
}                                                                            //

테스트용 함수입니다.

Apps Script (JavaScript)
// → 알림 메일 테스트
function BOOK_testNotifyBookingConfirmed_() {                                // → 테스트 함수
  const today = new Date();                                                  // → 오늘 날짜
  const time = new Date();                                                   // → 현재 시각
  time.setHours(11, 30, 0, 0);                                               // → 11:30로 설정

  const dummy = {                                                            // → 예시 예약 객체
    email: Session.getActiveUser().getEmail() || '[email protected]',        // → 현재 사용자 메일
    date: APPT_ymd_(today),                                                  // → 'YYYY-MM-DD'
    time: APPT_hm_(time),                                                    // → 'HH:mm'
    type: 'CONTAINER',                                                       // → 장비 유형
    containerNo: 'TEST1234567',                                              // → 예시 컨테이너
    door: 'D01'                                                              // → 예시 도어(빈 값이면 보류 안내)
  };                                                                         //

  notifyBookingConfirmed_(dummy);                                            // → 메일 발송 테스트
}                                                                            //

제대로 됐는지 확인하는 법: Apps Script에서 BOOK_testNotifyBookingConfirmed_ 를 실행한 뒤, Gmail 수신함에 “[입고 예약 확정] …” 제목의 메일이 도착하면 성공입니다. 실제 예약에서도 메일이 오는지는 별도로 확인해야 합니다 — 위 (3) 을 빠뜨리면 테스트 함수만 성공하고 실제 예약에서는 한 통도 나가지 않습니다. 조직 계정 정책에 따라 Session.getActiveUser().getEmail() 이 빈 문자열일 수 있으므로, 그 경우에는 [email protected] 으로 전송되도록 한 점에 유의해야 합니다.


실무 팁 – 동시에 여러 예약이 들어올 때 안전하게 운영하는 법

실제 창고에서 구글시트 예약 시스템과 도어 자동 배정을 돌려 보면, 기능 자체보다 동시 입력과 예외 상황을 어떻게 다루느냐가 품질을 좌우합니다. 경험상 다음과 같은 기준을 지키면 운영이 훨씬 안정적이었습니다.

첫째, 예약 저장 함수 BOOK_bookAppt() 전체를 LockService.getScriptLock() 으로 감싸야 합니다. 도어 자동 배정, 용량 계산, 중복 예약 검사, 실제 시트 쓰기까지를 하나의 잠금 범위로 묶지 않으면, 두 사용자가 거의 동시에 같은 슬롯에 접근해 같은 도어를 가져가는 상황이 발생할 수 있습니다. 잠금 대기 시간은 20~30초 정도로 설정하면 실사용에서 무리가 없었습니다.

둘째, 장비 유형·도어 코드 등 문자열 필드는 항상 화이트리스트로 검증합니다. 장비 유형 컬럼의 오타 하나가, 코드 상에서는 특정 슬롯을 “도어 사용 0개”로 인식하게 만들어 지나치게 많은 예약을 허용하는 일이 실제로 있었습니다. 이번 코드처럼 허용 목록을 상수로 두고, 목록 밖 값은 예외로 막는 편이 결국 운영 비용을 줄였습니다.

셋째, “도어 배정 보류”를 적극적으로 사용합니다. 도어가 꽉 찬 시간대까지 자동 배정을 억지로 시도하는 것보다, 예약 자체는 받되 도어 칸만 비워 두면, 교대 리더가 출근 후 10분 정도만 투자해도 보류 건을 한 번에 정리할 수 있습니다. 지금 시트 구조에서는 D열이 빈 행이 곧 보류 건이므로, 상태 열을 새로 만들 필요 없이 D열 빈 값 필터 하나로 보류 뷰를 만들 수 있습니다.

넷째, 취소 흐름은 아직 이 시리즈에 없다는 점을 분명히 알고 넘어가야 합니다. 지금 예약 시트(A~M)에는 상태 열이 없어서, 취소된 예약을 “도어를 놓았다”고 표시할 방법이 없습니다. 현재 운영에서 취소는 해당 행을 지우거나 보관 시트로 옮기는 방식으로 처리해야 도어 점유 계산이 맞습니다. 상태 열을 정식으로 추가하려면 예약 시트 머리글·저장 함수·정원 계산·이 편의 점유 계산을 한꺼번에 고쳐야 하므로, 별도 편에서 다루는 편이 안전합니다.

마지막으로, 모든 변경은 테스트 스프레드시트에서 먼저 충분히 시뮬레이션해 보는 것을 권장합니다. 실제 운영 시트 하나만을 대상으로 바로 코드를 수정하면, 그날 도어 배정 통계와 리포트가 뒤섞입니다. 같은 구조의 테스트 시트를 하나 복제해 두고, 최소 하루치 운영 패턴을 가정한 더미 데이터를 만들어 여러 시간대·여러 장비 유형을 돌려 보는 것이 안전합니다.


맺음말 – 오늘 바로 해 볼 한 가지 점검

구글시트 도크 도어 자동 배정은 겉으로 보기에는 복잡해 보이지만, 결국 “지금 이 슬롯에 이미 어떤 도어가 꽂혀 있는지 정확히 세고, 남은 도어 중 하나를 고른다”는 단순한 원리를 코드로 옮긴 것입니다. 여기에 구글시트 예약 중복 방지 자동화와 잠금 처리를 결합하면, 사람이 시트 열고 도어를 손으로 배정하던 시간을 상당 부분 줄일 수 있습니다.

오늘 바로 해 볼 수 있는 한 가지는, 운영 중인 APPT_MAIN 시트에서 특정 날짜·시간대를 하나 고른 뒤 직접 눈으로 도어를 세어 본 결과와 BOOK_testGetDoorUsageForSlotCore_() 실행 결과를 비교해 보는 것입니다. 두 값이 정확히 일치한다면, 그 위에 자동 배정과 메일 발송을 올릴 준비가 된 것입니다. 이 검증만 통과해 두어도, 이후 도어 추가·시간대 확장 등 운영 변화가 생길 때 자신 있게 코드를 손볼 수 있습니다.