Googleフォームの送信トリガーがタイムアウトしたあと、担当者が同じ回答を手動で再実行すると、受付ログに同じ案件が2行できることがあります。AI要約や返信案の生成をつなぐ場合、重複案件ごとに処理が進み、二重返信の確認負荷も増えます。
この記事では、フォーム回答から安定した受付IDを作り、同じ入力を再処理しても「新しい案件」として登録しない最小構成をGASで実装します。AI API、メール送信、Sheetsへの書き込みは実装せず、架空の回答とインメモリ台帳で境界をテストします。
作るもの
対象は、Googleフォームの回答をSheetsへ記録し、後でAI返信案やCRM登録へつなぐ担当者です。完成するのは「同じ回答を同じ案件として識別する」境界であり、送信許可ではありません。
- 受付IDの入力項目を固定する
- 値を正規化しすぎず、契約した形式でキーを作る
- 同じキーの既存案件を再登録しない
- 同じIDでも内容が変わった場合は全体HOLDにする
- AI処理や外部送信は受付IDの判定後、別の確認ルールで扱う
| 判定 | 意味 | 次の処理 |
|---|---|---|
| NEW_CASE | 未登録の受付ID | 人間確認キューへ登録 |
| DUPLICATE_REPLAY | 同じID・同じ内容 | 既存案件へ戻し、新規作成しない |
| HOLD_ID_COLLISION | 同じIDなのに内容が違う | 原文を保持して人が確認 |
| INPUT_INVALID | ID生成に必要な値が不正 | 受付処理を停止 |
DUPLICATE_REPLAY は「処理が成功した」や「返信済み」を意味しません。再実行を一件へ束ねるだけです。
1. 受付IDの契約を決める
フォームの回答時刻だけをIDに使うと、タイムゾーンや再送時の違いで同じ案件を別件と誤認しやすくなります。今回は、フォーム回答に含める受付番号と、対象業務を組み合わせます。
const CASE_ID_SCHEMA = Object.freeze({
version: 'case-id-v1',
required: ['受付番号', '対象業務'],
allowedWorkTypes: ['無料診断', '問い合わせ'],
});
function assertText(value, field) {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error('INVALID_' + field);
}
return value;
}
function buildCaseId(answer) {
const requestNo = assertText(answer['受付番号'], 'REQUEST_NO');
const workType = assertText(answer['対象業務'], 'WORK_TYPE');
if (!CASE_ID_SCHEMA.allowedWorkTypes.includes(workType)) {
throw new Error('WORK_TYPE_NOT_ALLOWED');
}
return CASE_ID_SCHEMA.version + ':' + workType + ':' + requestNo;
}
受付番号はフォームの質問として必須にし、発行ルールを別途決めます。フォーム回答の配列をそのまま連結したり、全項目をIDに含めたりすると、自由記述の編集で別案件扱いになりやすいので避けます。
2. 回答をID用の入力へ取り出す
GASのフォーム送信イベントは、質問名から回答配列を引く形で扱います。必要な値が一件の文字列であることを確認し、欠落を空文字へ変換しません。
function readOne(namedValues, question) {
if (namedValues === null || typeof namedValues !== 'object' || Array.isArray(namedValues)) {
return { ok: false, reason: 'NAMED_VALUES_INVALID' };
}
if (!Object.prototype.hasOwnProperty.call(namedValues, question)) {
return { ok: false, reason: 'QUESTION_MISSING', question };
}
const values = namedValues[question];
if (!Array.isArray(values) || values.length !== 1 || typeof values[0] !== 'string') {
return { ok: false, reason: 'ANSWER_SHAPE_INVALID', question };
}
return { ok: true, value: values[0] };
}
function readCaseIdInput(namedValues) {
const requestNo = readOne(namedValues, '受付番号');
const workType = readOne(namedValues, '対象業務');
if (!requestNo.ok) return { status: 'INPUT_INVALID', ...requestNo };
if (!workType.ok) return { status: 'INPUT_INVALID', ...workType };
try {
const caseId = buildCaseId({
'受付番号': requestNo.value,
'対象業務': workType.value,
});
return { status: 'ID_READY', caseId };
} catch (error) {
return { status: 'INPUT_INVALID', reason: error.message };
}
}
ID生成の前に、受付番号の前後空白を黙って削除するかどうかも決めます。今回の例では空欄判定にだけ trim を使い、保存値そのものは変更しません。フォーム側で形式を固定し、変更は保留へ戻す方針です。
3. 再実行を同じ案件へ束ねる
実運用のSheetsでは、同時実行や書き込み途中の失敗も考えます。まずは判定関数を副作用なしで作り、既存台帳のIDと内容の指紋を比較します。
function classifyReplay(caseId, contentFingerprint, existingRows) {
if (!Array.isArray(existingRows)) {
return { status: 'INPUT_INVALID', reason: 'LEDGER_INVALID' };
}
const sameId = existingRows.filter(row => row.caseId === caseId);
if (sameId.length === 0) {
return { status: 'NEW_CASE', caseId, contentFingerprint };
}
if (sameId.length > 1) {
return { status: 'HOLD_ID_COLLISION', reason: 'DUPLICATE_LEDGER_ROWS', caseId };
}
if (sameId[0].contentFingerprint !== contentFingerprint) {
return { status: 'HOLD_ID_COLLISION', reason: 'CONTENT_CHANGED', caseId };
}
return { status: 'DUPLICATE_REPLAY', caseId, existingRow: sameId[0].rowNo };
}
contentFingerprint は本文をAIへ送るための値ではありません。自由記述をそのままログへ複製しないため、実装時は組織の保持方針に合わせて、送信元で作ったハッシュや管理番号などを使います。ハッシュの一致も、内容が正しいことや同意を証明しません。
4. 架空の台帳でテストする
次のテストでは、Sheets APIを呼びません。新規、同じ入力の再実行、同じIDで内容が変わったケース、台帳側の重複を確認します。
function testClassifyReplay() {
const first = [{ caseId: 'case-id-v1:無料診断:R-001', contentFingerprint: 'fp-a', rowNo: 2 }];
const cases = [
[[], 'NEW_CASE'],
[first, 'DUPLICATE_REPLAY'],
[[{ caseId: 'case-id-v1:無料診断:R-001', contentFingerprint: 'fp-b', rowNo: 2 }], 'HOLD_ID_COLLISION'],
[[...first, ...first], 'HOLD_ID_COLLISION'],
];
cases.forEach(([ledger, expected], index) => {
const actual = classifyReplay('case-id-v1:無料診断:R-001', 'fp-a', ledger).status;
if (actual !== expected) throw new Error('CASE_FAILED_' + index);
});
console.log('replay cases passed: ' + cases.length);
}
最低限、次を含めます。
- 未登録ID
- 同じIDと同じ指紋の再実行
- 同じIDだが内容指紋が違う入力
- 台帳に同じIDが複数ある異常
- 受付番号の欠落・空欄・許可外の業務
5. ID判定後も人間確認を残す
受付IDで束ねられたからといって、後続処理を自動送信へ進めてはいけません。境界を分けます。
- フォーム受信
- ID入力の型と許可値を検査
- 既存IDと指紋を照合
- 新規なら受付ログへ登録し、再実行なら既存案件へ戻す
- AIへ渡す項目・同意・FAQ版を別に確認
- AI返信案を人が確認
- 宛先・添付・期限を確認してから送信
再実行判定のログには、本文全文や機密情報ではなく、次を残します。
received_at, case_id_schema, case_id, replay_status,
content_fingerprint, source_event_id, reviewer, reviewed_at
同じフォーム回答を再実行した担当者が、既存案件のどこへ戻ればよいかが分かることが重要です。
導入前チェックリスト
- 受付IDの入力項目・版・発行者が決まっている
- 欠落や不正な型を空文字へ変換していない
- 同じIDと内容変更を別の判定にしている
- 台帳側の同一ID複数行をHOLDにできる
- 再実行は新規登録せず既存案件へ戻す
- ID判定とAI送信許可を分けている
- 同意・FAQ版・人間確認を別に記録している
- 架空データで境界ケースを再実行できる
まとめ
フォーム処理の再実行は、失敗後の復旧に必要です。一方、毎回新しい案件として登録すると、AI要約や返信案の重複につながります。受付IDの契約を固定し、同じID・同じ内容は既存案件へ戻し、同じIDで内容が変わった場合はHOLDにすれば、再処理の入口を小さくできます。
Miraigentでは、AIを接続する前に、案件を一つに束ねるID、止まる条件、次に確認する人を決めます。まずはフォーム一つで、再実行を再現するテストから始めてください。
