社内から「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)も使える、という点で保存検索とは設計思想が根本的に違います。
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にログイン済みのユーザーが画面として使う
今回作るものの全体構成
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 | 用途 |
|---|---|
TEXT | 1行テキスト |
TEXTAREA | 複数行テキスト |
INTEGER | 整数 |
DATE | 日付ピッカー |
SELECT | ドロップダウン |
CHECKBOX | チェックボックス |
INLINEHTML | HTMLをそのまま埋め込む |
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, '&').replace(/</g, '<').replace(/>/g, '>');
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へのデプロイ手順
スクリプトレコードの作成
Script Type:
Suitelet / Name: SuiteQL Exporter / ID: customscript_suiteql_exporterID:
customdeploy_suiteql_exporter / Status: ReleasedDEBUG(開発中)/ ERROR(本番) / Audience: 使わせたいロールにチェックアクセスURLの確認
?script=customscript_suiteql_exporter
&deploy=customdeploy_suiteql_exporter
必要なロール権限
| 権限カテゴリ | 権限名 | レベル |
|---|---|---|
| Setup | SuiteScript | Full |
| Reports | SuiteAnalytics Workbook | View(SuiteQL実行に必要) |
| Transactions | Sales Order など対象レコード | View |
INSUFFICIENT_PERMISSIONエラーが出ます。権限エラーが出たらまずここを確認してください。
SECTION 08ハマったポイントまとめ
SuiteQLのテーブル名は「内部名」で書く
FROM "Sales Order" sFROM transaction t
WHERE t.type = 'SalesOrd'テーブル名が分からないときはNetSuite公式の「Records Catalog」(Setup > Records Catalog)か、SuiteAnalytics Workbenchで確認するのが確実です。
CSVにBOMを付けないと日本語が文字化けする
contents: contentcontents: '\uFEFF' + contentasMappedResults()のキーは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を呼んではいけない
res.writeFile({ file: f, isInline: false });
res.write({ output: 'done' }); // Errorres.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/search | SuiteQL | |
|---|---|---|---|
| 設定方法 | UIで1つずつ | コードで記述 | SQLで記述 |
| フィールド追加 | 毎回プルダウン | コード変更 | SELECT句に足すだけ |
| JOIN | NetSuite許可範囲のみ | 不可 | 任意の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実行に必要。権限エラーが出たらここを確認