注文ステータスの飛び越しを更新前に発見|GASで状態遷移をdry-run監査

注文ステータスの飛び越しを更新前に発見|GASで状態遷移をdry-run監査

ツールおじ|GoogleアカウントをAIエージェント化

ツールおじ|GoogleアカウントをAIエージェント化

注文管理シートで「未入金から発送済へ進んでいる」「発送済なのに次の状態が発送準備になっている」といった食い違いがあると、あとから履歴を追うのが大変です。この記事では、元の注文行を変更せず、現在状態から次の状態へ進めてよいかを行番号だけで確認する無料GASを紹介します。

コードは常にdry-runで動きます。注文ID、購入者名、住所、商品名などの値をログへ出さず、問題件数と行番号だけを確認できます。

先に知っておきたいこと

  • できること: 注文ステータスの未入力、想定外の値、順序の飛び越し、逆戻り、注文IDの重複、更新予定日の形式を確認できます。
  • できないこと: 入金確認、配送会社との連携、発送通知、請求や税務上の判断、注文行の自動更新は行いません。
  • 安全設計: 元シートへsetValue、deleteRow、sendEmailなどを実行しません。
  • ログ範囲: 行番号とエラー分類だけを記録し、注文IDや個人情報は出力しません。
  • 制約: 状態名と遷移順は自分の運用に合わせて、コード上の設定を確認してください。

この例は学習・検証用です。実データへ適用する前に、必ずシートをコピーし、少量のサンプルで結果を確認してください。

準備するシート

スプレッドシートに「注文確認」というシートを作り、1行目へ次の見出しを左から並べます。

  • 注文ID
  • 現在状態
  • 次の状態
  • 更新予定日

状態は、この記事では次の順番を想定します。

  1. 未入金
  2. 入金済
  3. 発送準備
  4. 発送済
  5. 完了

たとえば「現在状態」が未入金で「次の状態」が入金済なら正常です。未入金から発送済へ飛ぶ行、発送済から発送準備へ戻る行、一覧にない状態名が入った行は確認対象になります。

「次の状態」を空欄にした行は、更新予定のない行として扱います。そのため、監査だけしたい既存行を無理に編集する必要はありません。

コピーして使える最小コード

function auditOrderStatusTransitionFree() {
  const config = {
    sheetName: '注文確認',
    headerRow: 1,
    dryRun: true,
    writeSummary: false,
    summarySheetName: 'AuditSummary',
    allowedStatuses: ['未入金', '入金済', '発送準備', '発送済', '完了'],
    maxRows: 1000
  };

  validateOrderAuditConfig_(config);

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName(config.sheetName);
  if (!sheet) {
    throw new Error('対象シート「' + config.sheetName + '」が見つかりません。');
  }

  const lastRow = sheet.getLastRow();
  const lastColumn = sheet.getLastColumn();
  if (lastRow <= config.headerRow) {
    Logger.log('確認対象は0件です。');
    return { checked: 0, ok: 0, warning: 0, errors: 0 };
  }

  const targetCount = lastRow - config.headerRow;
  if (targetCount > config.maxRows) {
    throw new Error(
      '確認対象が上限の' + config.maxRows + '行を超えています。' +
      'コピー環境で範囲を分けて確認してください。'
    );
  }

  const values = sheet
    .getRange(config.headerRow, 1, targetCount + 1, lastColumn)
    .getValues();

  const headers = values[0].map(function(value) {
    return String(value).trim();
  });
  const requiredHeaders = ['注文ID', '現在状態', '次の状態', '更新予定日'];
  const indexes = {};

  requiredHeaders.forEach(function(header) {
    const index = headers.indexOf(header);
    if (index === -1) {
      throw new Error('必須列「' + header + '」がありません。');
    }
    indexes[header] = index;
  });

  const statusIndex = {};
  config.allowedStatuses.forEach(function(status, index) {
    statusIndex[status] = index;
  });

  const seenOrderKeys = {};
  const issues = [];
  let okCount = 0;

  values.slice(1).forEach(function(row, offset) {
    const rowNumber = config.headerRow + offset + 1;
    const orderId = String(row[indexes['注文ID']] || '').trim();
    const currentStatus = String(row[indexes['現在状態']] || '').trim();
    const nextStatus = String(row[indexes['次の状態']] || '').trim();
    const plannedAt = row[indexes['更新予定日']];
    const rowIssues = [];

    if (!orderId) {
      rowIssues.push('ORDER_ID_EMPTY');
    } else {
      const orderKey = hashOrderKey_(orderId);
      if (seenOrderKeys[orderKey]) {
        rowIssues.push('ORDER_ID_DUPLICATE');
      }
      seenOrderKeys[orderKey] = true;
    }

    if (!Object.prototype.hasOwnProperty.call(statusIndex, currentStatus)) {
      rowIssues.push('CURRENT_STATUS_INVALID');
    }

    if (nextStatus) {
      if (!Object.prototype.hasOwnProperty.call(statusIndex, nextStatus)) {
        rowIssues.push('NEXT_STATUS_INVALID');
      } else if (Object.prototype.hasOwnProperty.call(statusIndex, currentStatus)) {
        const difference = statusIndex[nextStatus] - statusIndex[currentStatus];
        if (difference > 1) {
          rowIssues.push('STATUS_SKIPPED');
        } else if (difference < 0) {
          rowIssues.push('STATUS_REVERSED');
        } else if (difference === 0) {
          rowIssues.push('STATUS_UNCHANGED');
        }
      }
    }

    if (plannedAt !== '' && !isValidOrderAuditDate_(plannedAt)) {
      rowIssues.push('PLANNED_DATE_INVALID');
    }

    if (rowIssues.length === 0) {
      okCount += 1;
      return;
    }

    rowIssues.forEach(function(code) {
      issues.push({ row: rowNumber, code: code });
    });
  });

  const summary = buildOrderAuditSummary_(targetCount, okCount, issues);

  Logger.log('dry-run: ' + config.dryRun);
  Logger.log('確認行数: ' + summary.checked);
  Logger.log('問題なし行数: ' + summary.ok);
  Logger.log('確認対象行数: ' + summary.warningRows);
  Logger.log('指摘件数: ' + summary.issueCount);
  issues.forEach(function(issue) {
    Logger.log('行' + issue.row + ': ' + issue.code);
  });
  Logger.log('注文ID・購入者情報・住所・商品名はログへ出していません。');

  if (config.writeSummary === true) {
    writeOrderAuditSummary_(spreadsheet, config, summary);
  }

  return summary;
}

function validateOrderAuditConfig_(config) {
  if (config.dryRun !== true) {
    throw new Error('安全のためdryRunはtrue固定です。');
  }
  if (!config.sheetName || !Array.isArray(config.allowedStatuses)) {
    throw new Error('シート名または状態一覧の設定が不正です。');
  }
  if (config.allowedStatuses.length < 2) {
    throw new Error('状態一覧は2件以上必要です。');
  }
  if (!Number.isInteger(config.maxRows) || config.maxRows < 1) {
    throw new Error('maxRowsは1以上の整数にしてください。');
  }
}

function hashOrderKey_(orderId) {
  const digest = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    orderId,
    Utilities.Charset.UTF_8
  );
  return digest.map(function(byte) {
    const normalized = byte < 0 ? byte + 256 : byte;
    return ('0' + normalized.toString(16)).slice(-2);
  }).join('');
}

function isValidOrderAuditDate_(value) {
  if (Object.prototype.toString.call(value) === '[object Date]') {
    return !isNaN(value.getTime());
  }
  if (typeof value !== 'string') {
    return false;
  }
  const text = value.trim();
  if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
    return false;
  }
  const parts = text.split('-').map(Number);
  const date = new Date(parts[0], parts[1] - 1, parts[2]);
  return date.getFullYear() === parts[0] &&
    date.getMonth() === parts[1] - 1 &&
    date.getDate() === parts[2];
}

function buildOrderAuditSummary_(checked, ok, issues) {
  const warningRows = {};
  const countsByCode = {};
  issues.forEach(function(issue) {
    warningRows[issue.row] = true;
    countsByCode[issue.code] = (countsByCode[issue.code] || 0) + 1;
  });
  return {
    checked: checked,
    ok: ok,
    warningRows: Object.keys(warningRows).length,
    issueCount: issues.length,
    countsByCode: countsByCode
  };
}

function writeOrderAuditSummary_(spreadsheet, config, summary) {
  let summarySheet = spreadsheet.getSheetByName(config.summarySheetName);
  if (!summarySheet) {
    summarySheet = spreadsheet.insertSheet(config.summarySheetName);
  }
  summarySheet.clearContents();
  summarySheet.getRange(1, 1, 5, 2).setValues([
    ['確認日時', new Date()],
    ['確認行数', summary.checked],
    ['問題なし行数', summary.ok],
    ['確認対象行数', summary.warningRows],
    ['指摘件数', summary.issueCount]
  ]);
}

実行手順

  1. 元のスプレッドシートをコピーします。
  2. コピー側に「注文確認」シートを作り、見出しと5〜10行程度のサンプルを入力します。
  3. 拡張機能からApps Scriptを開き、コードを貼り付けて保存します。
  4. auditOrderStatusTransitionFreeを選び、実行します。
  5. 初回だけ表示される権限画面を読み、対象のスプレッドシートを確認します。
  6. 実行ログで、確認行数、問題なし行数、確認対象行数、行番号とエラー分類を確認します。
  7. 結果が想定どおりになるまで、実データへは適用しません。

このコードは元の注文行を書き換えません。writeSummaryも初期値はfalseです。集計結果を別シートへ残したい場合だけ、コピー環境でtrueへ変更してください。その場合も、保存するのは件数だけで、注文IDや購入者情報は保存しません。

エラー分類の読み方

  • ORDER_ID_EMPTY: 注文IDが空欄です。
  • ORDER_ID_DUPLICATE: 同じ注文IDが複数行にあります。ログにはIDそのものを出しません。
  • CURRENT_STATUS_INVALID: 現在状態が状態一覧にありません。
  • NEXT_STATUS_INVALID: 次の状態が状態一覧にありません。
  • STATUS_SKIPPED: 状態を2段階以上飛び越そうとしています。
  • STATUS_REVERSED: 前の状態へ戻ろうとしています。
  • STATUS_UNCHANGED: 現在状態と次の状態が同じです。
  • PLANNED_DATE_INVALID: 更新予定日が日付として読めません。

逆戻りを業務上許可している場合は、自動で正常扱いにせず、返品や取消などの専用状態を状態一覧へ追加してください。通常の進行と例外処理を同じ名前で扱わないほうが、あとから理由を追いやすくなります。

失敗しやすい点と制限

状態名の表記ゆれ

「発送済」「発送済み」「発送完了」は別の文字列です。まず運用で使う正式な状態名を決め、allowedStatusesを合わせてください。コードが勝手に表記を変えることはありません。

注文IDの重複

重複確認には注文IDのSHA-256ハッシュをメモリ上で使います。ハッシュや元の注文IDはシートにもログにも保存しません。ただし、注文IDの採番ルールそのものが重複している場合は、業務側で原因を確認する必要があります。

日付とタイムゾーン

更新予定日は、シートの日付型またはYYYY-MM-DD形式を想定します。表示形式だけでなく、スプレッドシート設定のタイムゾーンも確認してください。時刻を含む厳密な締切管理は、この無料例の対象外です。

実行上限

初期設定では最大1000行です。大量データを一度に処理せず、コピー環境で範囲を分けてください。Apps Scriptには実行時間などの上限があります。

自動更新はしない

問題なしと判定された行も、このコードは更新しません。入金や発送の事実確認をせずに状態だけ進めると事故につながるため、確認後の更新は担当者が手動で行う前提です。

公開前チェックリスト

  • コピーしたシートで試した
  • 注文ID、購入者名、住所、商品名をログへ出していない
  • 状態名と順序を自分の運用に合わせた
  • dryRunがtrueになっている
  • writeSummaryがfalseになっている
  • 自動送信、自動削除、自動更新の処理がない
  • 確認対象がmaxRows以内である
  • エラー分類と行番号を見て、手動で元データを確認した

まとめ

注文管理では、更新そのものよりも「いまの状態から次へ進めてよいか」を先に確認することが大切です。dry-runで状態遷移、重複、日付形式を確認しておけば、元データを変更する前に飛び越しや逆戻りへ気づけます。

より多くの注文を、未入金・未発送・売上集計までGoogleシートで整理したい場合は、次の有料Tipsも確認できます。

個人販売の注文・発送管理GAS Proを見る

GasおじのTips商品一覧を見る

※本記事は学習・検証用の例です。入金、配送、返品、会計・税務などの判断は行いません。Google公式・Google公認の商品ではありません。


あなたも記事の投稿・販売を
始めてみませんか?

Tipsなら簡単に記事を販売できます!
登録無料で始められます!

Tipsなら、無料ですぐに記事の販売をはじめることができます Tipsの詳細はこちら
 

この記事の販売者

ツールおじ|GoogleアカウントをAIエージェント化

おう、Gasおじだ。 AIツールとSaaSに散々突っ込んで遠回りした結果、最後に強かったのは「今あるGoogleアカウント」だった。 今ではAIエージェントであらゆるタスクを自動化して無双してるぞ。 GAS、スプレッドシート、フォーム、Gmail、AIエージェントを組み合わせれば、個人でも小さな会社でも、手作業だらけの業務はかなり自動化できる。 ここでは机上のAI論じゃなく、現場で動くテンプレ、コード、手順だけを置いていくぞ。 高いツールを増やす前に、まずお前さんのGoogleアカウントを仕事するエージェントに変えようじゃないか。

この販売者が書いた他の記事

  • Gmailの予約送信ミスを送信前に発見|GASで宛先・日時をdry-run監査

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • スプレッドシートの入力ミスを公開前に発見|GASでルール表をdry-run監査

関連のおすすめ記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化

  • USBメモリで持ち運べるLinux Mint 22.1と永続的な書き込み領域の作成法【Rufus だけで作成する方法を初心者向けに徹底解説】

    ¥500
    1 %獲得
    (5 円相当)
    SmartSenior

    SmartSenior

  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化