はじめに
最近は、非エンジニアがAIを使ってGASを書く場面が増えました。一方で、止まっても気づかない、作った本人しか直せないといった形でつまずくケースも多く見かけます。そこで、まずエンジニアが土台となる型を作り、それを引き渡すという方式を考えました。
題材は、卸の担当者がメールで受け取る注文書です。15分ごとに Gmail のラベルを読み、注文の明細をスプレッドシートに書き出し、怪しい注文は推測せずに「要確認」に回して、まとめて1通メールで知らせます。顧客・商品・メールはすべて架空です。
現場の悩みと設計判断
| 現場でよくある悩み | このリポジトリでの判断 |
|---|---|
| しきい値や通知先を変えるのに、コードを開く必要がある | 設定は 設定シート(キー・値・説明の3列)に置く。値はスクリプトが確かめ、間違いはふつうの言葉で知らせる |
| 夜中に止まっても誰も気づかない | 実行のたびに 実行ログ シートへ1行書く。失敗と要確認は メールで通知 |
| 推測で取り込んで、違う商品が出荷される | 読み切れないもの(未登録のSKU、多すぎる数量、顧客コードなし、読めない行)があれば、注文ごと要確認 に回す。黙って捨てない |
| 再実行や実行の重なりで、同じ注文が2回書かれる | スクリプトロック で重なりを止め、書き込んでからラベルを付け替える。シートにあるメールIDは読み飛ばす |
| 件数が多い日に6分の制限で止まる | 古い順に読み、1回あたりの上限 を超えた分は次の実行に回す |
| 作った人しか直せない | TypeScript とテスト・CI、clasp で git から配布。運用ガイド と 引き継ぎチェックリスト を同梱 |
作り方:AIとの分担
AIコーディングアシスタント(Claude)と一緒に作りました。何を作るか、守る条件(架空データのみ・秘密情報を扱わない・非エンジニアが運用できること)を決め、結果を確かめたのは私です。設計の提案、コード、テスト、CI、ドキュメントはAIが担当しました。
全体像
ルールはすべて src/core に置き、GmailApp や SpreadsheetApp を一切呼びません。Apps Script とのやりとりは src/gas の薄い層だけです。こうしておくと、ルールを Vitest で手元でテストできます。
1. Apps Script を「窓口(ports)」の向こうに置く
実行の流れは、小さなインターフェースに対して書いています。
export interface Ports {
lock: { tryLock(timeoutMs: number): boolean; release(): void };
settingsRows(): unknown[][];
inbox: {
list(label: string, limit: number): IncomingMessage[];
relabel(threadIds: readonly string[], from: string, to: string): void;
};
sheet: {
processedIds(): Set<string>;
knownOrderNos(): Set<string>;
knownSkus(): Set<string>;
appendOrders(rows: readonly OrderRow[]): void;
appendRunLog(entry: RunLogEntry): void;
url(): string;
};
notify(to: readonly string[], subject: string, body: string): void;
ownerEmail(): string;
now(): Date;
}
本番では GmailApp などで実装し、テストでは偽物を渡します。テストでは「ロックが取れなければ何もしない」「設定が間違っていればメールを読む前に止まる」「例外が出てもロックを外す」といった、本番で試しにくい動きを確かめています。
2. 書き込んでからラベルを付け替える
一番気をつけたのは、途中で止まっても注文が二重にならない ことです。
// Write first, relabel second: if the run dies in between, the next run sees the
// message IDs on the sheet and skips them instead of writing the orders twice.
if (plan.rows.length > 0) ports.sheet.appendOrders(plan.rows);
if (plan.doneThreadIds.length > 0) ports.inbox.relabel(plan.doneThreadIds, settings.inboxLabel, settings.doneLabel);
逆の順番だと、ラベルを付け替えた直後に止まった場合、その注文はどこにも書かれずに消えます。この順番なら、最悪でも次の実行で読み直し、シートにあるメールIDとして読み飛ばすだけです。
3. 推測しない:注文ごと要確認に回す
注文のうち1行でも怪しければ、その注文全体を要確認にします。半分だけ出荷するより、人が確かめるまで止めておくほうが安全だからです。
// If any part of an order is doubtful, the whole order goes to review:
// shipping half an order is worse than holding it for a person to check.
const lineReasons = lineRows.filter((r) => r.reasons !== "").map((r) => `${r.sku}: ${r.reasons}`);
const allReasons = [...orderReasons, ...lineReasons];
const needsReview = allReasons.length > 0;
読み取りは、安全なところだけ寛容にしています。全角の英数字(日本語入力のまま打たれたもの)、タブや x 区切り、あいさつ・署名・引用部分は受け入れ、読めない行は「○行目が読めません」として必ず理由に残します。
4. 設定はシートで、間違いはふつうの言葉で
設定シートの値は、スクリプトが毎回確かめます。
if (!Number.isInteger(n) || n < 1 || n > max) {
errors.push(`"${key}" must be a whole number between 1 and ${max} (it is "${raw}").`);
return fallback;
}
設定が間違っているときは、メールを1通も読まずに止まり、「注文は失われていません。設定シートの○○を直して、メニューから再実行してください」とメールで知らせます。止まり方まで含めて、非エンジニアが自分で直せることを目指しました。
5. TypeScript を GAS で動かすビルド
Apps Script はトップレベルの関数しか呼べないので、esbuild で1つのファイルにまとめたあと、公開する関数ごとに小さな関数を足しています。
const wrappers = exportsList
.map((name) => `function ${name}() {\n return ${GLOBAL}.${name}.apply(this, arguments);\n}`)
.join("\n\n");
テストでは、できあがった dist/Code.js を Node の vm で読み込み、トリガーやメニューから呼ぶ関数が本当にあるかを確かめています。
テストとCI
Vitest で33件のテストを書きました。
- 読み取り:全角、区切り文字、引用部分、読めない行、日付のチェック
- 設定:既定値、不正な値の説明、通知先が空のとき
- 計画:要確認の判定、二重取り込みの防止、1回の上限と次回への持ち越し
- 実行:書き込みと付け替えの順番、ロック、設定エラー、例外時の通知
- ビルド:GAS から呼べる関数がそろっていること
GitHub Actions で、lint・型チェック・テスト・ビルドを実行しています。テストは偽物のサービスを使うので、Gmail やシートそのものは確かめていません。src/gas を変えたときは、シートのコピーで「今すぐ実行」を試す運用にしています。
まとめ
- ルールは純粋な TypeScript に置き、Apps Script は薄い窓口にする
- 書き込んでからラベルを付け替え、途中で止まっても二重にしない
- 怪しいものは推測せず、注文ごと要確認に回し、理由を残す
- 設定はシートに置き、間違いはふつうの言葉で知らせる
- 実行ログ・通知・運用ガイド・引き継ぎチェックリストまでを1セットにする
おわりに
型さえ引き渡しておけば、その後のメンテナンスや機能の追加は、非エンジニアがAIを使って自分たちで進められる時代になったと思います。テストとCIを最初から入れておいたのは、AIに直してもらったあとに、壊れていないかを確かめられるようにするためです。