[GAS] 具体事例から学ぶScriptAppによるトリガー生成コードサンプル（４）～指定休業日以外の営業日ごとに実行したい場合

# はじめに
スプレッドシートを始めとしたGoogleのクラウドアプリは、**時間主導型トリガー**（以降、タイムトリガー）を使用して、定型処理を定期的に自動実行させることができます。

タイムトリガーはＵＩ画面から簡単に設定可能ですが、ＵＩにて設定できる項目が限られているため、要望する内容によってはGASの`ScriptApp`を使ってプログラム側からトリガーを生成する必要があります。

本稿では、ＵＩ画面からの設定では対応できない内容について、当方にご相談頂いた内容を元にした事例別の参考コードを不定期連載形式で紹介していきたいと思います。（ネタが溜まってきたら、随時公開予定です）

今回は[前回](https://qiita.com/STSHISHO/items/83f7047876d29520847a)の応用編として、**指定休業日以外の営業日ごとに実行したい場合** のご紹介です。

::: note
**前提条件**
1. 仕様上、実行遅延（タイムラグ）がおよそ数分以内のレベルで発生します。そのため、厳密な時間管理が必要な処理には不向きです。（[公式](https://developers.google.com/apps-script/reference/script/clock-trigger-builder)にて一部メソッドにおいては±15分と明記されています）
2. 同様に、仕様上、エラー通知の頻度をスクリプト側で設定できないため、 **毎日通知を受け取る** がデフォルトとなります。
3. 拙記事ですが、[こちら](https://qiita.com/STSHISHO/items/2985c7b92c80b28f1018)を参考にタイムゾーンの設定が実行環境と合っているか確認してください。
4. サンプルコードでは１日あたりの総実行時間、１ユーザーあたり２０トリガーまで等、GASの制限事項は考慮していません。
:::

::: note
**設置時の共通事項**
1. トリガー生成ロジックは独立した関数として定義し、メイン処理内に組み込みます。組み込み位置は末尾を推奨しておきますが、好みや状況に合わせて適宜ご対応ください。
2. [GASの制限事項](https://developers.google.com/apps-script/guides/services/quotas#current_quotas)を考慮し、適宜メイン処理の軽量化、高速化をしましょう。また、実行回数に関係なく、実行済みトリガーの削除処理の実装を推奨します。
3. 初回のトリガー設定のみ手動で行いますが、設定画面経由でもエディタから当該関数の直接実行でもどちらでも結構です。
:::

# 指定休業日以外の営業日ごとに実行したい場合
## 休業日についての要件をまとめる
主にサービス業の方からのご相談が多いケースですが、本件のような内容では、指定休業日が不定休なのか一部例外を除き一定の法則があるのかに関わらず、**翌営業日を特定すること** がポイントになります。

なお、ベースとなるロジックは [前回](https://qiita.com/STSHISHO/items/83f7047876d29520847a) の応用編になりますので、**当日実行時に翌営業日を特定し、指定の日時で実行予約をする** 、というロジックをベースとします。以下が雛形となるコードになります。

```Javascript: 雛形コードサンプル
// 初期設定用オブジェクト
function init(){
  return {
    /* 必要に応じてその他プロパティ設定 */

    START_TIME: '00:00', // hh:mm形式で指定
    TRIGGER_HUNDLER: 'main' // 自動実行対象の関数名
  };
}

// メイン処理
function main() {
  /* メイン処理省略 */

  setTrigger();
}

// トリガー生成処理
function setTrigger() {
  const now = new Date();
  const INIT = init();
  const startTime = INIT.START_TIME.split(':');

  // 翌営業日を特定する処理
  const getNBD = date => {

    /* 以降、ケースに応じて構築 */
  };

  const NBD = getNBD(now);  
  const targetDate = new Date(NBD.getFullYear(), NBD.getMonth(), NBD,getDate(), startTime[0]*1, startTime[1]*1);

  ScriptApp.newTrigger(INIT.TRIGGER_HUNDLER)
  .timeBased()
  .at(targetDate)
  .create();
}
```

つまり、今回の主目的は、個別のご要望に応じた `getNBD()` の構築ということになり、合わせてその他付随する処理を追加もしくは修正して対応することになります。以下、具体的な事例を元に対応内容を考えてみましょう。

- 事例Ａ：元日以外は原則無休、月内に１日店休日を設けることもあり、当月中に翌月の店休日を決定する（連休になることはない）
- 事例Ｂ：土日祝は原則として休業、家族や個人的な理由で急遽１日から数日間休業することがあり、原則前日までには決定する
- 事例Ｃ：毎週火曜日が定休日、祝日の場合は翌水曜日に振替える（水曜日も祝日の場合はその週は無休）

※事例Ｃは年末年始、GW、お盆期間に不定期の休業があり、期間は年によって変わる

また、今回は以下の内容も前提として追加しておきます。
- 営業日の指定時間に１度だけ実行する
- スプレッドシートがメインＵＩ

## ロジック
事例ごとの要件に合わせ、翌営業日を取得する `getNBD()` を構築します。いずれも別途休業日登録用のカレンダーかテーブルを用意し、参照、休業日判定という流れとなります。事例Ｂ、Ｃは、一定の法則があるため、加えて、例外時の判定方法を考えればよいでしょう。

### 事例A
基本的に毎日実行することになりますので、その際に翌日が営業日かどうかを判定し、実行予約をするという方針で良いと思います。この事例の場合は祝日カレンダーを読み込む必要がありませんので、別途カレンダー参照ではなく、別シートに休業日設定用のテーブルを設置し、名前付き範囲で参照するほうが良いでしょう。

スプレッドシートを参照するため、`init()` に `Spreadsheet` を追加しておきます。

```Javascript
// 初期設定用オブジェクト
function init(){
  return {
    SS: SpreadsheetApp.getActive(),
    START_TIME: '00:00', // hh:mm形式で指定
    TRIGGER_HUNDLER: 'main' // 自動実行対象の関数名
  };
}
```

要件より、元日が店休日確定のため、ピンポイントの元日判定を行います。また、`Utilities.formatDate()` にて日付を文字列としてフォーマットし、単純比較を行います。最後に翌日が店休日だった場合には更にその翌日を返すようにします。

```Javascript
const getNBD = date => {
  const tomorrow = new Date(date);
  tomorrow.setDate(date.getDate()+1);

  // 名前付き範囲から次回休業日を取得
  const nextClosedDay = INIT.SS.getRangeByName('**********').getValue();

  // 翌日が元日かどうか
  const isNewYear = nextDay.getMonth() === 0 && nextDay.getDate() === 1;

  // 翌営業日の判定
  const formatedNCD = Utilities.formatDate(nextClosedDay, 'JST', 'yyyyMMdd');
  const formatedNxt = Utilities.formatDate(tomorrow, 'JST', 'yyyyMMdd');
  const isClosedDay = formatedNCD === formatedNxt;

  return (isNewYear || isClosedDay)? tomorrow.setDate(tomorrow.getDate()+1): tomorrow;
};
```

### 事例Ｂ
祝日カレンダーとは別に **臨時休業カレンダーを用意する** という方針で良いと思います。事例Ａと同様に定義用のテーブルを用意しても良いですが、判定方法の統一という面から、カレンダーによる管理を推奨します。その際、カレンダーはお店や会社用のアカウントで作成し、**個人用とは混同させない** ようにしたほうが良いでしょう。

`init()` に祝日カレンダーと臨時休業カレンダーを追加します。休業日は終日イベントとして事前にカレンダーに登録されていることが前提となります。

```Javascript
// 初期設定用オブジェクト
function init(){
  return {
    HOLIDAY_CAL: CalendarApp.getCalendarById('ja.japanese#holiday@group.v.calendar.google.com'),
    CLOSED_CAL: CalendarApp.getCalendarById('********************'), // 臨時休業カレンダーID
    START_TIME: '00:00', // hh:mm形式で指定
    TRIGGER_HUNDLER: 'main' // 自動実行対象の関数名
  };
}
```

祝日判定は [前回](https://qiita.com/STSHISHO/items/83f7047876d29520847a) のものを修正して利用します。`INITオブジェクト` へアクセスするため、`setTrigger()` 内に定義します。

```Javascript
// 祝日の判定
const isHoliday = date => {
  const events = INIT.HOLIDAY_CAL.getEventsForDay(date);
  return events.length > 0;
}
```

メインとなる翌営業日の取得です。先に臨時休業判定用の処理  `isClosedDay()` を定義しておきます。

```Javascript
/* 祝日の判定処理割愛 */

// 臨時休業日の判定
const isClosedDay = date => {
  const events = INIT.CLOSED_CAL.getEventsForDay(date);
  return events.length > 0;
};

const getNBD = date => {
  const tomorrow = new Date(date);
  tomorrow.setDate(date.getDate()+1);

  // 土曜日なら日曜日をスキップし月曜日の判定
  if(tomorrow.getDay() === 6) return getNBD(new Date(tomorrow.setDate(tomorrow.getDate()+1)));

  // 祝日なら翌日の判定
  if(isHoliday(tomorrow)) return getNBD(tomorrow);

  // 臨時休業日なら翌日の判定
  if(isClosedDay(tomorrow)) return getNBD(tomorrow);

  return tomorrow;
};
```

### 事例Ｃ
基本的には事例Ｂと同様、GW、年末年始、お盆期間などの **休業日カレンダーを用意する** という方針で良いと思います。この事例では **振替処理の構築が主なポイント** となります。定休日が祝日であった場合、翌日を休業日として自動登録することにします。

また、**休業日判定のタイミングも重要** です。祝日が営業日扱いになるため処理分岐時の条件設定に注意しましょう。特に振替休業日については混乱してしまいますので、以下に分岐内容をまとめておきます。

- 定休日が祝日かつ翌日が平日：休業日カレンダーへイベント登録
- 定休日が祝日かつ翌日が祝日：何もしない
- 定休日が平日：翌日の休業日判定へ

なお、休業日の判定は事例Ｂで使用した `isClosedDay()` をそのまま使用します。もちろん、 `isHoliday()` もそのまま使用し、`init()` には休業日カレンダーと定休日の設定として、`REGULAR_CLOSED_DAY: 2` を追加しておきます。

```Javascript
// 初期設定用オブジェクト
function init(){
  return {
    HOLIDAY_CAL: CalendarApp.getCalendarById('ja.japanese#holiday@group.v.calendar.google.com'),
    CLOSED_CAL: CalendarApp.getCalendarById('********************'), // 休業日カレンダーID
    REGULAR_CLOSED_DAY: 2,
    START_TIME: '00:00', // hh:mm形式で指定
    TRIGGER_HUNDLER: 'main' // 自動実行対象の関数名
  };
}
```

先に休業日判定を行うことで、事前に登録されているGW等の休業期間と後から自動で登録される振替休業日が重なっても、正常に判定されようにします。

```Javascript
/* 祝日の判定処理割愛 */

/* 休業日の判定処理割愛 */

const getNBD = date => {
  const tomorrow = new Date(date);
  tomorrow.setDate(date.getDate()+1);

  // 休業日の判定（GW、年末年始、お盆、振替休業日）
  if(isClosedDay(tomorrow)) return getNBD(tomorrow);

  // 振替休業日の設定
  const isRCD = tomorrow.getDay() === INIT.REGULAR_CLOSED_DAY;
  if(isRCD){
    const slideDay = new Date(tomorrow);
    slideDay.setDate(tomorrow.getDate()+1);

    // 定休日が祝日かつ翌日が平日なら振替え、平日なら翌日の判定
    if(isHoliday(tomorrow) && !isHoliday(slideDay)) INIT.CLOSED_CAL.createAllDayEvent('振替休業日', slideDay);
    if(!isHoliday(tomorrow)) return getNBD(tomorrow);
  }

  return tomorrow;
};
```

### 初回設定（共通）
初回はＵＩ画面より**特定の日時**で翌日の指定時間を設定し、`main()` を実行させます。以下は現在時が2022年6月23日、時間は午前９時の場合の設定例です。

- **実行する関数を選択**：main
- **時間ベースのトリガーのタイプを選択**：特定の日時
- **日時を入力**：2022-06-24 09:00

# 最後に
サンプルということもあり、開発効率優先のテンプレートに当てはめる形式で構築しました。事例Ａは時間指定がなければ **日付ベースのタイマー** で毎日定時間帯に実行し、休業日だけ処理スキップする仕様でも良いと思います。その他、事例Ｂ、Ｃについてももっと効率的な方法があると思います。

誰かのお役に立てたなら幸いです。

# [参考・引用]
- [Class ClockTriggerBuilder  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/reference/script/clock-trigger-builder)
- [[GAS] 簡単になったGAS実行環境のタイムゾーン設定](https://qiita.com/STSHISHO/items/2985c7b92c80b28f1018)
- [Quotas for Google Services  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/guides/services/quotas#current_quotas)