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

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

SuiteQLをSuiteletで動かしてExcelで受け取る――クエリ画面からスプレッドシートDLまでフルコード解説

SuiteQLをSuiteletで動かしてExcelで受け取る――クエリ画面からスプレッドシートDLまでフルコード解説

社内から「NetSuiteのデータをExcelで欲しい」という要望が来るたびに、最初は保存検索を案内していた時期があった。でもそのたびに「またUI画面でフィールドを1個ずつプルダウンから選ばないといけない」「顧客テーブルと受注テーブルをまたいだ集計はやっぱりできない」という壁にぶつかった。保存検索はNetSuiteのノーコードな強みではあるのだが、JOINの自由度がなく副問合せも書けない以上、複雑なデータ抽出には向いていない。

そこでSuiteQLとSuiteletを組み合わせて「SQLを入力してボタンを押すとExcelが落ちてくる画面」を作った。SQLさえ書ければ保存検索では不可能だった集計やJOINが普通に動く。この記事はそのときに詰まったポイントを整理したものです。

SECTION 01前提――SuiteQLとSuiteletの位置づけ

SuiteQLとは何か

SuiteQLは、NetSuiteのデータをSQLライクな構文で取得できるクエリ言語です。2020年頃から使えるようになりました。

まず保存検索(Saved Search)と比較すると、痛みポイントが分かりやすいです。

保存検索の弱点
  • UIで1項目ずつ設定しないといけない。フィールドを追加するたびにプルダウンから選択して保存して確認、を繰り返す必要がある。カラムが20個あれば20回選ぶ。バージョン管理もできない。
  • JOINに限界がある。NetSuiteが用意した「結合」の範囲内でしか横断できない。自分でJOIN条件を書く自由度はない。
  • 副問合せが書けない。「この受注の中で最新の1件だけ」「平均を超えた行だけ」といった条件は、保存検索の仕組みでは表現できない。

SuiteQLはこの3つをすべて解決します。SQLを知っていれば直感的に書ける・任意のJOINが書ける・副問合せも集計関数(COUNT、SUM、GROUP BY)も使える、という点で保存検索とは設計思想が根本的に違います。

⚠ 副問合せの制約:SuiteQLで副問合せ自体は書けますが、WITH句(CTE)はサポートされていません。複雑なクエリをCTEで整理しようとすると構文エラーになります。また副問合せが深くなるとタイムアウトのリスクもあるため、重い処理は複数クエリに分割する設計を検討してください。
SQL-- N/searchでは難しいJOINとGROUP BYをSuiteQLなら普通に書ける
SELECT
  c.companyname AS 顧客名,
  COUNT(t.id) AS 受注件数,
  SUM(t.foreigntotal) AS 合計金額
FROM transaction t
INNER JOIN customer c ON t.entity = c.id
WHERE t.type = 'SalesOrd'
  AND t.trandate >= '2025-01-01'
GROUP BY c.companyname
ORDER BY SUM(t.foreigntotal) DESC

Suiteletとは何か

Suiteletは「NetSuiteの中にWebページを作れる」スクリプトタイプです。RESTletが「外部からHTTPで叩けるAPIエンドポイント」なのに対して、Suiteletは「NetSuiteにログインしたユーザーが直接ブラウザでアクセスできる画面」です。

TEXTRESTlet  → 外部システムからAPIとして叩く
Suitelet → NetSuiteにログイン済みのユーザーが画面として使う

今回作るものの全体構成

SuiteQL Excelエクスポーター 全体構成図 ユーザーのブラウザからSuiteletへGET/POSTリクエストを送り、SuiteletがN/query経由でNetSuiteデータベースにSuiteQLを実行し、Excelファイルを返す構成図 ユーザー (ブラウザ) Suitelet GET HTMLフォームを返す POST Excelを返す onRequest() N/query runSuiteQL() NetSuite DB transactionなど ① GET ② フォーム ③ POST(SQLを送信) SuiteQL 結果 ④ Excel

SECTION 02SuiteQLの基礎――N/queryモジュールの使い方

実行方法は2つある

SuiteQLを実行するモジュールはN/queryです。実行関数は2種類あります。

関数返すもの向いているケース
query.runSuiteQL({ query })ResultSetオブジェクト件数が少ない・全件取得でOK
query.runSuiteQLPaged({ query, pageSize })PagedDataオブジェクト件数が多い・ページング処理が必要

基本的な書き方

JavaScriptdefine(['N/query', 'N/log'], (query, log) => {

  const execute = () => {
    // runSuiteQL:全件取得(上限5,000件)
    const resultSet = query.runSuiteQL({
      query: `
        SELECT
          t.id,
          t.tranid AS 伝票番号,
          t.trandate AS 取引日,
          t.foreigntotal AS 金額,
          c.companyname AS 顧客名
        FROM transaction t
        INNER JOIN customer c ON t.entity = c.id
        WHERE t.type = 'SalesOrd'
          AND t.trandate >= '2025-01-01'
        ORDER BY t.trandate DESC
      `
    });

    // asMappedResults()でオブジェクト配列に変換(AS句の名前がキーになる)
    const mapped = resultSet.asMappedResults();
    log.debug('件数', mapped.length);
    log.debug('1件目', JSON.stringify(mapped[0]));
  };

  return { execute };
});

ページング処理が必要なとき

runSuiteQLの上限は5,000件です。それ以上あるならページング処理が必要になります。

JavaScriptconst fetchAllRows = (sql) => {
  const allRows = [];
  const pagedData = query.runSuiteQLPaged({
    query: sql,
    pageSize: 1000  // 最大1,000件/ページ
  });

  pagedData.pageRanges.forEach((pageRange) => {
    const page = pagedData.fetch({ index: pageRange.index });
    const rows = page.data.asMappedResults();
    allRows.push(...rows);
  });

  return allRows;
};

SuiteQLで詰まりやすいポイント

テーブル名はNetSuiteのスキーマ名で書く

Saved Searchのレコードタイプ名とSuiteQLのテーブル名は一致しません。

Saved Searchでの名前SuiteQLでのテーブル名
販売注文(Sales Order)transaction
顧客customer
品目item
従業員employee
取引明細行transactionline

受注レコードを取るのにsalesorderテーブルは存在しません。transactionテーブルを使ってWHERE type = 'SalesOrd'で絞ります。

カスタムフィールドの書き方

SQLSELECT
  t.id,
  t.custbody_myfield AS カスタムフィールド値
FROM transaction t
WHERE t.type = 'SalesOrd'

日付の書き方・NULLの扱い

SQL-- 文字列で書く(YYYY-MM-DD形式)
WHERE t.trandate >= '2025-01-01'
  AND t.trandate < '2026-01-01'

-- COALESCEでNULLをデフォルト値に変換
SELECT COALESCE(t.memo, '(メモなし)') AS メモ FROM transaction t

SECTION 03Suiteletの基礎――GETとPOSTを使い分ける

エントリーポイントはonRequestひとつだけ

SuiteletのエントリーポイントはonRequest関数のみです。GETとPOSTの分岐はコードの中で行います。

JavaScript/**
 * @NScriptType Suitelet
 * @NApiVersion 2.1
 */
define(['N/ui/serverWidget', 'N/log'], (serverWidget, log) => {

  const onRequest = (ctx) => {
    const req = ctx.request;
    const res = ctx.response;

    if (req.method === 'GET') {
      handleGet(req, res);   // HTMLフォームを返す
    } else if (req.method === 'POST') {
      handlePost(req, res);  // 処理してExcelを返す
    }
  };

  return { onRequest };
});

レスポンスの種類

JavaScript// ① HTMLページ(N/ui/serverWidgetで組んだフォーム)を返す
res.writePage(form);

// ② HTML文字列を直接返す
res.write({ output: '<html><body>Hello</body></html>' });

// ③ ファイル(Excel、CSVなど)を返す
res.writeFile({ file: myFile, isInline: false });
// isInline: false → ダウンロードダイアログが出る
// isInline: true  → ブラウザ内で表示しようとする

SECTION 04フォーム画面を作る――N/ui/serverWidget

フォームの構造

JavaScriptdefine(['N/ui/serverWidget'], (serverWidget) => {

  const buildForm = () => {
    const form = serverWidget.createForm({ title: 'SuiteQL Excelエクスポート' });

    form.addFieldGroup({ id: 'custpage_group_query', label: 'クエリ設定' });

    // テキストエリア(SQL入力欄)
    const sqlField = form.addField({
      id: 'custpage_sql',
      type: serverWidget.FieldType.TEXTAREA,
      label: 'SuiteQL',
      container: 'custpage_group_query'
    });
    sqlField.defaultValue = `SELECT t.id, t.tranid AS 伝票番号 FROM transaction t ...`;
    sqlField.updateDisplaySize({ height: 10, width: 80 });

    // セレクトボックス(ファイル形式)
    const formatField = form.addField({
      id: 'custpage_format',
      type: serverWidget.FieldType.SELECT,
      label: 'ファイル形式',
      container: 'custpage_group_query'
    });
    formatField.addSelectOption({ value: 'xls', text: 'Excel (XML Spreadsheet)' });
    formatField.addSelectOption({ value: 'csv', text: 'CSV' });

    form.addSubmitButton({ label: 'Excelをダウンロード' });
    return form;
  };

  return { buildForm };
});

使えるFieldTypeのよく使うもの

FieldType用途
TEXT1行テキスト
TEXTAREA複数行テキスト
INTEGER整数
DATE日付ピッカー
SELECTドロップダウン
CHECKBOXチェックボックス
INLINEHTMLHTMLをそのまま埋め込む

SECTION 05ExcelファイルをN/fileで生成する

Excelを生成する方法は2つある

アプローチ向いているケース備考
N/fileでCSVを生成簡単・高速・大量データスタイリングなし
XMLSpreadsheet形式書式や見出しに色をつけたいxlsとして開ける

CSV生成(BOM付き)

JavaScriptconst buildCsv = (columns, rows, filename) => {
  const header = columns.map(quoteCsvValue).join(',');
  const lines  = rows.map((row) =>
    columns.map((col) => quoteCsvValue(row[col] ?? '')).join(',')
  );
  const content = [header, ...lines].join('\r\n');

  return file.create({
    name: filename,
    fileType: file.Type.CSV,
    contents: '\uFEFF' + content  // BOM付き:Excelで開いたとき文字化けしない
  });
};

XML Spreadsheet生成(ヘッダー色付き)

JavaScriptconst buildXmlSpreadsheet = (columns, rows, filename) => {
  const headerCells = columns
    .map((col) => `<Cell ss:StyleID="header"><Data ss:Type="String">${escapeXml(col)}</Data></Cell>`)
    .join('');

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
  xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
  <Styles>
    <Style ss:ID="header">
      <Interior ss:Color="#4472C4" ss:Pattern="Solid"/>
      <Font ss:Bold="1" ss:Color="#FFFFFF"/>
    </Style>
  </Styles>
  <Worksheet ss:Name="Sheet1">
    <Table>
      <Row>${headerCells}</Row>
      ${dataRows}
    </Table>
  </Worksheet>
</Workbook>`;

  return file.create({ name: filename, fileType: file.Type.PLAINTEXT, contents: xml });
};

SECTION 06完成版のフルコード

ファイル構成:SuiteScripts/suiteql_exporter/sl_suiteql_exporter.js

JavaScript/**
 * SuiteQL Excelエクスポーター
 * @NScriptType Suitelet
 * @NApiVersion 2.1
 */
define([
  'N/ui/serverWidget', 'N/query', 'N/file', 'N/log', 'N/error'
], (serverWidget, query, file, log, error) => {

  const onRequest = (ctx) => {
    try {
      if (ctx.request.method === 'GET') handleGet(ctx.request, ctx.response);
      else handlePost(ctx.request, ctx.response);
    } catch (e) {
      log.error('onRequest error', `${e.name}: ${e.message}`);
      ctx.response.write({ output: `<html><body><h2>エラー</h2><pre>${e.message}</pre></body></html>` });
    }
  };

  const handleGet = (req, res) => {
    const form = serverWidget.createForm({ title: 'SuiteQL Excelエクスポーター' });
    form.addFieldGroup({ id: 'custpage_grp_query', label: 'クエリ設定' });

    const sqlField = form.addField({
      id: 'custpage_sql', type: serverWidget.FieldType.TEXTAREA,
      label: 'SuiteQL', container: 'custpage_grp_query'
    });
    sqlField.defaultValue = getDefaultSql();
    sqlField.updateDisplaySize({ height: 12, width: 100 });

    const formatField = form.addField({
      id: 'custpage_format', type: serverWidget.FieldType.SELECT,
      label: 'ファイル形式', container: 'custpage_grp_query'
    });
    formatField.addSelectOption({ value: 'xls', text: 'Excel (XML Spreadsheet)' });
    formatField.addSelectOption({ value: 'csv', text: 'CSV' });
    formatField.defaultValue = 'xls';

    const filenameField = form.addField({
      id: 'custpage_filename', type: serverWidget.FieldType.TEXT,
      label: 'ファイル名(拡張子なし)', container: 'custpage_grp_query'
    });
    filenameField.defaultValue = `export_${formatDate(new Date())}`;

    const pageSizeField = form.addField({
      id: 'custpage_pagesize', type: serverWidget.FieldType.INTEGER,
      label: '最大取得件数(上限5,000件)', container: 'custpage_grp_query'
    });
    pageSizeField.defaultValue = 1000;

    form.addSubmitButton({ label: 'ダウンロード' });
    res.writePage(form);
  };

  const handlePost = (req, res) => {
    const params   = req.parameters;
    const sql      = params.custpage_sql;
    const format   = params.custpage_format || 'xls';
    const filename = (params.custpage_filename || 'export').replace(/[\\/:*?"<>|]/g, '_');
    const maxRows  = Math.min(parseInt(params.custpage_pagesize) || 1000, 5000);

    if (!sql || !sql.trim()) throw error.create({ name: 'MISSING_SQL', message: 'SQLを入力してください' });

    log.audit('SuiteQL実行', sql.substring(0, 200));
    const { columns, rows } = runSuiteQL(sql, maxRows);
    log.audit('取得件数', rows.length);

    const outputFile = format === 'csv'
      ? buildCsv(columns, rows, `${filename}.csv`)
      : buildXmlSpreadsheet(columns, rows, `${filename}.xls`);

    res.writeFile({ file: outputFile, isInline: false });
  };

  const runSuiteQL = (sql, maxRows) => {
    let resultSet;
    try {
      resultSet = query.runSuiteQL({ query: sql });
    } catch (e) {
      throw error.create({ name: 'SUITEQL_ERROR', message: `SQLエラー: ${e.message}` });
    }
    const mapped  = resultSet.asMappedResults();
    const columns = mapped.length > 0 ? Object.keys(mapped[0]) : [];
    return { columns, rows: mapped.slice(0, maxRows) };
  };

  const buildCsv = (columns, rows, filename) => {
    const quoteCsvValue = (val) => {
      const str = String(val ?? '').replace(/"/g, '""');
      return /[,"\r\n]/.test(str) ? `"${str}"` : str;
    };
    const header  = columns.map(quoteCsvValue).join(',');
    const lines   = rows.map((row) => columns.map((col) => quoteCsvValue(row[col] ?? '')).join(','));
    return file.create({ name: filename, fileType: file.Type.CSV, contents: '\uFEFF' + [header, ...lines].join('\r\n') });
  };

  const buildXmlSpreadsheet = (columns, rows, filename) => {
    const escapeXml = (str) => String(str)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    const headerCells = columns
      .map((col) => `<Cell ss:StyleID="header"><Data ss:Type="String">${escapeXml(col)}</Data></Cell>`)
      .join('');
    const dataRows = rows.map((row) => {
      const cells = columns.map((col) => {
        const val  = row[col] ?? '';
        const type = typeof val === 'number' ? 'Number' : 'String';
        return `<Cell><Data ss:Type="${type}">${escapeXml(String(val))}</Data></Cell>`;
      }).join('');
      return `<Row>${cells}</Row>`;
    }).join('\n');
    const xml = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
  xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
  <Styles>
    <Style ss:ID="header">
      <Interior ss:Color="#4472C4" ss:Pattern="Solid"/>
      <Font ss:Bold="1" ss:Color="#FFFFFF"/>
    </Style>
  </Styles>
  <Worksheet ss:Name="Sheet1">
    <Table><Row>${headerCells}</Row>${dataRows}</Table>
  </Worksheet>
</Workbook>`;
    return file.create({ name: filename, fileType: file.Type.PLAINTEXT, contents: xml });
  };

  const formatDate = (date) => {
    const y = date.getFullYear();
    const m = String(date.getMonth() + 1).padStart(2, '0');
    const d = String(date.getDate()).padStart(2, '0');
    return `${y}${m}${d}`;
  };

  const getDefaultSql = () =>
    `SELECT t.id, t.tranid AS 伝票番号, t.trandate AS 取引日,
  c.companyname AS 顧客名, t.foreigntotal AS 合計金額
FROM transaction t
INNER JOIN customer c ON t.entity = c.id
WHERE t.type = 'SalesOrd'
  AND t.trandate >= '2025-01-01'
ORDER BY t.trandate DESC`;

  return { onRequest };
});

SECTION 07NetSuiteへのデプロイ手順

スクリプトレコードの作成

1
Customization > Scripting > Scripts > New
Script Type: Suitelet / Name: SuiteQL Exporter / ID: customscript_suiteql_exporter
2
Deployments タブ → New Deployment
ID: customdeploy_suiteql_exporter / Status: Released
3
Log Level: DEBUG(開発中)/ ERROR(本番) / Audience: 使わせたいロールにチェック

アクセスURLの確認

https://{accountId}.app.netsuite.com/app/site/hosting/scriptlet.nl
  ?script=customscript_suiteql_exporter
  &deploy=customdeploy_suiteql_exporter

必要なロール権限

権限カテゴリ権限名レベル
SetupSuiteScriptFull
ReportsSuiteAnalytics WorkbookView(SuiteQL実行に必要)
TransactionsSales Order など対象レコードView
「SuiteAnalytics Workbook」権限が抜けているとINSUFFICIENT_PERMISSIONエラーが出ます。権限エラーが出たらまずここを確認してください。

SECTION 08ハマったポイントまとめ

SuiteQLのテーブル名は「内部名」で書く

NG
FROM "Sales Order" s
OK
FROM transaction t
WHERE t.type = 'SalesOrd'

テーブル名が分からないときはNetSuite公式の「Records Catalog」(Setup > Records Catalog)か、SuiteAnalytics Workbenchで確認するのが確実です。

CSVにBOMを付けないと日本語が文字化けする

NG
contents: content
OK
contents: '\uFEFF' + content

asMappedResults()のキーはSQLのAS句で変わる

JavaScript// SQL: SELECT t.id, t.tranid AS 伝票番号 ...
const rows = resultSet.asMappedResults();
log.debug(rows[0]['伝票番号']); // OK
log.debug(rows[0]['tranid']);   // undefined(AS句を付けた場合)

日本語AS句を使うとObject.keys()でそのままExcelのヘッダーにできるので便利です。

res.writeFileの後にres.writeを呼んではいけない

NG
res.writeFile({ file: f, isInline: false });
res.write({ output: 'done' }); // Error
OK
res.writeFile({ file: f, isInline: false });
// writeFileで完結させる

Suiteletのログは「スクリプトログ」で確認する

Suiteletはサーバー側で動くので、log.debug()の出力はNetSuiteの管理画面(Customization > Scripting > Script Execution Log)に出ます。ブラウザのF12コンソールには出ません。開発中はlog.audit()を積極的に使うとフィルタしやすくなります。

件数が多いときはrunSuiteQLPagedを使う

JavaScriptconst pagedData = query.runSuiteQLPaged({ query: sql, pageSize: 1000 });
const allRows = [];
pagedData.pageRanges.forEach((range) => {
  const page = pagedData.fetch({ index: range.index });
  allRows.push(...page.data.asMappedResults());
});

SECTION 09よくある質問

Q. 外部のユーザーにも使わせたい

Suiteletはデフォルトでログイン必須です。Deploymentレコードの「Available Without Login」をチェックすると認証なしでアクセスできるようになりますが、誰でもアクセスできる状態になるのでデータの内容に応じて慎重に判断してください。

Q. 実行したSQLをログに残したい

SuiteQLはNetSuiteのAudit Logには記録されません。実行SQLをカスタムレコードに書き込む形でログを残す必要があります。

JavaScriptrecord.create({ type: 'customrecord_sql_log' })
  .setValue({ fieldId: 'custrecord_sql', value: sql })
  .setValue({ fieldId: 'custrecord_executed_at', value: new Date() })
  .setValue({ fieldId: 'custrecord_rows', value: rows.length })
  .save();

Q. 保存検索・N/search・SuiteQLはどれを使うべきか

保存検索N/searchSuiteQL
設定方法UIで1つずつコードで記述SQLで記述
フィールド追加毎回プルダウンコード変更SELECT句に足すだけ
JOINNetSuite許可範囲のみ不可任意のJOIN
副問合せ不可不可書ける(CTE不可・深いと重い)
GROUP BY / 集計制限あり制限あり自由に書ける
件数上限制限あり4,000件Pagedは無制限
バージョン管理不可Git管理可Git管理可
学習コスト低い独自構文が必要SQLを知っていれば即戦力
副問合せの注意:IN (SELECT ...)やスカラー副問合せは動きますが、WITH句(CTE)は非対応です。複雑になるとタイムアウトが発生しやすいため、重い場合は複数クエリに分割してSuiteScript側で結合する設計を検討してください。

まとめ今回の実装で覚えておきたいこと

この記事で作ったSuiteletは、SQLを書き換えるだけで様々なレコードタイプに流用できます。一度テンプレートとして整備しておくと、「あのデータをExcelで出してほしい」という依頼にSQLを書くだけで対応できるようになります。

  • SuiteQL:JOINとGROUP BYが書けるSQL。テーブル名はNetSuiteの内部名を使う。CTE(WITH句)は非対応
  • Suitelet:エントリーポイントはonRequestひとつ。GETとPOSTをコードで分岐させる
  • ファイルのダウンロードres.writeFile({ file, isInline: false })で実現できる
  • CSVはBOM付きで'\uFEFF'を先頭に付けないと日本語環境のExcelで文字化けする
  • SuiteAnalytics Workbook権限:SuiteQL実行に必要。権限エラーが出たらここを確認