0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Googleフォームの受付IDをGASで固定する:再実行で案件を増やさない

0
Posted at

Googleフォームの送信トリガーがタイムアウトしたあと、担当者が同じ回答を手動で再実行すると、受付ログに同じ案件が2行できることがあります。AI要約や返信案の生成をつなぐ場合、重複案件ごとに処理が進み、二重返信の確認負荷も増えます。

この記事では、フォーム回答から安定した受付IDを作り、同じ入力を再処理しても「新しい案件」として登録しない最小構成をGASで実装します。AI API、メール送信、Sheetsへの書き込みは実装せず、架空の回答とインメモリ台帳で境界をテストします。

フォーム回答を受付IDで束ね、再実行を保留へ戻すフロー

作るもの

対象は、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で束ねられたからといって、後続処理を自動送信へ進めてはいけません。境界を分けます。

  1. フォーム受信
  2. ID入力の型と許可値を検査
  3. 既存IDと指紋を照合
  4. 新規なら受付ログへ登録し、再実行なら既存案件へ戻す
  5. AIへ渡す項目・同意・FAQ版を別に確認
  6. AI返信案を人が確認
  7. 宛先・添付・期限を確認してから送信

再実行判定のログには、本文全文や機密情報ではなく、次を残します。

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、止まる条件、次に確認する人を決めます。まずはフォーム一つで、再実行を再現するテストから始めてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?