NetSuite Suitelet + Python (Pyodide) Structure Guide
This guide explains the complete architecture for building NetSuite Suitelets that use Python (via Pyodide) to generate Excel reports.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ SUITELET SCRIPT (.js) │
│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ 1. Query Data │ -> │ 2. Build Form │ -> │ 3. Inject Data + HTML │ │
│ │ (SuiteQL) │ │ (UI Fields) │ │ (INLINEHTML) │ │
│ └─────────────────┘ └──────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ HTML TEMPLATE (.html) │
│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ 1. Display │ │ 2. Download Btn │ │ 3. Pyodide Python │ │
│ │ (Tabulator) │ │ Triggers │ │ Generates Excel │ │
│ └─────────────────┘ └──────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
File Structure
SuiteScripts/
├── your_report/
│ ├── your_report_sl.js # Suitelet script
│ └── your_report.html # HTML template with Python
Part 1: Suitelet Script Structure (.js)
Required Modules
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
*/
define([
'N/ui/serverWidget', // Form building
'N/query', // SuiteQL queries
'N/runtime', // Current user info
'N/file', // Load HTML template
'N/search', // Saved searches (optional)
'N/record' // Record operations (optional)
], (serverWidget, query, runtime, file, search, record) => {
// ... script code
});
Section 1: Data Query Functions
Paginated Query (Handles Large Datasets)
NetSuite limits query results to 5000 rows. This function handles pagination automatically:
/**
* Execute paginated SuiteQL query (handles > 5000 rows)
*/
function execPaginatedQuery(baseQuery) {
let rows = [];
let paginatedRowBegin = 1;
let paginatedRowEnd = 5000;
let queryResults;
baseQuery = baseQuery.trim().replace(/;$/, '');
do {
const paginatedSQL = `
SELECT * FROM (
SELECT a.*, ROWNUM rnum FROM (
${baseQuery}
) a
WHERE ROWNUM <= ${paginatedRowEnd}
)
WHERE rnum >= ${paginatedRowBegin}
`;
queryResults = query.runSuiteQL({
query: paginatedSQL
}).asMappedResults();
rows = rows.concat(queryResults);
paginatedRowBegin += 5000;
paginatedRowEnd += 5000;
} while (queryResults.length === 5000);
return rows;
}
Main Data Query
/**
* Get main report data
*/
function getReportData(params) {
const sqlQuery = `
SELECT
customer.companyname,
transaction.tranid,
transaction.trandate,
transactionline.amount
FROM transaction
INNER JOIN transactionline
ON transaction.id = transactionline.transaction
INNER JOIN customer
ON transaction.entity = customer.id
WHERE transaction.trandate >= TO_DATE('${params.startDate}', 'DD/MM/YYYY')
AND transaction.trandate <= TO_DATE('${params.endDate}', 'DD/MM/YYYY')
AND transactionline.mainline = 'T'
`;
return execPaginatedQuery(sqlQuery);
}
Dropdown Data Queries
/**
* Get accounting periods for dropdown
*/
function getPeriods() {
const sql = `
SELECT id, periodname, startdate, enddate
FROM accountingperiod
WHERE isposting = 'T'
ORDER BY startdate DESC
`;
return query.runSuiteQL({ query: sql }).asMappedResults();
}
/**
* Get locations for dropdown
*/
function getLocations() {
const sql = `
SELECT id, name
FROM location
WHERE isinactive = 'F'
ORDER BY name
`;
return query.runSuiteQL({ query: sql }).asMappedResults();
}
Section 2: Helper Functions
/**
* Parse multi-select parameter (NetSuite uses special delimiter)
*/
function parseMultiSelectParam(param) {
if (!param) return [];
return String(param)
.split('\u0005') // NetSuite multi-select delimiter
.map(s => s.trim())
.filter(s => s.length > 0);
}
/**
* Process/transform data before sending to frontend
*/
function processData(rawData) {
return rawData.map(row => ({
...row,
// Add calculated fields
amount_with_tax: row.amount * 1.11,
// Format dates if needed
formatted_date: row.trandate
}));
}
Section 3: Main Entry Point
const onRequest = (context) => {
try {
// ──────────────────────────────────────────────────────────
// Step 1: Get request parameters
// ──────────────────────────────────────────────────────────
const params = context.request.parameters;
const periodStart = params.custpage_periodstart;
const periodEnd = params.custpage_periodend;
// ──────────────────────────────────────────────────────────
// Step 2: Load dropdown data
// ──────────────────────────────────────────────────────────
const periodData = getPeriods();
// ──────────────────────────────────────────────────────────
// Step 3: Create the form
// ──────────────────────────────────────────────────────────
const form = serverWidget.createForm({
title: 'My Report Title'
});
form.addFieldGroup({
id: 'field_grp_param',
label: 'Parameters'
});
// ──────────────────────────────────────────────────────────
// Step 4: Add form fields
// ──────────────────────────────────────────────────────────
// Period Start dropdown
const fldPeriodStart = form.addField({
id: 'custpage_periodstart',
type: serverWidget.FieldType.SELECT,
label: 'Period Start',
container: 'field_grp_param'
});
fldPeriodStart.isMandatory = true;
fldPeriodStart.addSelectOption({ value: '', text: '' });
periodData.forEach(p => {
fldPeriodStart.addSelectOption({
value: String(p.id),
text: String(p.periodname)
});
});
fldPeriodStart.defaultValue = periodStart;
// ──────────────────────────────────────────────────────────
// Step 5: Add submit button
// ──────────────────────────────────────────────────────────
form.addSubmitButton({
label: 'Generate Report'
});
// ──────────────────────────────────────────────────────────
// Step 6: Add HTML wrapper field
// ──────────────────────────────────────────────────────────
const wrapperField = form.addField({
id: 'custpage_wrapper',
type: serverWidget.FieldType.INLINEHTML,
label: ' ' // Invisible label
});
// ──────────────────────────────────────────────────────────
// Step 7: Handle POST (when form is submitted)
// ──────────────────────────────────────────────────────────
if (context.request.method === 'POST') {
// Get report data
const reportData = getReportData({
startDate: periodData.find(p => p.id == periodStart)?.startdate,
endDate: periodData.find(p => p.id == periodEnd)?.enddate
});
// Process data
const processedData = processData(reportData);
// Prepare data for frontend
const frontendData = {
tableData: processedData,
params: {
periodStart: periodData.find(p => p.id == periodStart)?.periodname,
periodEnd: periodData.find(p => p.id == periodEnd)?.periodname
}
};
// ══════════════════════════════════════════════════════
// CRITICAL: Inject data as JavaScript variable
// ══════════════════════════════════════════════════════
wrapperField.defaultValue = `
<script type="text/javascript">
var reportData = ${JSON.stringify(frontendData)};
</script>
`;
// ══════════════════════════════════════════════════════
// CRITICAL: Load and append HTML template
// ══════════════════════════════════════════════════════
const htmlTemplate = file.load({
id: './your_report.html' // Relative path
}).getContents();
wrapperField.defaultValue += htmlTemplate;
}
// ──────────────────────────────────────────────────────────
// Step 8: Render the page
// ──────────────────────────────────────────────────────────
context.response.writePage(form);
} catch (e) {
log.error('Suitelet Error', e);
}
};
return { onRequest };
Part 2: HTML Template Structure (.html)
Section 1: External Libraries
<!-- Tabulator (for data tables) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabulator-tables/dist/css/tabulator_bootstrap4.min.css" />
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables/dist/js/tabulator.min.js"></script>
<!-- Moment.js (for dates) -->
<script src="https://cdn.jsdelivr.net/npm/moment/moment.min.js"></script>
<!-- Pyodide (Python in browser) -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.1/full/pyodide.js"></script>
Section 2: Page Layout
<style>
.tabulator { font-size: 12px; }
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title {
white-space: normal;
text-overflow: clip;
}
.report-section { margin-top: 20px; }
</style>
<div id="report_container" style="margin-top: 20px;">
<h2 id="report-title">Report Results</h2>
<div id="data-table" class="report-section"></div>
</div>
Section 3: Tabulator Table Setup
// Access data injected from Suitelet
// reportData is defined via: var reportData = ${JSON.stringify(frontendData)};
console.log('Data received:', reportData);
// Initialize Tabulator table
function initTable() {
const table = new Tabulator("#data-table", {
data: reportData.tableData,
layout: "fitDataStretch",
height: "400px",
pagination: "local",
paginationSize: 50,
paginationCounter: "rows",
columns: [
{
title: "Customer",
field: "companyname",
headerHozAlign: "center"
},
{
title: "Amount",
field: "amount",
headerHozAlign: "center",
hozAlign: "right",
formatter: "money",
formatterParams: {
decimal: ",",
thousand: ".",
precision: 0
}
},
],
});
}
initTable();
Section 4: Helper Functions
// Loading overlay
function toggleLoading(show = false) {
if (show) {
const overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
Object.assign(overlay.style, {
position: 'fixed',
top: 0, left: 0,
width: '100%', height: '100%',
background: 'rgba(0,0,0,0.5)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
});
overlay.innerHTML = `
<div style="text-align:center;color:white;font-size:18px;">
<div style="width:40px;height:40px;margin:0 auto 10px;
border:6px solid #fff;border-top-color:transparent;
border-radius:50%;animation:spin 1s linear infinite;">
</div>
Generating Report...
</div>
`;
document.body.appendChild(overlay);
} else {
document.getElementById('loadingOverlay')?.remove();
}
}
// Base64 to ArrayBuffer
function base64ToArrayBuffer(base64) {
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Trigger file download
function downloadFile(filename, bytes) {
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
link.click();
}
Section 5: Pyodide Python Execution
async function downloadReport() {
toggleLoading(true);
try {
// Step 1: Initialize Pyodide
let pyodide = await loadPyodide();
// Step 2: Load required packages
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install('pandas');
await micropip.install('xlsxwriter');
// Step 3: Pass data from JavaScript to Python
window.pythonData = JSON.stringify(reportData.tableData).replaceAll(null, 'null');
window.reportParams = JSON.stringify(reportData.params);
// Step 4: Execute Python code
const bytesData = pyodide.runPython(`
import io, json, base64
import pandas as pd
import xlsxwriter
from js import pythonData, reportParams
data = json.loads(pythonData)
params = json.loads(reportParams)
df = pd.DataFrame(data).fillna(0)
# Create Excel...
bio = io.BytesIO()
workbook = xlsxwriter.Workbook(bio)
# ... formatting and writing ...
workbook.close()
bio.seek(0)
base64.b64encode(bio.getvalue()).decode()
`);
// Step 5: Download the file
const arrayBuffer = base64ToArrayBuffer(bytesData);
downloadFile('Report.xlsx', arrayBuffer);
} catch (error) {
console.error('Error:', error);
alert('Error generating report.');
} finally {
toggleLoading(false);
}
}
Part 3: Complete Flow Diagram
┌─────────────────────────────────────────────────────────────────────────────────┐
│ USER OPENS SUITELET │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ SUITELET (GET Request) │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Load dropdown data (periods, locations) │ │
│ │ 2. Build form with fields │ │
│ │ 3. Display empty form │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ USER SELECTS PARAMETERS & CLICKS SUBMIT │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ SUITELET (POST Request) │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Get parameters from form │ │
│ │ 2. Execute SuiteQL queries │ │
│ │ 3. Process/transform data │ │
│ │ 4. Inject data as JavaScript variable │ │
│ │ 5. Load and append HTML template │ │
│ │ 6. Render page │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ HTML TEMPLATE (Browser) │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. JavaScript reads `reportData` variable │ │
│ │ 2. Tabulator displays data in table │ │
│ │ 3. User can view/filter data │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ USER CLICKS DOWNLOAD BUTTON │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PYODIDE (Python in Browser) │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Load Pyodide + packages (pandas, xlsxwriter) │ │
│ │ 2. Pass JavaScript data to Python │ │
│ │ 3. Python processes data and generates Excel │ │
│ │ 4. Return base64-encoded Excel bytes │ │
│ │ 5. JavaScript triggers file download │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ USER DOWNLOADS EXCEL FILE │
└─────────────────────────────────────────────────────────────────────────────────┘
Part 4: Data Flow Between Components
Suitelet → HTML (Injecting Data)
// In Suitelet (.js):
const data = { tableData: [...], params: {...} };
wrapperField.defaultValue = `
<script>var reportData = ${JSON.stringify(data)};</script>
`;
// In HTML (.html):
// `reportData` is now available as a global JavaScript variable
console.log(reportData.tableData);
console.log(reportData.params);
HTML (JavaScript) → Pyodide (Python)
// In HTML - JavaScript:
window.pythonData = JSON.stringify(reportData.tableData);
window.reportParams = JSON.stringify(reportData.params);
// In Pyodide - Python:
from js import pythonData, reportParams
data = json.loads(pythonData)
params = json.loads(reportParams)
Pyodide (Python) → HTML (JavaScript)
# In Python (last line returns to JavaScript):
base64.b64encode(bio.getvalue()).decode()
// In JavaScript:
const bytesData = pyodide.runPython(`...Python code...`);
// bytesData contains the base64 string returned from Python
Part 5: Key Points Summary
Suitelet Script Must:
- ✅ Define query functions for data retrieval
- ✅ Create form with parameter fields
- ✅ Handle both GET (display form) and POST (process data)
- ✅ Inject data using
<script>var data = ${JSON.stringify(...)}</script> - ✅ Load HTML template using
file.load()
HTML Template Must:
- ✅ Include required CDN libraries (Tabulator, Pyodide, etc.)
- ✅ Access injected data via global variable
- ✅ Initialize table/visualization
- ✅ Provide download button
- ✅ Pass data to Python using
window.variableName - ✅ Handle Python return value (base64 bytes)
Python (Pyodide) Must:
- ✅ Import from
jsto get JavaScript variables - ✅ Parse JSON data with
json.loads() - ✅ Handle nulls with
fillna() - ✅ Return base64-encoded bytes as last expression
Quick Reference: File Checklist
□ Suitelet Script (.js)
□ @NApiVersion 2.1
□ @NScriptType Suitelet
□ Required modules imported
□ Query functions defined
□ Form created with fields
□ POST handler with data injection
□ HTML template loaded
□ HTML Template (.html)
□ CDN libraries included
□ CSS styles defined
□ Container divs for tables
□ Tabulator initialization
□ Helper functions (loading, download)
□ Pyodide execution function
□ Event listener for download button