業務の隙間を埋める技術メモ。

「それ、作れるか?」より 「それ、作って大丈夫か?」を考えたい。 業務で“ちゃんと使える”かどうかを、 実際に手を動かして確かめたログを残しています。

SuiteScriptスクリプトタイプ完全ガイド――ClientScript・UserEvent・Scheduled・MapReduce・RESTletをコードで使い分ける

新人教育を仰せつかったのを機に、自分が実際にハマったポイントを意識しながらスクリプトタイプの使い分けを改めて整理してみました。


セクション1:前提――5つのタイプが存在する理由

結論(先に言う)

SuiteScriptのスクリプトタイプは、「誰がいつ何のために呼ぶか」で分かれています。

これを最初に理解していないと、とりあえずUserEventで書いてみて動かなくて困る、という経験を繰り返すことになります。自分はそうでした。

なぜタイプが分かれているのか

NetSuiteのスクリプト実行環境には「ガバナンス」という制約があります。要するに、1回の実行で使えるCPUやメモリのリソースに上限が設けられています。

これはNetSuiteがマルチテナントのクラウドERPである以上、1社のスクリプトが暴走して他のテナントに影響を与えないようにするための仕組みです。合理的ではあるのですが、開発者としてはこの制約と常に戦うことになります。

スクリプトタイプが複数あるのは、用途ごとに実行環境とガバナンスの設計を変えるためです。ユーザーが保存ボタンを押したときに動く処理と、夜中に10万件を処理するバッチとでは、求められる設計がまったく違います。それをタイプで分けている、というのが本質です。

5タイプを「誰が呼ぶか」で整理する

ユーザーのフォーム操作が起点になるのが ClientScript です。フィールドの変更や保存ボタンを押した瞬間など、ユーザーが画面を操作しているリアルタイムに、ブラウザ上で動きます。他の4タイプとは実行場所がまったく異なります。

ユーザーの保存操作が起点になるのが UserEvent です。レコードの保存・編集・削除のタイミングでサーバー側で自動的に発火します。ユーザーが意識せずとも裏で動く性質上、処理は軽くする必要があります。

時刻が起点になるのが Scheduled Script です。毎日深夜に実行する、1時間おきに実行するといった定期処理に使います。ユーザーが関与しないので、多少時間がかかっても問題ありません。

大量データの処理が起点になるのが MapReduce です。Scheduledの上位互換的な位置づけで、NetSuiteが処理を分散して実行してくれるため、Scheduledでは詰まるような件数でも安定して動かせます。

外部システムからの呼び出しが起点になるのが RESTlet です。NetSuite側にAPIエンドポイントを作り、外部から叩けるようにします。kintoneやSlackなど他のサービスと連携するときに使います。


セクション2:比較表と「どれを使うか」の判断フロー

5タイプ比較表

タイプ トリガー ガバナンス上限 同期/非同期 デバッグ場所
ClientScript ユーザーのフォーム操作 なし(ブラウザ依存) 同期 ブラウザのF12コンソール
UserEvent レコードの保存・編集・削除 1,000ユニット 同期 NetSuiteスクリプトログ
Scheduled 時刻・手動・スクリプトから起動 10,000ユニット 非同期 NetSuiteスクリプトログ
MapReduce 時刻・手動・スクリプトから起動 フェーズごとに分散 非同期 NetSuiteスクリプトログ
RESTlet 外部HTTPリクエスト 5,000ユニット 同期 NetSuiteスクリプトログ

「どれを使うか」の判断フロー

迷ったときはこの順番で考えると決まります。

Q1. 外部システムからHTTPで呼ばれる?
    YES → RESTlet

Q2. ユーザーがフォームを操作している最中に動かしたい?
    YES → ClientScript
          (保存後に重い処理が必要なら afterSubmit からScheduled/MRを起動)

Q3. レコードの保存タイミングに連動して動かしたい?
    YES → UserEvent
          (afterSubmitに重い処理を書かない。ScheduledかMRに委譲する)

Q4. 処理対象が数千件以下 or 定期実行でいい?
    YES → Scheduled Script

Q5. 数万件以上 or Scheduledでタイムアウトが出た?
    YES → MapReduce

迷うのは大抵「Q4とQ5のどちらか」です。件数が読めないならMapReduceを選んでおく方が後で書き直す手間がありません。

よくある間違い選択のパターン

UserEventで大量処理を書く

一番多いパターンです。開発環境の少ないデータでは動くので発見が遅れます。本番に上げてから「保存が遅い」「タイムアウトが出る」と言われます。目安として、afterSubmitでsearch.eachを回すような処理はScheduledに切り出すことを検討してください。

Scheduledで件数上限を超える

search.eachは最大4,000件しかループしません。それ以上を処理しようとすると途中で止まります。知らずに「なぜか件数が合わない」と悩むことになります。4,000件を超える可能性があるならMapReduceを使ってください。

RESTletに重い検索を書く

RESTletは外部からのリクエストに対して同期で応答します。処理が重いと外部システム側でタイムアウトが発生します。RESTletはレスポンスを返す責務に徹して、重い処理はScheduled/MapReduceを非同期で起動する設計にするのが安全です。

ClientScriptにサーバー専用モジュールを書く

N/taskN/emailをClientScriptに書いてもエラーになります。ブラウザにはそのモジュール自体が存在しないからです。UserEventで書いていたコードをClientScriptにコピペしたときに起きやすいミスです。


セクション3:ClientScript――フォーム上でリアルタイムに動く唯一のタイプ

結論(先に言う)

ClientScriptは「ブラウザ上で動く」という点で、他の4つとまったく別物です。

UserEventと混同しやすいのですが、実行されるタイミングが根本的に違います。UserEventはサーバー側で動き、保存のタイミングが起点です。ClientScriptはユーザーがフォームを操作している最中、ブラウザ上でリアルタイムに動きます。

「ブラウザで動く」とはどういうことか

NetSuiteの画面を開くとき、裏側では2つの場所でコードが動いています。

サーバーはNetSuiteのデータセンターにあるコンピューターです。UserEvent、Scheduled、MapReduce、RESTletはすべてここで動きます。

クライアントはユーザーが使っているPC、つまりブラウザです。ClientScriptはここで動きます。NetSuiteの画面を開いた瞬間に、スクリプトのコードがブラウザにダウンロードされて実行されます。

[ユーザーのPC(クライアント)]          [NetSuiteサーバー]
  ブラウザ
  ┌─────────────────┐                ┌──────────────────────┐
  │  NetSuiteの画面  │  HTTPリクエスト │  UserEvent           │
  │                  │ ────────────→  │  Scheduled Script    │
  │  ClientScript    │                │  MapReduce           │
  │  ここで動く      │ ←────────────  │  RESTlet             │
  │                  │  レスポンス     │  ここで動く          │
  └─────────────────┘                └──────────────────────┘

使えるモジュールがサーバーと違う

SuiteScriptのモジュールはすべてがClientScriptで使えるわけではありません。

モジュール ClientScript 備考
N/currentRecord クライアント専用。現在のレコードを操作
N/record 一部△ load()submitFields()は使えない
N/search 非同期で呼ぶ必要あり
N/https RESTletを叩くときに使う
N/url URL生成
N/runtime ユーザー情報取得など
N/task サーバー専用
N/file サーバー専用
N/email サーバー専用

4つのエントリーポイント

関数 発火タイミング
pageInit レコードのページを開いたとき
fieldChanged フィールドの値が変更されたとき
saveRecord 保存ボタンを押したとき(サーバー送信前)
validateField フィールドからフォーカスが外れたとき

実装例:フィールド連動と保存前バリデーション

/**
 * @NScriptType ClientScript
 * @NApiVersion 2.1
 */
define(['N/currentRecord', 'N/log'], (currentRecord, log) => {

  const pageInit = (ctx) => {
    log.debug('pageInit', `mode: ${ctx.mode}`);
  };

  const fieldChanged = (ctx) => {
    if (ctx.fieldId !== 'quantity' && ctx.fieldId !== 'rate') return;

    const rec = ctx.currentRecord;
    const qty = rec.getValue({ fieldId: 'quantity' }) || 0;
    const rate = rec.getValue({ fieldId: 'rate' }) || 0;

    rec.setValue({
      fieldId: 'amount',
      value: qty * rate,
      ignoreFieldChange: true // ループ防止:この変更でfieldChangedを再発火させない
    });
  };

  const saveRecord = (ctx) => {
    const rec = ctx.currentRecord;
    const amount = rec.getValue({ fieldId: 'amount' });

    if (!amount || amount <= 0) {
      alert('合計金額が0円です。数量と単価を確認してください。');
      return false; // 保存をキャンセル
    }

    return true;
  };

  return { pageInit, fieldChanged, saveRecord };
});

ハマりポイント3つ

ignoreFieldChange: true を忘れると無限ループになる

fieldChangedの中でsetValueを呼ぶと、その変更が再びfieldChangedを発火させます。ignoreFieldChange: trueを付けないと無限ループが起きてブラウザが固まります。

// NG:fieldChangedが無限に発火する
rec.setValue({ fieldId: 'amount', value: qty * rate });

// OK
rec.setValue({ fieldId: 'amount', value: qty * rate, ignoreFieldChange: true });

② サーバー側のモジュールは使えない

// NG:ClientScriptでは使えない
const rec = record.load({ type: 'salesorder', id: 123 });

// OK:currentRecordで現在開いているレコードを操作する
const rec = ctx.currentRecord;
const val = rec.getValue({ fieldId: 'entity' });

saveRecordでfalseを返しているのに保存される

saveRecord内でエラーが発生してfalseが返る前に例外が飛んでいるケースです。try-catchで明示的にハンドリングしてください。

const saveRecord = (ctx) => {
  try {
    const val = ctx.currentRecord.getValue({ fieldId: 'amount' });
    if (val <= 0) {
      alert('金額を入力してください');
      return false;
    }
    return true;
  } catch (e) {
    log.error('saveRecordエラー', e.message);
    alert('処理中にエラーが発生しました');
    return false;
  }
};

デバッグ方法

ClientScriptのlog.debug()はブラウザのコンソールに出力されます。 NetSuiteの管理画面のスクリプトログには何も出ません。

  • Consoleタブ:log.debug()console.log()の出力を確認
  • Sourcesタブ:ファイルを探してブレークポイントを設定(Ctrl+Pでファイル検索)
  • Networkタブ:RESTletを叩いた際のリクエスト・レスポンスを確認
サーバースクリプト ClientScript
ログの確認場所 NetSuite管理画面のスクリプトログ ブラウザのConsoleタブ(F12)
ブレークポイント NetSuite Script Debugger ブラウザのSourcesタブ
エラーの表示 スクリプトログにスタックトレース ブラウザのConsoleにエラー

サーバーのデータが必要なときはRESTletを叩く

define(['N/https', 'N/url'], (https, url) => {

  const fieldChanged = async (ctx) => {
    if (ctx.fieldId !== 'entity') return;

    const customerId = ctx.currentRecord.getValue({ fieldId: 'entity' });
    if (!customerId) return;

    const restletUrl = url.resolveScript({
      scriptId: 'customscript_customer_restlet',
      deploymentId: 'customdeploy_customer_restlet',
      returnExternalUrl: false
    });

    try {
      const response = https.get({
        url: `${restletUrl}&type=customer&id=${customerId}`,
        headers: { 'Content-Type': 'application/json' }
      });
      const data = JSON.parse(response.body);

      if (data.success) {
        ctx.currentRecord.setValue({
          fieldId: 'custbody_credit_limit',
          value: data.data.creditLimit,
          ignoreFieldChange: true
        });
      }
    } catch (e) {
      log.error('RESTlet呼び出しエラー', e.message);
    }
  };

  return { fieldChanged };
});

UserEventとの使い分けの境界線

ClientScript(fieldChanged / saveRecord)
→ ユーザー体験の改善、入力補助、UI上の即時警告

UserEvent(beforeSubmit)
→ データ整合性の担保、絶対に外せないバリデーション

ClientScriptはAPIアクセスで簡単に迂回できます。「絶対に通ってほしい」バリデーションはbeforeSubmitにも書いておくのが安全です。


セクション4:UserEvent――beforeSubmit / afterSubmit の実装パターン

結論(先に言う)

UserEventで一番やりがちなミスは、afterSubmitに重い処理を書くことです。

「保存後に何かしたい」→「じゃあafterSubmitに書けばいいか」という発想は自然なのですが、これが後でじわじわ効いてきます。最初は動くんです。レコードが少ないうちは。

beforeSubmitとafterSubmitは「目的」が違う

beforeSubmitは、「保存される前にデータに介入したい」ときに使います。バリデーションや、フィールドの自動計算・上書きが主な用途です。このタイミングではまだレコードがDBに保存されていないので、ctx.newRecord.setValue()で値を変更するとそのまま保存に反映されます。

afterSubmitは、「保存が完了した後に何かしたい」ときに使います。関連レコードの作成や、外部サービスへの通知などが典型的な用途です。

/**
 * @NScriptType UserEventScript
 * @NApiVersion 2.1
 */
define(['N/record', 'N/log', 'N/error'], (record, log, error) => {

  const beforeSubmit = (ctx) => {
    const newRec = ctx.newRecord;
    const amount = newRec.getValue({ fieldId: 'amount' });

    if (amount <= 0) {
      throw error.create({
        name: 'INVALID_AMOUNT',
        message: '金額は0より大きい値を入力してください'
      });
    }

    newRec.setValue({ fieldId: 'custbody_checked', value: true });
  };

  const afterSubmit = (ctx) => {
    const savedId = ctx.newRecord.id;
    log.debug('保存完了', `id: ${savedId}`);
  };

  return { beforeSubmit, afterSubmit };
});

やらかした話:afterSubmitで検索をぶん回した

afterSubmitはユーザーが保存ボタンを押してから画面が切り替わるまでの間に同期で実行されます。つまり処理が重いほど、ユーザーは待たされます。

// NG:afterSubmitでこれをやると画面が固まる
const afterSubmit = (ctx) => {
  const customerId = ctx.newRecord.getValue({ fieldId: 'entity' });

  search.create({
    type: 'salesorder',
    filters: [['entity', 'anyof', customerId]]
  }).each((result) => {
    record.submitFields({
      type: record.Type.SALES_ORDER,
      id: result.id,
      values: { custbody_processed: false }
    });
    return true;
  });
};

解決策:重い処理はScheduledに委譲する

define(['N/task', 'N/log'], (task, log) => {

  const afterSubmit = (ctx) => {
    if (ctx.type === ctx.UserEventType.DELETE) return;

    const recId = ctx.newRecord.id;

    const scriptTask = task.create({
      taskType: task.TaskType.SCHEDULED_SCRIPT,
      scriptId: 'customscript_update_related',
      deploymentId: 'customdeploy_update_related',
      params: { custscript_target_id: recId }
    });
    scriptTask.submit();

    log.debug('Scheduled起動', `id: ${recId}`);
  };

  return { afterSubmit };
});

コンテキスト判定でスキップする

const afterSubmit = (ctx) => {
  const type = ctx.type;
  const types = ctx.UserEventType;

  if (type === types.DELETE) return;

  if (type === types.CREATE) {
    log.debug('新規作成', ctx.newRecord.id);
  }

  if (type === types.EDIT) {
    const oldVal = ctx.oldRecord.getValue({ fieldId: 'status' });
    const newVal = ctx.newRecord.getValue({ fieldId: 'status' });

    if (oldVal !== newVal) {
      log.debug('ステータス変更', `${oldVal}${newVal}`);
    }
  }
};

ctx.oldRecordは編集・削除時だけ存在します。新規作成時にアクセスするとエラーになるので、ctx.typeで分岐してから使ってください。

beforeSubmitで値を上書きするときの注意

beforeSubmitでsetValueした値はafterSubmitのctx.newRecordには反映されません。afterSubmitでその値を読みたい場合はrecord.loadで取り直してください。

const beforeSubmit = (ctx) => {
  ctx.newRecord.setValue({ fieldId: 'custbody_flag', value: true });
};

const afterSubmit = (ctx) => {
  // NG:beforeSubmit前の値が返る
  const flag = ctx.newRecord.getValue({ fieldId: 'custbody_flag' }); // false

  // OK:record.loadで取り直す
  const rec = record.load({ type: ctx.newRecord.type, id: ctx.newRecord.id });
  const flagAfterSave = rec.getValue({ fieldId: 'custbody_flag' }); // true
};

セクション5:Scheduled Script――一括処理の基本構造と上限対策

結論(先に言う)

Scheduled Scriptで一番やらかしやすいのは、「件数が増えたら途中で止まる」に気づかないことです。

エラーも出ない。ログも一見正常。でも処理件数が合わない――という状況が起きます。原因のほとんどはsearch.eachの4,000件上限か、ガバナンスポイントの超過です。

基本構造

/**
 * @NScriptType ScheduledScript
 * @NApiVersion 2.1
 */
define(['N/search', 'N/record', 'N/runtime', 'N/log'], (search, record, runtime, log) => {

  const execute = (ctx) => {
    log.audit('開始', `reason: ${ctx.reason}`);

    // 処理本体

    log.audit('完了', '正常終了');
  };

  return { execute };
});

ctx.reasonには起動理由が入ります。スケジュール実行ならSCHEDULED、手動実行ならON_DEMANDです。

落とし穴①:search.each は4,000件で止まる

// 件数が4,000件を超えると途中で止まる(エラーなし)
search.create({
  type: 'salesorder',
  filters: [['status', 'anyof', 'SalesOrd:B']]
}).each((result) => {
  record.submitFields({ ... });
  return true;
});

対策:getRangeでページング処理する

const execute = (ctx) => {
  const mySearch = search.create({
    type: 'salesorder',
    filters: [['status', 'anyof', 'SalesOrd:B']],
    columns: ['internalid', 'entity', 'amount']
  });

  const resultSet = mySearch.run();
  const pageSize = 1000;
  let start = 0;
  let hasMore = true;

  while (hasMore) {
    const results = resultSet.getRange({ start, end: start + pageSize });
    if (!results || results.length === 0) break;

    results.forEach((result) => {
      record.submitFields({
        type: record.Type.SALES_ORDER,
        id: result.id,
        values: { custbody_processed: true }
      });
    });

    log.debug('ページ処理完了', `start: ${start}, 件数: ${results.length}`);

    if (results.length < pageSize) {
      hasMore = false;
    } else {
      start += pageSize;
    }
  }
};

落とし穴②:ガバナンスポイントが足りなくなっても気づかない

対策:残りガバナンスを監視しながら自己再スケジュール

define(['N/search', 'N/record', 'N/runtime', 'N/task', 'N/log'], (search, record, runtime, task, log) => {

  const GOVERNANCE_THRESHOLD = 500;

  const execute = (ctx) => {
    const script = runtime.getCurrentScript();
    const startIndex = parseInt(script.getParameter({ name: 'custscript_start_index' })) || 0;

    const resultSet = search.create({
      type: 'salesorder',
      filters: [['status', 'anyof', 'SalesOrd:B']],
      columns: ['internalid']
    }).run();

    const pageSize = 100;
    let currentIndex = startIndex;
    let rescheduled = false;

    while (true) {
      const remaining = script.getRemainingUsage();
      if (remaining < GOVERNANCE_THRESHOLD) {
        log.audit('ガバナンス不足', `remaining: ${remaining}, 次のstart: ${currentIndex}`);

        task.create({
          taskType: task.TaskType.SCHEDULED_SCRIPT,
          scriptId: script.id,
          deploymentId: script.deploymentId,
          params: { custscript_start_index: currentIndex }
        }).submit();

        rescheduled = true;
        break;
      }

      const results = resultSet.getRange({ start: currentIndex, end: currentIndex + pageSize });
      if (!results || results.length === 0) break;

      results.forEach((result) => {
        record.submitFields({
          type: record.Type.SALES_ORDER,
          id: result.id,
          values: { custbody_processed: true }
        });
      });

      if (results.length < pageSize) break;
      currentIndex += pageSize;
    }

    if (!rescheduled) {
      log.audit('全件処理完了', `total start: ${startIndex}`);
    }
  };

  return { execute };
});

落とし穴③:エラーが起きても気づかない

カスタムレコードで処理状態を記録しておくと、バックグラウンド実行のエラーを見落とさずに済みます。

const STATUS_RECORD_TYPE = 'customrecord_batch_status';

const updateStatus = (statusRecordId, status, message) => {
  record.submitFields({
    type: STATUS_RECORD_TYPE,
    id: statusRecordId,
    values: {
      custrecord_status: status,
      custrecord_last_run: new Date(),
      custrecord_message: message
    }
  });
};

const execute = (ctx) => {
  const script = runtime.getCurrentScript();
  const statusRecordId = script.getParameter({ name: 'custscript_status_record_id' });

  try {
    updateStatus(statusRecordId, '実行中', '処理開始');

    let processedCount = 0;
    search.create({
      type: 'salesorder',
      filters: [['status', 'anyof', 'SalesOrd:B']]
    }).each((result) => {
      record.submitFields({
        type: record.Type.SALES_ORDER,
        id: result.id,
        values: { custbody_processed: true }
      });
      processedCount++;
      return true;
    });

    updateStatus(statusRecordId, '完了', `処理件数: ${processedCount}件`);

  } catch (e) {
    updateStatus(statusRecordId, 'エラー', e.message);
    log.error('エラー発生', e.message);
    throw e;
  }
};

スケジュール設定の注意点

  • 最短実行間隔は15分
  • サンドボックスではスケジュール実行が動かない。開発中は「今すぐ実行」で手動確認する
  • デプロイメントのステータスを定期的に確認する運用を最初から決めておく

セクション6:MapReduce――getInputData / map / reduce / summarize の役割と実装

結論(先に言う)

MapReduceは「大量データをNetSuiteに怒られずに処理するための分散設計」だと思ってください。

4つのフェーズがそれぞれ何を返すかさえ理解すれば、あとは組み立てるだけです。

なぜMapReduceが必要になるのか

Scheduled Scriptは件数が増えるとScript Execution Time Exceededが出ます。自己再スケジュールで対応できますが、コードが複雑になります。MapReduceはNetSuiteが処理を複数のキューに分散してくれるので、Scheduledでは詰まる量でも安定して処理できます。

4フェーズの役割

フェーズ 役割 返すもの
getInputData 処理対象データの取得 SearchオブジェクトまたはArray
map 1件ずつの変換・加工 ctx.write(key, value)
reduce 同じkeyのデータをまとめる ctx.write(key, value)
summarize 全体の後処理・エラー確認 なし(void)

reduceは「グループ集計が必要なとき」だけ使います。単純な1件ずつの処理ならmapだけで完結させてもOKです。

実装例:受注レコードを一括更新する

/**
 * @NScriptType MapReduceScript
 * @NApiVersion 2.1
 */
define(['N/search', 'N/record', 'N/log'], (search, record, log) => {

  const getInputData = (ctx) => {
    return search.create({
      type: search.Type.SALES_ORDER,
      filters: [
        ['status', 'anyof', 'SalesOrd:B'],
        'AND',
        ['mainline', 'is', 'T']
      ],
      columns: ['internalid', 'entity', 'trandate', 'amount']
    });
  };

  const map = (ctx) => {
    const result = JSON.parse(ctx.value);
    const orderId = result.id;
    const customerId = result.values.entity.value;

    record.submitFields({
      type: record.Type.SALES_ORDER,
      id: orderId,
      values: { custbody_processed: true }
    });

    ctx.write({ key: customerId, value: orderId });
  };

  const reduce = (ctx) => {
    const count = ctx.values.length;
    log.audit('顧客別処理件数', `customerId: ${ctx.key}, count: ${count}`);
    ctx.write({ key: ctx.key, value: count });
  };

  const summarize = (ctx) => {
    ctx.mapSummary.errors.each((key, error) => {
      log.error('mapエラー', `key: ${key}, error: ${error}`);
      return true; // 必須:trueを返さないとループが止まる
    });

    ctx.reduceSummary.errors.each((key, error) => {
      log.error('reduceエラー', `key: ${key}, error: ${error}`);
      return true;
    });

    log.audit('MapReduce完了', `inputStage: ${ctx.inputSummary.error || 'OK'}`);
  };

  return { getInputData, map, reduce, summarize };
});

ハマりポイント3つ

summarize.each()でreturn trueを忘れる

// NG:ループが最初の1件で止まる
ctx.mapSummary.errors.each((key, error) => {
  log.error('エラー', error);
  // return忘れ
});

// OK
ctx.mapSummary.errors.each((key, error) => {
  log.error('エラー', error);
  return true; // 必須
});

② getInputDataでArrayを返すと件数上限がある

// 大量データにはNG(50,000件上限)
const getInputData = () => {
  return [{ id: 1 }, { id: 2 }, ...];
};

// OK:Searchオブジェクトをそのままreturn
const getInputData = () => {
  return search.create({ type: 'salesorder', ... });
};

③ reduceを使わなくていいケース

// reduceが不要な場合は定義しなくてもOK
return { getInputData, map, summarize };

セクション7:RESTlet――外部から叩けるAPIエンドポイントの作り方

結論(先に言う)

RESTletは「NetSuite側にAPIの窓口を作る」スクリプトタイプです。

認証まわりで詰まりやすいのですが、2026年時点で認証方式の選択基準が大きく変わっています。OAuth 1.0(TBA)は現時点ではまだ動きますが、NetSuiteが廃止タイムラインを公式に発表しました。新規で作るならOAuth 2.0一択です。なお、以前NetSuiteからkintone APIを叩いたときに401エラーで数時間溶かした話を書いたが、あの記事はTBAベースの実装だった。今から同じ連携を作るならOAuth 2.0で設計してほしいです。

認証方式の現状と廃止タイムライン

時期 TBA(OAuth 1.0)の扱い
現在(2026.1) 既存・新規ともに利用可能
2027.1 新規インテグレーションでの使用不可になる
2027.2以降 既存TBAは継続サポートだが、新機能はOAuth 2.0のみ

基本構造

/**
 * @NScriptType Restlet
 * @NApiVersion 2.1
 */
define(['N/record', 'N/search', 'N/log', 'N/error'], (record, search, log, error) => {

  const get = (params) => {
    if (!params.id) {
      throw error.create({
        name: 'MISSING_PARAM',
        message: 'idパラメータが必要です',
        notifyOff: true
      });
    }

    const rec = record.load({
      type: params.type,
      id: parseInt(params.id)
    });

    return {
      id: rec.id,
      entityId: rec.getValue({ fieldId: 'entityid' }),
      status: rec.getValue({ fieldId: 'status' })
    };
  };

  const post = (body) => {
    const rec = record.create({ type: body.type });
    rec.setValue({ fieldId: 'entityid', value: body.entityId });
    const newId = rec.save();
    return { created: true, id: newId };
  };

  const put = (body) => {
    record.submitFields({
      type: body.type,
      id: parseInt(body.id),
      values: body.values
    });
    return { updated: true, id: body.id };
  };

  const doDelete = (params) => {
    // deleteはJavaScriptの予約語なのでdoDeleteにする
    record.delete({
      type: params.type,
      id: parseInt(params.id)
    });
    return { deleted: true, id: params.id };
  };

  return { get, post, put, 'delete': doDelete };
});

deleteはJavaScriptの予約語なので関数名に使えません。'delete': doDeleteと文字列キーでマッピングします。

OAuth 2.0の設定手順

NetSuite側の設定

1. Setup > Company > Enable Features > SuiteCloud
   → "OAuth 2.0" にチェックを入れる

2. Setup > Integration > Manage Integrations > New
   → Authentication タブで "OAuth 2.0" を選択
   → Grant Type: "Client Credentials (Machine to Machine)"
   → Scope: "RESTlets" にチェック
   → 保存すると Client ID / Client Secret が発行される

3. Setup > Users/Roles > Access Tokens > New
   → OAuth2のアクセストークンを発行
   → 実行ロールを割り当てる

アクセストークンの取得

const getAccessToken = async ({ accountId, clientId, clientSecret }) => {
  const tokenUrl = `https://${accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`;
  const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');

  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${credentials}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: 'grant_type=client_credentials'
  });

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`トークン取得失敗: ${err}`);
  }

  const data = await response.json();
  return data.access_token;
};

RESTletへのリクエスト

const callRestlet = async ({ accountId, scriptId, deployId, accessToken, params }) => {
  const baseUrl = `https://${accountId}.restlets.api.netsuite.com/app/site/hosting/restlet.nl`;
  const url = new URL(baseUrl);
  url.searchParams.set('script', scriptId);
  url.searchParams.set('deploy', deployId);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

  const response = await fetch(url.toString(), {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json'
    }
  });

  if (!response.ok) {
    throw new Error(`RESTlet呼び出し失敗: ${response.status}`);
  }

  return response.json();
};

トークンキャッシュ+自動再取得

class NetSuiteClient {
  constructor({ accountId, clientId, clientSecret }) {
    this.accountId = accountId;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.accessToken = null;
    this.tokenExpiry = null;
  }

  async getToken() {
    const now = Date.now();
    if (this.accessToken && this.tokenExpiry && now < this.tokenExpiry - 5 * 60 * 1000) {
      return this.accessToken;
    }

    const tokenUrl = `https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`;
    const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');

    const res = await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Basic ${credentials}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: 'grant_type=client_credentials'
    });

    const data = await res.json();
    this.accessToken = data.access_token;
    this.tokenExpiry = now + data.expires_in * 1000;
    return this.accessToken;
  }

  async get(scriptId, deployId, params = {}) {
    const token = await this.getToken();
    return callRestlet({ accountId: this.accountId, scriptId, deployId, accessToken: token, params });
  }
}

ハマりポイント:アカウントIDのサブドメインに注意

## トークン取得エンドポイント
https://{accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token

## RESTlet呼び出しエンドポイント
https://{accountId}.restlets.api.netsuite.com/app/site/hosting/restlet.nl

suitetalk.api.netsuite.comrestlets.api.netsuite.comでサブドメインが違います。混在させると404か401になります。


セクション8:まとめ――タイプ選択チートシートと逆引き表

この記事で伝えたかったこと

スクリプトタイプの選択ミスは、「動かない」ではなく「なんか遅い」「なんか件数が合わない」「なんか保存が重い」という形で現れます。エラーメッセージが出ないぶん、原因にたどり着くまでに時間がかかります。

最初に正しいタイプを選ぶだけで、こういったジワジワ系の問題のほとんどは防げます。

タイプ選択チートシート

Q1. 外部システムからHTTPで呼ばれる?
    YES → RESTlet

Q2. ユーザーがフォームを操作している最中に動かしたい?
    YES → ClientScript
          (保存後に重い処理が必要ならafterSubmitからScheduled/MRを起動)

Q3. レコードの保存タイミングに連動して動かしたい?
    YES → UserEvent
          (afterSubmitに重い処理を書かない。ScheduledかMRに委譲する)

Q4. 処理対象が数千件以下 or 定期実行でいい?
    YES → Scheduled Script

Q5. 数万件以上 or Scheduledでタイムアウトが出た?
    YES → MapReduce

5タイプ比較表

タイプ トリガー ガバナンス上限 同期/非同期 デバッグ場所
ClientScript ユーザーのフォーム操作 なし(ブラウザ依存) 同期 ブラウザのF12コンソール
UserEvent レコードの保存・編集・削除 1,000ユニット 同期 NetSuiteスクリプトログ
Scheduled 時刻・手動・スクリプトから起動 10,000ユニット 非同期 NetSuiteスクリプトログ
MapReduce 時刻・手動・スクリプトから起動 フェーズごとに分散 非同期 NetSuiteスクリプトログ
RESTlet 外部HTTPリクエスト 5,000ユニット 同期 NetSuiteスクリプトログ

「この症状が出たら」逆引き表

症状 疑うべき原因 対策
保存後の画面表示が遅い UserEventのafterSubmitに重い処理がある Scheduled/MRに委譲する
処理件数が途中で止まる(エラーなし) search.eachの4,000件上限 getRangeでページング処理に変える
SSS_OPERATION_TOTAL_GOVERNANCE_EXCEEDED ガバナンスポイント超過 自己再スケジュールパターンに変える、またはMRに移行
RESTletが401を返し続ける OAuth設定のミス スコープ・エンドポイントURLのサブドメインを確認
ClientScriptが動かない サーバー専用モジュールを使っている N/currentRecordN/httpsに変える
フィールド変更時にブラウザが固まる fieldChanged内でignoreFieldChangeなしのsetValue ignoreFieldChange: trueを付ける
beforeSubmitでセットした値がafterSubmitで読めない ctx.newRecordはbeforeSubmit前の値を返す afterSubmitでrecord.loadして取り直す
MapReduceのエラーが部分的にしか見えない summarizeの.each()return trueしていない 全エラーループのコールバックにreturn trueを追加

各タイプで「絶対に覚えておくこと」1行まとめ

  • ClientScript:ブラウザで動く。サーバーモジュールは使えない。デバッグはF12
  • UserEvent:afterSubmitに重い処理を書かない。ScheduledかMRに投げる
  • Scheduledsearch.eachは4,000件上限。件数が多いならgetRangeでページング
  • MapReduce:summarizeの.each()return true必須。reduceはオプション
  • RESTletdeleteは予約語。新規はOAuth 2.0で作る(TBAは2027.1で新規不可)

次に読む記事

スクリプトタイプを正しく選んでも、ガバナンスポイントの設計を間違えると本番で詰まります。続きは「NetSuiteのガバナンスポイントで詰まった話――SSS_OPERATION_TOTAL_GOVERNANCE_EXCEEDEDが出たときに読む記事」に書きました。