深夜営業を扱うGoogle Apps Scriptアプリでは、「日付」と「時刻」を素直に保存すると、集計や採番がずれやすくなります。
たとえば、営業日は9月1日でも、実際の発生時刻は9月2日1時40分というケースがあります。これを単純に9月2日のデータとして保存すると、営業日別集計、伝票番号、部別集計が全部ずれます。
さらに、外部データを数百行まとめて取り込む場合、毎行getRange()とsetValue()を繰り返すと遅くなります。この記事では、深夜営業の営業日設計と、大量行upsertをGoogleスプレッドシートで安定させる方法をまとめます。
営業日と発生日時は別の列にする
深夜営業では、業務上の「日付」と実際のタイムスタンプを分けます。
| 列 | 役割 | 例 |
|---|---|---|
| 営業日 | 集計、採番、検索に使う業務上の日付 | 2026-09-01 |
| 発生日時 | 実際に発生した日時 | 2026-09-02 01:40 |
| 表示時刻 | 利用者に見せる24時超え表記 | 25:40 |
営業日を主キーや検索キーに使い、発生日時は時系列確認用として持たせます。これにより、深夜1時のデータを前営業日の枠に残せます。
24時超え設定は表示値で読む
Googleスプレッドシートで24:00や33:00のような値を設定セルに置く場合、getValue()で読むと日付型に変換され、0:00や9:00のように見えることがあります。
深夜営業の区切り時刻は、表示された文字列として読む方が安全です。
function readSettingTime_(range) {
return range.getDisplayValue().trim();
}
function timeTextToMinutes_(text) {
const normalized = String(text).trim();
const match = normalized.match(/^(\d{1,2})(?::?(\d{2}))?$/);
if (!match) throw new Error(`時刻形式が不正です: ${text}`);
const hours = Number(match[1]);
const minutes = Number(match[2] || '0');
return hours * 60 + minutes;
}
1:40、25:40、2540のような入力を許可する場合も、内部では分に統一します。
営業日は締め時刻から逆算する
2部終了が33:00、つまり翌9時なら、営業日の切り替え時刻は9時です。実時刻がその時刻より前なら、前日を営業日として扱います。
function toBusinessDate_(occurredAt, closeMinutes) {
const cutoffMinutes = closeMinutes % 1440;
const minutesOfDay = occurredAt.getHours() * 60 + occurredAt.getMinutes();
const businessDate = new Date(occurredAt);
if (minutesOfDay < cutoffMinutes) {
businessDate.setDate(businessDate.getDate() - 1);
}
return Utilities.formatDate(businessDate, 'Asia/Tokyo', 'yyyy-MM-dd');
}
表示側では、翌日未明の時刻を24時超えとして戻します。
function formatBusinessTime_(occurredAt, businessDateText) {
const base = new Date(`${businessDateText}T00:00:00+09:00`);
const diffMinutes = Math.round((occurredAt.getTime() - base.getTime()) / 60000);
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
return `${hours}:${String(minutes).padStart(2, '0')}`;
}
この設計にすると、保存データは実時刻で正確に追え、業務画面では25:40のような表記もできます。
採番は営業日を含める
深夜営業で日跨ぎがあるなら、採番も実日付ではなく営業日ベースにします。
function buildSaleId_(businessDate, sequence) {
return `${businessDate.replaceAll('-', '')}-${String(sequence).padStart(4, '0')}`;
}
このIDなら、9月2日1時40分に登録されたデータでも、9月1日営業分として採番できます。
大量行upsertはMap索引を先に作る
次に、大量行の更新または追加です。スプレッドシートで遅くなる典型は、1行ずつ検索して1セルずつ書く実装です。
対策は、最初に既存行をまとめて読み、キーから行番号を引けるMapを作ることです。
function buildRowIndex_(values, keyColumns) {
const index = new Map();
values.forEach((row, i) => {
const key = buildKey_(row, keyColumns);
if (key) index.set(key, i + 2); // 1行目がヘッダーの場合
});
return index;
}
function buildKey_(row, keyColumns) {
return keyColumns.map((col) => normalizeKeyValue_(row[col])).join('\t');
}
キーの連結にはカンマよりタブを使う方が安全です。案件名や住所などにはカンマが入りやすいためです。
日付キーは必ず正規化する
upsertで特に危ないのは日付です。シート上では同じ2026-09-01に見えても、内部値がDate型のセルと文字列のセルが混在することがあります。
function normalizeKeyValue_(value) {
if (value instanceof Date) {
return Utilities.formatDate(value, 'Asia/Tokyo', 'yyyy-MM-dd');
}
return String(value ?? '').trim();
}
この正規化をしないと、同じデータを2回取り込んだときに、2回目も追加扱いになり、重複行が増えます。
更新行は連続範囲にまとめてsetValuesする
更新対象が100行あっても、100回setValues()を呼ぶ必要はありません。更新行を行番号順に並べ、連続している範囲はまとめて書きます。
function writeGroupedUpdates_(sheet, updates) {
const sorted = [...updates].sort((a, b) => a.rowNumber - b.rowNumber);
let group = [];
for (const item of sorted) {
const last = group.at(-1);
if (!last || item.rowNumber === last.rowNumber + 1) {
group.push(item);
continue;
}
flushGroup_(sheet, group);
group = [item];
}
if (group.length) flushGroup_(sheet, group);
}
function flushGroup_(sheet, group) {
const startRow = group[0].rowNumber;
const values = group.map((item) => item.values);
sheet.getRange(startRow, 1, values.length, values[0].length).setValues(values);
}
追加行は末尾にまとめて挿入します。必要なら事前に行数を増やしてから1回で書き込みます。
二重実行はLockServiceで止める
外部連携や複数ユーザー操作がある場合、同じスクリプトが同時に動くことがあります。upsert中に別のupsertが走ると、Map索引を作った直後に他方が行を追加し、行番号がずれることがあります。
書き込み全体はLockServiceで囲みます。
function importRows_(rows) {
const lock = LockService.getScriptLock();
lock.waitLock(30_000);
try {
return upsertRows_(rows);
} finally {
lock.releaseLock();
}
}
さらに、外部から同じリクエストが再送される可能性がある場合は、requestId単位の処理済み記録も持たせます。
検証は同じデータを2回流す
upsertの検証は、1回成功しただけでは足りません。必ず同じデータを2回流します。
期待値は次の通りです。
| 回数 | 期待結果 |
|---|---|
| 1回目 | 新規追加または既存更新が行われる |
| 2回目 | 追加0件、必要な更新のみ |
2回目に追加が発生する場合、キー正規化、日付型、空白、全角半角、改行のどれかが揺れています。
まとめ
深夜営業と大量行upsertは、どちらも「見た目の値」と「内部処理の値」を分けることが重要です。
- 営業日と発生日時は別列にする
- 24時超え時刻は
getDisplayValue()で読み、内部では分に変換する - 営業日判定、表示時刻、採番は営業日ベースで統一する
- upsertは既存行をまとめて読んでMap索引を作る
- 日付キーは
yyyy-MM-ddへ正規化する - 更新は連続範囲ごとにまとめて
setValues()する - 同時実行はLockServiceで守る
- 検証は同じデータを2回流し、2回目に追加が出ないことを見る
この形にしておくと、深夜をまたぐ現場データでも、集計、検索、採番、再取込が崩れにくくなります。
