NetSuiteで5分で受注させる、ショッピングボット

**ボットシリーズについて**
「NetSuiteで5分」シリーズの三本目の記事です。一本目は[「5分で作るチッャトボット」](https://qiita.com/fujitak/items/7c8c472ccfba9c253e31)、二本目は[「5分で遊ぶ分析ボット」](https://qiita.com/fujitak/items/ff489dcdd59bb85d9d20)、そして今回は、「5分で受注、ショッピングボット」です。前回までの2つのソースコードサンプルでは、2025年1月に日本語でも利用可能になった「N/llm モジュール（NetSuiteのLLM）」を使用し、LLMがテキストを生成してユーザーと表現豊かな対話をするボットを例示しました。

今回は、「NetSuiteカスタマーセンター」が提供する外部ロールを使用して、NetSuiteユーザー企業の顧客から、受注をしてくれるショッピングボットのサンプルを作成してみました。コードの複雑化や、「クリエイティブ」な受注レコードの作成を回避するために、今回はあえてN/llmは使用しませんでした。本稿の目的は、NetSuiteで安心な（NetSuite内で完結できる、でも外部連携も可能） アプリケーションを簡単に作成できること、および、いくつかの便利なモジュールをご紹介することです。シンプルなビジネスシナリオを追求したら、「ぶっきらぼう」なボットに仕上がってしまったので、このボットを「ぶっきらボット」と名付けることにしました。
<br>

**ビジネスシナリオ**
シンプルかつ制御された受注プロセスを対話式で進めます：
•	顧客名を使ったフレンドリーな挨拶
•	商品名と数量の入力受付、その内容のバリデーション
•	NetSuiteの在庫情報を用いた在庫確認
•	確認用メールアドレスの入力受付と、その形式チェック（正規表現を使用）
•	N/record モジュールでの受注レコードの自動作成
•	メール送信 or エラー時の丁寧な終了処理

このシナリオの詳細は、[「Blunt Bot ShoppingJa.docx」](https://docs.google.com/document/d/1FzmTot_qZsVIWfuoV_PYpOmqNy8Yc0Gb/edit?usp=drive_link&ouid=103530670346101039999&rtpof=true&sd=true) として共有しております。スクリプトのディプロイ方法などは1本目の記事、御社アカウント（環境）における顧客IDと売上高の確認は2本目の記事をご参考にして頂けます。今回のアプリのデモに、それらをご利用頂くと便利です。
<br>

**1. 挨拶のあと、商品名と数量の入力を受付ける**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/e753673b-807e-4bdd-943d-22f9c18fe09f.png)
**2. 在庫があれば、確認用メールアドレスの入力を受付**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/ebf63372-a444-42ff-827d-fc45efed9dee.png)
**3. 受注レコードを作成し、メールを送信**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/0433d34b-8d6c-4df8-abd2-e77b5b45e190.png)
**4. 顧客は受注確認メールを受信（もしメール未着の場合は、受注レコードはキャンセル）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/04a31fb1-bc50-4f7f-b386-dc0922060994.png)
**5. 作成された注文書のステータスは承認待ち**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/52adadca-b0cd-4ac4-8918-d13de9e4993e.png)
<br>


**ソースコードの概要**
このボットアプリは Suitelet として構築されており、NetSuite内部で完結するNetSuite標準のAPIモジュールを活用しています。

**モジュールのインポート**
```
define([
　　'N/ui/serverWidget', 　// NetSuiteのUIフォームを操作するためのモジュール
　　'N/runtime',         　// 実行時のスクリプト情報取得モジュール
　　'N/search',          　// 検索モジュール
　　'N/record',          　// レコード操作用モジュール
　　'N/email',           　// メール送信用モジュール
　　'N/log',             　// ログ出力用モジュール
], function (...) { ... });
```
UIの表示、検索処理、レコード作成、メール送信、ログなど、便利な機能をカバーしています。
<br>

**状態管理**
チャットボットによる受注の流れは、明確に定義されたステータスに基づいて制御されます：
```
const BOT_STATE = {
　  　GREETING: 'greeting',	　　　// 初期の挨拶の状態
  　　ITEM_QUERY: 'item_query',	　// アイテム名と数量を尋ねる状態
  　　EMAIL_QUERY: 'email_query',	　// 顧客のメールアドレスを尋ねる状態
  　　ORDER_COMPLETE: 'order_complete',	　// 受注が正常に処理された状態
 　 　ERROR_STATE: 'error_state'	　　　// エラーが発生した場合の状態
};
```
<br>

**ユーザーインタラクション（Suiteletフォーム）**
すべての入力はチャットの対話形式で行うUIを実現しています：
```
form.addField({
	id: "custpage_input",
	type: serverWidget.FieldType.TEXTAREA,
	label: "あなたの返答",
	container: "chat_group"
});
```
隠しフィールドで、会話の進行状況やユーザー入力を保持します。
<br>


**受注レコード（伝票）の作成**
たった数行で、トランザクションを作成することができます：
```
const salesOrder = record.create({ type: record.Type.SALES_ORDER, isDynamic: true });
salesOrder.setValue({ fieldId: 'entity', value: demoCustomer.internalId });
salesOrder.selectNewLine({ sublistId: 'item' });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: itemId });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: quantity });
salesOrder.commitLine({ sublistId: 'item' });
salesOrder.save();
```
応用として、見積からの受注、受注からの出荷（フルフィルメント）など、コンパクトで平易なコードで実現できることが分かります。
<br>

**注文確認メール**
N/email モジュールを使って、受注確認メールを顧客に即時送信します。万が一メールの送信（顧客による受信）が失敗したら、ボットが受注を自動でキャンセルするようにしてみました。
<br>

**エラー処理**
ユーザー入力ミスから在庫不足など、すべてのエラーケースは顧客に会話の最初からやり直ししてもらうことにしました。ボットさんの言い回しは丁寧ですが、ビジネスシナリオのシンプルさを優先したら、ちょっとぶっきらぼうな対応になってしまいました。
<br>

**まとめ**
本稿では、NetSuiteの柔軟性や、多数の便利な標準APIが使用できることをお伝えしました。ぜひ、N/llmやその他のNetSuite APIモジュールまたは外部サービスを使用して、本当にホスピタリティにあふれる、でも正確無比で頼れるボットアプリを、作成してみませんか。ご参考になれば幸いです。
<br>

**参考リンク**
[Oracle NetSuite Help Center > N/search Module](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4345764122.html#subsect_87180423808)
[Oracle NetSuite Help Center > N/record Module](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4267255811.html)
[Alea Technology NetSuiteのカスタマーセンターを使ってみよう](https://aleatech.org/2023/27/customer-center/#:~:text=%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%83%BC%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC%E3%81%A8%E3%81%AF%EF%BC%9F,%E3%81%AEWeb%E3%83%9D%E3%83%BC%E3%82%BF%E3%83%AB%E3%81%A8%E3%81%AA%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82)
<br>

**ソースコード**
```
/**
 * @NApiVersion 2.1
 * @NScriptType Suitelet
 * @NModuleScope SameAccount
 * @description Blunt Bot Shopping - A simplified demo that uses the user’s input for the ordered item.
 */
define([
    'N/ui/serverWidget', // Module for creating and managing NetSuite UI forms.
    'N/runtime',         // Module to access runtime settings and script parameters.
    'N/search',          // Module to perform saved searches in NetSuite.
    'N/record',          // Module for creating, loading, updating, or deleting records.
    'N/email',           // Module to send emails from within NetSuite.
    'N/log',             // Module to log messages for debugging purposes.
    'N/format'           // Module to format data such as numbers and dates.
], function (serverWidget, runtime, search, record, email, log, format) {

    // Demo customer information (hardcoded for this demonstration).
    const demoCustomer = {
        internalId: 223,
        id: "サンリオテック (Sanrio Tech)",
        primaryContact: "Yamada Yukio"
    };

    // Define various states for the chatbot to manage the conversation flow.
    const BOT_STATE = {
        GREETING: 'greeting',          // Initial greeting state.
        ITEM_QUERY: 'item_query',        // State where the bot asks for item name and quantity.
        EMAIL_QUERY: 'email_query',      // State where the bot requests the customer's email.
        ORDER_COMPLETE: 'order_complete',// Terminal state when the order is successfully processed.
        ERROR_STATE: 'error_state'       // Terminal state when an error occurs.
    };

    // Predefined error messages used throughout the script.
    const ERROR_MESSAGES = {
        INVALID_QUANTITY: "有効な数値の数量を入力してください。",
        BLANK_ITEM: "商品名を入力してください。",
        OUT_OF_STOCK: "申し訳ありません、その商品は在庫切れです。",
        INVALID_EMAIL: "申し訳ありません、そのメールアドレスは無効です。",
        EMAIL_DELIVERY_FAILED: "申し訳ありません、メールを送信できませんでした。注文はキャンセルされました。"
    };

    /**
     * Main entry point for the Suitelet.
     * Handles both GET (initial form load) and POST (user submits input) requests.
     */
    function onRequest(context) {
        // Create a new form for the shopping session.
        const form = serverWidget.createForm({ title: "ぶっきらボットショッピング" });
        // Add a field group to hold the conversation history.
        const fieldGroup = form.addFieldGroup({
            id: "chat_group",
            label: "チャット履歴"
        });
        fieldGroup.isSingleColumn = true;
        
        // Retrieve persisted parameters to keep track of the conversation state.
        const state = context.request.parameters.custpage_state || BOT_STATE.GREETING;
        const historySize = parseInt(context.request.parameters.custpage_num_chats || "0");
        const itemId = context.request.parameters.custpage_item_id || '';
        const orderId = context.request.parameters.custpage_order_id || '';
        const quantity = context.request.parameters.custpage_quantity || "";
        // Persist the item name entered by the user.
        const persistedItemName = context.request.parameters.custpage_item_name || "";
        
        // Add hidden fields to the form so that state persists between requests.
        addHiddenField(form, 'custpage_state', state);
        addHiddenField(form, 'custpage_num_chats', historySize.toString());
        if (itemId) addHiddenField(form, 'custpage_item_id', itemId);
        if (orderId) addHiddenField(form, 'custpage_order_id', orderId);
        if (quantity) addHiddenField(form, 'custpage_quantity', quantity);
        if (persistedItemName) addHiddenField(form, 'custpage_item_name', persistedItemName);
        
        // Load the previous chat history into the form.
        loadHistory(context, form, historySize);
        
        // Process the form submission (POST request) or display initial form (GET request).
        if (context.request.method === "POST") {
            const userInput = context.request.parameters.custpage_input || "";
            if (userInput.trim()) {
                // Add the user's message to the chat history.
                addMessageField(form, "custpage_hist" + historySize, "あなた", userInput);
                // Process user input to determine next steps.
                const responseData = processUserInput(userInput, state, itemId, orderId, quantity, persistedItemName);
                // Add the bot's response to the chat history.
                addMessageField(form, "custpage_hist" + (historySize + 1), "ボット", responseData.message);
                // Save any new information from the bot's response.
                if (responseData.itemId) addHiddenField(form, 'custpage_item_id', responseData.itemId);
                if (responseData.orderId) addHiddenField(form, 'custpage_order_id', responseData.orderId);
                if (responseData.quantity) addHiddenField(form, 'custpage_quantity', responseData.quantity.toString());
                if (responseData.itemName) addHiddenField(form, 'custpage_item_name', responseData.itemName);
                // Update the state and chat history count.
                const stateField = form.getField({ id: 'custpage_state' });
                stateField.defaultValue = responseData.nextState;
                const numChatsField = form.getField({ id: 'custpage_num_chats' });
                numChatsField.defaultValue = (historySize + 2).toString();
                // Check if the order process has finished (either complete or error).
                if (responseData.nextState === BOT_STATE.ORDER_COMPLETE || 
                    responseData.nextState === BOT_STATE.ERROR_STATE) {
                    const completionMessage = responseData.nextState === BOT_STATE.ORDER_COMPLETE ?
                        "このショッピングセッションは注文が完了して終了しました。" :
                        "このショッピングセッションは注文が完了せずに終了しました。";
                    form.addField({
                        id: "custpage_shopping_complete",
                        type: serverWidget.FieldType.INLINEHTML,
                        label: " ",
                        container: "chat_group"
                    }).defaultValue = "<p>" + completionMessage + "</p>";
                    // Provide a reset button to start a new order.
                    const scriptId = runtime.getCurrentScript().id;
                    const deploymentId = runtime.getCurrentScript().deploymentId;
                    form.addField({
                        id: "custpage_reset_button",
                        type: serverWidget.FieldType.INLINEHTML,
                        label: " ",
                        container: "chat_group"
                    }).defaultValue = `
                        <div style="margin-top: 15px;">
                            <a href="/app/site/hosting/scriptlet.nl?script=${scriptId}&deploy=${deploymentId}" 
                               style="padding: 8px 16px; background-color: #0066cc; color: white; 
                               border: none; border-radius: 4px; cursor: pointer; font-size: 14px; 
                               text-decoration: none; display: inline-block;">
                                新規注文を開始
                            </a>
                        </div>`;
                } else {
                    // If the conversation is ongoing, add an input field and submit button.
                    addInputField(form);
                    form.addSubmitButton({ label: "送信" });
                }
            } else {
                // No user input provided; simply add the input field again.
                addInputField(form);
                form.addSubmitButton({ label: "送信" });
            }
        } else {
            // Handle initial form load (GET request).
            if (historySize === 0) {
                // On the first load, greet the user and ask for item details.
                const greeting = "こんにちは、" + demoCustomer.internalId + " " + demoCustomer.primaryContact +
                    "さん！当店へようこそ。ご注文されたい商品の名前と数量を教えていただけますか？なお、このデモは一つの商品注文のみ対応しています。";
                addMessageField(form, "custpage_hist0", "ボット", greeting);
                const numChatsField = form.getField({ id: 'custpage_num_chats' });
                numChatsField.defaultValue = "1";
                const stateField = form.getField({ id: 'custpage_state' });
                stateField.defaultValue = BOT_STATE.ITEM_QUERY;
            }
            const finalState = state === BOT_STATE.ORDER_COMPLETE || state === BOT_STATE.ERROR_STATE;
            if (finalState && historySize > 0) {
                const completionMessage = state === BOT_STATE.ORDER_COMPLETE ?
                    "このショッピングセッションは注文が完了して終了しました。" :
                    "このショッピングセッションは注文が完了せずに終了しました。";
                form.addField({
                    id: "custpage_shopping_complete",
                    type: serverWidget.FieldType.INLINEHTML,
                    label: " ",
                    container: "chat_group"
                }).defaultValue = "<p>" + completionMessage + "</p>";
                // Include a reset button to restart the order process.
                const scriptId = runtime.getCurrentScript().id;
                const deploymentId = runtime.getCurrentScript().deploymentId;
                form.addField({
                    id: "custpage_reset_button",
                    type: serverWidget.FieldType.INLINEHTML,
                    label: " ",
                    container: "chat_group"
                }).defaultValue = `
                    <div style="margin-top: 15px;">
                        <a href="/app/site/hosting/scriptlet.nl?script=${scriptId}&deploy=${deploymentId}" 
                           style="padding: 8px 16px; background-color: #0066cc; color: white; 
                           border: none; border-radius: 4px; cursor: pointer; font-size: 14px; 
                           text-decoration: none; display: inline-block;">
                            新規注文を開始
                        </a>
                    </div>`;
            } else {
                // Continue the conversation by adding an input field.
                addInputField(form);
                form.addSubmitButton({ label: "送信" });
            }
        }
        // Render the form as the page response.
        context.response.writePage(form);
    }
    
    /**
     * Processes the user's input based on the current conversation state.
     * Validates input, checks inventory, processes orders, and sends confirmation emails.
     */
    function processUserInput(userInput, currentState, itemId, orderId, quantity, persistedItemName) {
        let result = {
            message: "",
            nextState: currentState,
            itemId: itemId,
            orderId: orderId,
            quantity: quantity,
            itemName: persistedItemName
        };
        try {
            switch (currentState) {
                // If in the GREETING state, greet the customer and ask for item details.
                case BOT_STATE.GREETING:
                    result.message = "こんにちは、" + demoCustomer.internalId + " " + demoCustomer.primaryContact +
                        "さん！当店へようこそ。ご注文されたい商品の名前と数量を教えていただけますか？なお、このデモは一つの商品注文のみ対応しています。";
                    result.nextState = BOT_STATE.ITEM_QUERY;
                    break;
                // Process the item query by parsing the user input.
                case BOT_STATE.ITEM_QUERY: {
                    const orderInfo = parseItemAndQuantity(userInput);
                    if (!orderInfo.itemName) {
                        result.message = ERROR_MESSAGES.BLANK_ITEM;
                        break;
                    }
                    if (isNaN(orderInfo.quantity) || orderInfo.quantity <= 0) {
                        result.message = ERROR_MESSAGES.INVALID_QUANTITY;
                        break;
                    }
                    // Check if the requested item is available in inventory.
                    const inventoryCheck = checkInventory(orderInfo.itemName, orderInfo.quantity);
                    if (!inventoryCheck.available) {
                        result.message = inventoryCheck.message || ERROR_MESSAGES.OUT_OF_STOCK;
                        result.nextState = BOT_STATE.ERROR_STATE;
                        break;
                    }
                    // Item is available; proceed to request the customer's email.
                    result.message = "在庫がございます。注文を完了するために、メールアドレスを教えていただけますか？";
                    result.nextState = BOT_STATE.EMAIL_QUERY;
                    result.itemId = inventoryCheck.itemId;
                    result.itemName = orderInfo.itemName; // Update item name based on user input.
                    result.quantity = orderInfo.quantity;
                    break;
                }
                // Process the email query by validating the email and processing the order.
                case BOT_STATE.EMAIL_QUERY:
                    if (!isValidEmail(userInput)) {
                        result.message = ERROR_MESSAGES.INVALID_EMAIL;
                        result.nextState = BOT_STATE.ERROR_STATE;
                        break;
                    }
                    const userEmail = userInput.trim();
                    // Ensure the item name is persisted.
                    if (!result.itemName) {
                        result.itemName = persistedItemName;
                    }
                    // Process the order by creating a new sales order.
                    const orderResult = processOrder(result.itemId, result.itemName, result.quantity, userEmail);
                    if (!orderResult.success) {
                        result.message = orderResult.message || "注文の処理中にエラーが発生しました。";
                        result.nextState = BOT_STATE.ERROR_STATE;
                        break;
                    }
                    // Send a confirmation email to the customer.
                    const emailResult = sendOrderConfirmation(
                        orderResult.orderId,
                        userEmail,
                        orderResult.itemName,
                        orderResult.quantity,
                        orderResult.currency,
                        orderResult.amount,
                        orderResult.orderNumber
                    );
                    if (!emailResult.success) {
                        result.message = ERROR_MESSAGES.EMAIL_DELIVERY_FAILED;
                        result.nextState = BOT_STATE.ERROR_STATE;
                        // Cancel the order if the email fails to send.
                        cancelOrder(orderResult.orderId);
                        break;
                    }
                    // Confirm the order is complete.
                    result.message = "ありがとうございます！ご注文が確定しました。注文番号は " + 
                        orderResult.orderNumber + " です。確認メールを " + userEmail + " に送信しました。";
                    result.nextState = BOT_STATE.ORDER_COMPLETE;
                    result.orderId = orderResult.orderId;
                    break;
                default:
                    // Handle any unexpected state.
                    result.message = "申し訳ありません、予期せぬエラーが発生しました。ページを更新して再試行してください。";
                    result.nextState = BOT_STATE.ERROR_STATE;
            }
        } catch (e) {
            // Log errors for debugging.
            log.error({ title: 'Error in processUserInput', details: e });
            result.message = "申し訳ありません、予期せぬエラーが発生しました。ページを更新して再試行してください。";
            result.nextState = BOT_STATE.ERROR_STATE;
        }
        return result;
    }
    
    /**
     * Parses the user input to extract the item name and quantity.
     * Assumes that the first set of non-numeric tokens form the item name and the first numeric token is the quantity.
     */
    function parseItemAndQuantity(input) {
        const words = input.trim().split(/\s+/);
        let itemName = "";
        let quantity = 0;
        for (const word of words) {
            if (!isNaN(word) && quantity === 0) {
                quantity = parseInt(word);
            } else {
                itemName += word + " ";
            }
        }
        itemName = itemName.trim();
        if (quantity === 0) quantity = 1; // Default to quantity 1 if none specified.
        log.audit({ title: 'Parsed input', details: 'Item: ' + itemName + ', Quantity: ' + quantity });
        return { itemName, quantity };
    }
    
    /**
     * Checks the inventory for the given item name and quantity.
     * Uses a NetSuite search to validate if the item exists and if there is sufficient stock.
     */
    function checkInventory(itemName, quantity) {
        try {
            const itemSearch = search.create({
                type: search.Type.INVENTORY_ITEM,
                filters: [
                    ['isinactive', 'is', 'F'], // Only consider active items.
                    'and',
                    ['name', 'contains', itemName] // Search for items that contain the input item name.
                ],
                columns: ['internalid', 'quantityavailable']
            });
            const searchResults = itemSearch.run().getRange({ start: 0, end: 1 });
            if (!searchResults || searchResults.length === 0) {
                return { available: false, message: "商品が見つかりません: " + itemName };
            }
            const item = searchResults[0];
            let availableQty = item.getValue('quantityavailable');
            // If quantity is not set, assume a default available stock.
            if (availableQty === null || availableQty === '' || availableQty === undefined) {
                availableQty = 100;
            } else {
                availableQty = Number(availableQty);
            }
            if (availableQty < Number(quantity)) {
                return { available: false, message: "在庫が不足しています。利用可能数: " + availableQty + "個" };
            }
            return { available: true, itemId: item.getValue('internalid') };
        } catch (e) {
            log.error({ title: 'Error in checkInventory', details: e });
            return { available: false, message: "在庫確認中にエラーが発生しました: " + e.message };
        }
    }
    
    /**
     * Validates the provided email address using a regular expression.
     */
    function isValidEmail(email) {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email);
    }
    
    /**
     * Processes the sales order by creating a new Sales Order record in NetSuite.
     * This demonstrates how a transaction can be created with minimal SuiteScript.
     */
    function processOrder(itemId, itemName, quantity, email) {
        try {
            // Create a new dynamic sales order record.
            const salesOrder = record.create({
                type: record.Type.SALES_ORDER,
                isDynamic: true
            });
            // Set the customer for the sales order.
            salesOrder.setValue({ fieldId: 'entity', value: demoCustomer.internalId });
            // Add a memo that includes the customer's email.
            salesOrder.setValue({ fieldId: 'memo', value: "顧客メール: " + email });
            // Add a new item line.
            salesOrder.selectNewLine({ sublistId: 'item' });
            // Set the item using its internal ID.
            salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: itemId });
            // Set the quantity for the item.
            salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: quantity });
            // Commit the item line to the sales order.
            salesOrder.commitLine({ sublistId: 'item' });
            // Save the sales order and retrieve the order ID.
            const orderId = salesOrder.save();
            // Load the saved sales order to extract additional details.
            const soRecord = record.load({ type: record.Type.SALES_ORDER, id: orderId });
            const documentNumber = soRecord.getValue({ fieldId: 'tranid' });
            const currencyText = soRecord.getText({ fieldId: 'currency' });
            const amount = soRecord.getValue({ fieldId: 'total' });
            return {
                success: true,
                orderId: orderId,
                orderNumber: documentNumber,
                itemName: itemName,
                quantity: quantity,
                currency: currencyText,
                amount: amount
            };
        } catch (e) {
            log.error({ title: 'Error in processOrder', details: e });
            return { success: false, message: "注文処理中にエラーが発生しました: " + e.message };
        }
    }
    
    /**
     * Sends an order confirmation email to the customer.
     */
    function sendOrderConfirmation(orderId, emailAddress, itemName, quantity, currency, amount, orderNumber) {
        try {
            // Retrieve the company name from script parameters or use a default.
            const companyName = runtime.getCurrentScript().getParameter({ name: 'custscript_bb_company_name' }) || 'Our Store';
            const formattedAmount = Number(amount).toLocaleString('en-US', { maximumFractionDigits: 0 });
            // Construct the email body with order details.
            const emailBody = 
                "ご注文ありがとうございます！注文番号は " + orderNumber + " です。\n\n" +
                "注文詳細:\n" +
                "顧客: " + demoCustomer.id + " (" + demoCustomer.primaryContact + ")\n" +
                "ご注文商品: " + itemName + "\n" +
                "数量: " + quantity + "\n" +
                "通貨: " + currency + "\n" +
                "合計金額: " + formattedAmount + "\n\n" +
                "できるだけ早くご注文を処理いたします。\n\n" +
                "ご質問がある場合は、カスタマーサポートにお問い合わせください。\n\n" +
                "よろしくお願いいたします。\n" + companyName;
            // Send the email using the email module.
            email.send({
                author: -5,
                recipients: [emailAddress],
                subject: companyName + '：ご注文確認 #' + orderNumber,
                body: emailBody
            });
            return { success: true };
        } catch (e) {
            log.error({ title: 'Error in sendOrderConfirmation', details: e });
            return { success: false, message: "確認メールの送信中にエラーが発生しました: " + e.message };
        }
    }
    
    /**
     * Cancels an order by updating its status.
     */
    function cancelOrder(orderId) {
        try {
            const salesOrder = record.load({ type: record.Type.SALES_ORDER, id: orderId });
            salesOrder.setValue({ fieldId: 'status', value: 'C' });
            salesOrder.save();
            log.audit({ title: 'Order Cancelled', details: 'Order ID: ' + orderId });
        } catch (e) {
            log.error({ title: 'Error in cancelOrder', details: e });
        }
    }
    
    /**
     * Adds a hidden field to the form to persist values across requests.
     */
    function addHiddenField(form, id, value) {
        let field;
        try {
            field = form.getField({ id: id });
        } catch (e) { }
        if (field) {
            field.defaultValue = value;
        } else {
            field = form.addField({ id: id, type: serverWidget.FieldType.TEXT, label: id });
            field.updateDisplayType({ displayType: serverWidget.FieldDisplayType.HIDDEN });
            field.defaultValue = value;
        }
    }
    
    /**
     * Adds an input field to the form where the user can type their response.
     */
    function addInputField(form) {
        const inputField = form.addField({
            id: "custpage_input",
            type: serverWidget.FieldType.TEXTAREA,
            label: "あなたの返答",
            container: "chat_group"
        });
        inputField.setHelpText({ help: "ここに返答を入力してください。" });
    }
    
    /**
     * Loads the chat history from previous requests and displays it on the form.
     */
    function loadHistory(context, form, historySize) {
        const chatHistory = [];
        for (let i = 0; i < historySize; i++) {
            const text = context.request.parameters["custpage_hist" + i] || "";
            const label = i % 2 === 0 ? "ボット" : "あなた";
            addMessageField(form, "custpage_hist" + i, label, text);
            chatHistory.push({ role: label, text: text });
        }
        return chatHistory;
    }
    
    /**
     * Adds a message field to the form to display a single chat message.
     */
    function addMessageField(form, id, label, text) {
        const field = form.addField({
            id: id,
            type: serverWidget.FieldType.TEXTAREA,
            label: label,
            container: "chat_group"
        });
        field.defaultValue = text;
        field.updateDisplayType({ displayType: serverWidget.FieldDisplayType.INLINE });
    }
    
    // Expose the onRequest function as the entry point for the Suitelet.
    return { onRequest: onRequest };
});

```

© 2025 Takusuke Fujii
本記事は CC BY 4.0（原作者名の表記が必要）で自由に共有・改変・配布できますが、無保証につき著作者は一切責任を負いません。