NetSuite库存盘点接口探讨及WMS系统对接实例

NetSuite库存盘点接口探讨及WMS系统对接实例, 思路探讨文章不涉及商业应用或客户信息

有什么用

了解NetSuite库存盘点接口, 以及WMS系统对接实例

学习NetSuite如果通过Restlet进行接口类设计和开发

已提前隐去客户的私人信息, 本文内容不针对任何特定客户, 本文只是思路的探讨, 如有侵权, 请联系作者删除

怎么用

把两个实例代码在NetSuite中创建脚本记录, 配置Integration Record, 让RestLet可以与外界通信

抛砖引玉, 展开思路; 这一类的接口设计(或系统对接, 拓展NetSuite或外部系统功能)都可以引用参考

下面的实现方法已局限于特定的应用(库存盘点), 难免会局限思维, 仅可作为一个特定的实例, 现实当中可以搭配更加灵活的设计, 让整体多系统之间实现协调, 从而达到拓展单一系统功能局限性或包容特定行业应用的特色等目的.

解放人力, 高效协作(系统间, 人与人之间)

相关内容

NetSuite 实现方法

Inventory Count在Script中局限性

本身上的局限

  1. Inventory Count 不支持自定义表单(custom form), 但是可以新建自定义表头字段并应用到InventoryCount

  2. Inventory Count 不支持显示自定义表头和列表自定义字段

  3. Inventory Count 不支持保存自定义列表字段的内容(即便在Script中写入了自定义列表字段内容, 保存单据后, 自定义列表字段内容会清空消失)

  4. 当在点击“开始盘点Start Count” 动作之前输入货品以及详细的盘点数据, 然后在点击‘开始盘点’, 完成盘点 和审批的流程性动作, 会导致盘点的货品数据只是调增
  5. 也就是说详细的盘点数据 不是覆盖目前系统中货品的库存数据, 而是新增添加到当前的库存数据中去.

流程上局限

  1. 创建库存盘点单. 根据现实情况输入Body上面的字段. 然后搜索或选择货品,然后选择库位和单位后即可保存(此时为‘未结’状态)

  2. 之后点击“开始盘点” 为‘已开始’状态后点击编辑即可输入 盘点数量, 输入完数量后才能点击盘点 明细按钮打开对话框,然后输入批次号、下拉框状态、数量保存即可,此时状态下拉框 默认只有Good – available 可用状态,输入好判断数量后点击“完成盘点”后就完成了盘点单

  3. 点击“完成盘点”按钮后状态就变成了 ‘已完成/待审批’状态, 之后就是审批流程。审批通过后就回生成一个库存调整单,就会按照原来的盘点数量来加数量审批通过 后就 会操作数据. 在开发过程中,接收到传过来的值后就可以立即进行一整套操作,跳过审批直接生成库存调整,执行完成后返回信息。

走完库存盘点单Inventory Count 需要搭配 新建保存初始单据 然后 “开始盘点Start Count” 和 “完成盘点Complete Count” 和 “审批Approve”这个四个动作action

问题来了: 我们通过Reslet来接收WMS传来的库存盘单数据, 然后需要一步到位走完NetSuite的库存盘点的四个动作流程才能生效(影响货品库存和GL Impact)

经过测试 “开始盘点Start Count” 和 “完成盘点Complete Count” 和 “审批Approve”这个四个动作是无法用非’N/action’的NetSuite默认包来实现的, 然后Restlet又无法支持引用’N/action’包.

灵活思路解决方案

Restlet先接收和缓存库存盘点的详细Item数据(比如批次和数量等), 然后通过另新建一个User Event Script来继续操作剩下的流程 “开始盘点Start Count”, 输入详细的盘点数量数据 和 “完成盘点Complete Count” 和 “审批Approve”

Sample RestLet Script

解析WMS post传来的参数, 做好数据对应后, 在把详细的盘点数据缓存在表单头的自定义字段中(custbody_wms_invcount_items), 等待下一步处理.

//------------------------------------------------------------------
// Copyright 2018, All rights reserved, Carl Notes.
//
// No part of this file may be copied or used without express, written
// permission of Carl Notes.
//------------------------------------------------------------------

//------------------------------------------------------------------
//Script: ep_OperateInvCount_rl.js
//Developer: Carl
//Date: 20250808
//Description: API REST Endpoint: Operate Inventory Count
//             Running in nonpaged mode, per search upto 4000 results.
//
// ------------------------------------------------------------------

/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 */
define(['N/error', 'N/record', 'N/runtime', 'N/search'],
    /**
     * @param{error} error
     * @param{record} record
     * @param{runtime} runtime
     * @param{search} search
     */
    (error, record, runtime, search) => {
        /**
         * Defines the function that is executed when a GET request is sent to a RESTlet.
         * @param {Object} requestParams - Parameters from HTTP request URL; parameters passed as an Object (for all supported
         *     content types)
         * @returns {string | Object} HTTP response body; returns a string when request Content-Type is 'text/plain'; returns an
         *     Object when request Content-Type is 'application/json' or 'application/xml'
         * @since 2015.2
         */
        const get = (requestParams) => {

        }

        /**
         * Defines the function that is executed when a PUT request is sent to a RESTlet.
         * @param {string | Object} requestBody - The HTTP request body; request body are passed as a string when request
         *     Content-Type is 'text/plain' or parsed into an Object when request Content-Type is 'application/json' (in which case
         *     the body must be a valid JSON)
         * @returns {string | Object} HTTP response body; returns a string when request Content-Type is 'text/plain'; returns an
         *     Object when request Content-Type is 'application/json' or 'application/xml'
         * @since 2015.2
         */
        const put = (requestBody) => {

        }

        /**
         * Defines the function that is executed when a POST request is sent to a RESTlet.
         * @param {string | Object} requestBody - The HTTP request body; request body is passed as a string when request
         *     Content-Type is 'text/plain' or parsed into an Object when request Content-Type is 'application/json' (in which case
         *     the body must be a valid JSON)
         * @returns {string | Object} HTTP response body; returns a string when request Content-Type is 'text/plain'; returns an
         *     Object when request Content-Type is 'application/json' or 'application/xml'
         * @since 2015.2
         */
        const post = (requestBody) => {

            //Validate requestBody
            if (!requestBody.body || !requestBody.body.location||!requestBody.body.internalid||!requestBody.body.data) {

                error.create({
                    name: 'Invalid_Post_Data',
                    message: 'Please check documenation for validate Post data',
                    notifyOff: false
                });
                return '';
            }
            var objInvCountBd = requestBody.body;

            // Create Inventory Count
            var recInvCount = record.create({
                type: record.Type.INVENTORY_COUNT,
                isDynamic: false
            });

            recInvCount.setValue('location', objInvCountBd.location);
            recInvCount.setValue('account', 1423);
            recInvCount.setValue('custbody_wms_invcount_id', objInvCountBd.internalid);
            recInvCount.setValue('custbody_wms_invcount_user', requestBody.user);


            var arrInvCountItems = [];
            arrInvCountItems = objInvCountBd.data;

            for(var ln=0 ; arrInvCountItems && ln<arrInvCountItems.length; ln++){

                var objInvCountItm = arrInvCountItems[ln];

                recInvCount.setSublistValue({
                    sublistId: 'item',
                    fieldId: 'item',
                    value: objInvCountItm.item,
                    line: ln
                });
                recInvCount.setSublistValue({
                    sublistId: 'item',
                    fieldId: 'binnumber',
                    value: objInvCountItm.binnumber, //20 = 2.配件库位; 23 = 1. 成品库位
                    line: ln
                });

                arrInvCountItems[ln].line = ln;
            }
            recInvCount.setValue('custbody_wms_invcount_items', JSON.stringify(arrInvCountItems));

            var intInvCountId = recInvCount.save({
                enableSourcing: true
                // , ignoreMandatoryFields: true
            });
            log.audit('ep_OperateInvCount_rl', 'Created New Inventory Count: ' + intInvCountId);

            // return {
            //     code: 0,
            //     msg: '推送库存盘点单成功'
            // };
            return intInvCountId;
        }

        /**
         * Defines the function that is executed when a DELETE request is sent to a RESTlet.
         * @param {Object} requestParams - Parameters from HTTP request URL; parameters are passed as an Object (for all supported
         *     content types)
         * @returns {string | Object} HTTP response body; returns a string when request Content-Type is 'text/plain'; returns an
         *     Object when request Content-Type is 'application/json' or 'application/xml'
         * @since 2015.2
         */
        const doDelete = (requestParams) => {

        }

        return {
            // get: post,
            // , put,
            post: post
            // , delete: doDelete
        }

    });

Sample Inventory Count User Event

根据严格限定条件, 锁定只有第一次RestLet传来的库存盘点单才进行自动化处理. “开始盘点Start Count”, 输入每个货品详细的盘点批次和数量等数据 和 “完成盘点Complete Count” 和 “审批Approve”这个四个动作action

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */
define(['N/action', 'N/error', 'N/record', 'N/runtime', 'N/search'],

    (action, error, record, runtime, search) => {
        /**
         * Defines the function definition that is executed before record is loaded.
         * @param {Object} scriptContext
         * @param {Record} scriptContext.newRecord - New record
         * @param {string} scriptContext.type - Trigger type; use values from the context.UserEventType enum
         * @param {Form} scriptContext.form - Current form
         * @param {ServletRequest} scriptContext.request - HTTP request information sent from the browser for a client action only.
         * @since 2015.2
         */
        const beforeLoad = (scriptContext) => {

        }

        /**
         * Defines the function definition that is executed before record is submitted.
         * @param {Object} scriptContext
         * @param {Record} scriptContext.newRecord - New record
         * @param {Record} scriptContext.oldRecord - Old record
         * @param {string} scriptContext.type - Trigger type; use values from the context.UserEventType enum
         * @since 2015.2
         */
        const beforeSubmit = (scriptContext) => {

        }

        /**
         * Defines the function definition that is executed after record is submitted.
         * @param {Object} scriptContext
         * @param {Record} scriptContext.newRecord - New record
         * @param {Record} scriptContext.oldRecord - Old record
         * @param {string} scriptContext.type - Trigger type; use values from the context.UserEventType enum
         * @since 2015.2
         */
        const afterSubmit = (scriptContext) => {

            var currentRecord = scriptContext.newRecord;
            var intInvCountId = currentRecord.id;

            //flag for restlet API transaction
            if (!currentRecord.getValue('custbody_wms_invcount_id'))
                return true;

            //only available for RESTLET
            if (runtime.executionContext != runtime.ContextType.RESTLET)
                return true;

            if (!currentRecord.getValue('status')|| 
                currentRecord.getValue('status') == 'Open' || currentRecord.getValue('statuskey') == 'A') {

                action.execute({
                    id: 'startcount',
                    recordType: currentRecord.type,
                    params: {
                        recordId: currentRecord.id
                    }
                });

                //re-enter the quantity and count detail
                recInvCount = record.load({
                    type: record.Type.INVENTORY_COUNT,
                    id: intInvCountId
                })


                var strItemData = recInvCount.getValue('custbody_wms_invcount_items');
                var arrInvCountItems = JSON.parse(strItemData);
                var arrLinkedArrIdx = [];

                for(var i=0 ; arrInvCountItems && i<arrInvCountItems.length; i++){

                    var objInvCountItm = arrInvCountItems[i];

                    var intItemId_tmp = recInvCount.getSublistValue({
                        sublistId: 'item',
                        fieldId: 'item',
                        line: i
                    });
                    var intItemBin_tmp = recInvCount.getSublistValue({
                        sublistId: 'item',
                        fieldId: 'binnumber',
                        line: i
                    });

                    if (objInvCountItm.item!=intItemId_tmp || objInvCountItm.binnumber!=intItemBin_tmp)
                        continue;


                    // countdetail  --------------------------
                    var intQtyTtl = 0;
                    var arrCountDtl = objInvCountItm.info;

                    var objCountDtl = recInvCount.getSublistSubrecord({
                        sublistId: 'item',
                        fieldId: 'countdetail',
                        line: i
                    });

                    for(var countDtlIdx=0 ; arrCountDtl && countDtlIdx<arrCountDtl.length; countDtlIdx++){

                        objCountDtl.setSublistValue({
                            sublistId: 'inventorydetail',
                            fieldId: 'inventorynumber',
                            value: arrCountDtl[countDtlIdx].inventorynumber,//'Test20250812',
                            line: countDtlIdx
                        });
                        objCountDtl.setSublistValue({
                            sublistId: 'inventorydetail',
                            fieldId: 'inventorystatus',
                            value: arrCountDtl[countDtlIdx].inventorystatus,//'1',
                            line: countDtlIdx
                        });
                        objCountDtl.setSublistValue({
                            sublistId: 'inventorydetail',
                            fieldId: 'quantity',
                            value: arrCountDtl[countDtlIdx].inventorycount,//'999',
                            line: countDtlIdx
                        });

                        intQtyTtl = intQtyTtl + arrCountDtl[countDtlIdx].inventorycount;
                    }

                    recInvCount.setSublistValue({
                        sublistId: 'item',
                        fieldId: 'countquantity',
                        value: intQtyTtl,
                        line: i
                    });

                }

                var intInvCountId = recInvCount.save();
                log.audit('wms_invCount_ue', 'Updated New Inventory Count: ' + intInvCountId);

                action.execute({
                    id: 'completecount',
                    recordType: currentRecord.type,
                    params: {
                        recordId: currentRecord.id
                    }
                });

                action.execute({
                    id: 'approve',
                    recordType: currentRecord.type,
                    params: {
                        recordId: currentRecord.id
                    }
                });

            }

            return true;
        }

        return {
            // beforeLoad, beforeSubmit,
            afterSubmit
        }

    });

POSTMAN及设置

oAuth 1.0 的设置中, 有个需要注意的地方: 把NetSuite Account ID 放到 header的 Realm 字段中. In Postman in your request tab and then in the authorization tab in the advanced section there is a field called Realm. Put the account id in the realm field with underscores.

image-20250829113750207

结语

实例由于隐去所有显示应用的信息, 难免会十分偏向于技术性的探讨, 也变得比较难理解.

如果您对这类对接设计或开发或应用实现等感兴趣, 可通过下方的链接(或扫码) 与我取得联系

我们可以进行更加针对性的探讨与交流

感谢您有耐心看到这里, 祝您生活愉快!~

本文仅是思路的探讨, 如有侵权, 请联系作者删除修改

SuiteQL Query Tool

NetSuite SuiteQL Query Tool

背景

使用了3年时间后,我表示非常感激;不得不来赞美一下下。

我很喜欢Tim兄分享的SuiteQL Query Tool,

它用AJAX的方式提交query无需刷新页面动态加载query结果,

另外更加人性化的数据库字段搜索,索引与关联等。

总体感觉:非常简洁,直观,方便,快速

凸出优点

  • 支持选中部分多SQL语句,来点击提交‘Query’查询
  • 这对于多级嵌套的查询非常有帮助,方便逐级debug查询
  • 数据源检索
  • 方便的搜索和展示数据源,数据表的详细字段以及关联表信息

源地址

  1. https://timdietrich.me/netsuite-suitescripts/suiteql-query-tool/downloads/
  2. https://github.com/chuanzhuo/suiteqlquerytool_cli
    • 我的版本更新历史(包含对国内的各种优化)
    • 我的这个版本用SDF,可以直接一键发布到NetSuite上

经历与优化

最近1年左右,遇到一个奇怪的问题;在国内的加载速度非常慢,一般SuiteLet页面加载完成需要1-3分钟,有的时候更夸张,让我苦不堪言。

期间我做了很多优化,试图解决这个尴尬的首次页面加载速度问题,包括:关闭的远程加载Query库的功能,关闭更新,关闭所有不常用的加载项(workbook等)

虽然有所改善,但始终无法达到令我满意的局面(是的,我苛刻了)

今天终于在Chrome的分析工具下找到了原因:bootstrap库的压缩JS和CSS CDN链接在国内失效了(天朝就是如此神奇)。

* Replace the URL FROM * https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js
* https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css
* TO * https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.5.2/js/bootstrap.min.js
* https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.5.2/css/bootstrap.min.css

优化后,速度杠杠的! Yeah. 感恩🙏

NetSuite的gzip解压缩在接口开发中的应用

NetSuite的gzip解压缩在接口开发中的应用, 常见于接口开发中大数据量的传输, 用gzip压缩和解压缩功能提供传输效率

有什么用

可以看到NetSuite的gzip解压缩在接口开发中的应用

接口改造

接口优化

怎么用

两端的开发人员协调, 将不同系统中数据传输, 在接口端 和 主发送端口 将数据进行解压缩和压缩工作,

从而达到: 提高数据传输速率, 减少数据传输量, 另个角度加密数据; 对大量数据传输的情形有显著的效果提升作用

相关内容

NetSuite实现方法

NetSuite的SuiteScript在2023年加入的 N/compress Module模块, 实现了服务端对数据进行压缩和解压缩的buildin支持.

NetSuite发送往第三方系统

将NS中的数据/报表, 通过JSON组织成string字符串的类型, 然后compress.gzip进行压缩(压缩级别可选: 1-9),

最后通过payload把数据post到第三方系统的接口节点上. NS端实例如下:

....
var objTxtFile = file.create({
    fileType: 'PLAINTEXT',
    name: 'file.txt',
    encoding: file.Encoding.UTF8,
    contents: JSON.stringify(arrInvUpdateData)
});
var gzippedFile = compress.gzip({
    file: objTxtFile,
    level: 4
});
var strGzipText = gzippedFile.getContents();

let objBody = {
    "messageId": new Date().getTime().toString(),
    "route": "goodsInventoryUpdate",
    "payload":  strGzipText
};

...

第三方系统发到NetSuite接口

从第三方系统发送到NetSuite的接口; RESTlet; NetSuite端对接收的数据进行解压缩; 然后处理相应的业务需求

var objTxtFile = file.create({
                fileType: file.Type.GZIP,
                name: 'zipped.txt.gz',
                encoding: file.Encoding.UTF8,
                folder: -15,
                contents: strBodyZipped
            });

            var guzippedFile = compress.gunzip({
                file: objTxtFile
            });

            var strGuzipText = guzippedFile.getContents();
            strGuzipText = strGuzipText.toString().replaceAll('\', '');
            strGuzipText = strGuzipText.substr(1, strGuzipText.length-2);

            requestBodyOrg.body = JSON.parse(strGuzipText);

需要注意的点有:

  1. Netuite的compress.gunzip接收的参数是文件的类型
  2. compress.gunzip接收的文件类型需要必须是.gz, GZIP的文件类型: file.Type.GZIP
  3. 这点很重要, 虽然内部是相同的内容, 如果是txt文件, NetSuite会解压缩报错
  4. 解压缩以后的字符串, 是第三方系统处理过的, 需要进行一些清洗清洁工作, 比如 和多余的 “”

其他第三方gzip解决方案

多文件的压缩与解压缩

使用 JSZip-Sync

JSZip-Sync is a Javascript library created for creating, reading and editing .zip files in a simple way.
JSZip is a javascript library for creating, reading and editing .zip files, with a lovely and simple API.

使用步骤:

  • 下载 jszip.min.js file; the npm site.
  • 上传到NetSuite script 文件夹
  • 添加模块定义 define object (‘./jszip.min.js’)

举例解压缩一个zip文件

....
const getZipContents = (fileId) => {
    try {
        if(fileId){
            /** zip文件已存放于filecabinet中 */
            let objZippedFile = file.load({
                id: fileId
            })
            let strZippedFileContents = objZippedFile.getContents()
            let arrUnzippedFiles = [];

            var new_zip = new JSZip();

            new_zip.sync(function () {
                let zip = new_zip.loadAsync(strZippedFileContents, {base64:true})._result;
                /** 压缩包中遍历每个文件 */
                Object.keys(zip.files).forEach(fileName => {

                    let strFileContent = zip.file(fileName).async("string");.

                    arrUnzippedFiles.push({
                        file_name: fileName,
                        file_contents: strFileContent._result
                    })
                })
            })
            return arrUnzippedFiles;
        }
  } catch(error){
       log.error ('解压缩失败文件id ' + intZipFileId, error)
  }
}
....
....

const zippedFile = file.load({id: fileId});
            const zippedFileContent = zippedFile.getContents();
            const unzippedFiles = [];

            const ZipInstance = new jszip();

            ZipInstance.sync(function () {
                ZipInstance.loadAsync(zippedFileContent, {base64: true}).then(function (zip) {
                    Object.keys(zip.files).forEach(function(filename){
                        const file =  zip.file(filename).async("string");

                        unzippedFiles.push({
                            name: filename,
                            content: file._result,
                            size: file._result.length
                        })

                    });
                })
            });
....

NetSuite接口开发之JSON源数据发送实现gzip压缩

NetSuite如何发送大容量数据到第三方的专业系统? 通过压缩数据实现突破默认HTTP Post的数据量上限限制, 让数据在系统间传输更流畅, 更迅速


有什么用/怎么用

当需要一次发送大量的数据到第三方系统时, 或者第三方系统post进NetSuite大容量(size)的数据时,

将需要发送的数据ajax post中的body payload 进行gzip压缩(或解压缩)

image-20260415184103239

相关内容


实现方法

GZIP压缩

压缩级别: 4

字符串编码方式: UTF8

最后选用gzip NetSuite系统算法

测试用例20260415, 测试单据: IA2339,

压缩前:

[{"type":1,"internalid":"11272","locationInfos":[{"locationid":"1","storageLocInfos":[{"storagelocid":"20","goodInventoryInfos":[{"inventoryInfos":[{"inventorycount":"123","inventorynumber":"cea"}]}]}]}]}]

NetSuite压缩后:

H4sIAAAAAAAA/33OQQrCQAyF4bu8dRfOuBDmBgVvUFyM01gGaiLTVCildzfVaneS3Z8PkmaGTg9CcBUyKxWOfW4R4Jw/eVToJUXNwjXfZEBo5l/5MCODSokdnSXtaGtm38wfzHUibc1PYttNO81/UpKRdT3jj1gf3DKP9ysV64kilst3XsR9VMbNAAAA

网站压缩后: (比对测试网址: Gzip/Deflate 压缩 – 在线免费工具 | TingYu Tools)

H4sIAAAAAAAAA33OQQrCQAyF4bu8dRfOuBDmBgVvUFyM01gGaiLTVCildzfVaneS3Z8PkmaGTg9CcBUyKxWOfW4R4Jw/eVToJUXNwjXfZEBo5l/5MCODSokdnSXtaGtm38wfzHUibc1PYttNO81/UpKRdT3jj1gf3DKP9ysV64kilst3XsR9VMbNAAAA

备注: 多次测试发现并核实NetSuite生产的压缩数据(不管那个压缩级别)都是会与网站测试差一个字符: 第13个字符, NetSuite总是输出 /, 网站总是输出A, 其他都一致; 我觉得这应该不影响解压, 因为用NetSuite压缩过的数据可以用测试网站解压缩出正确的结果(结果一致).

压缩级别说明

​ ● 1-3:快速压缩,较低压缩率

​ ● 4-6:平衡速度和压缩率(推荐)

​ ● 7-9:最佳压缩,速度较慢

应用场景

​ ● HTTP 响应压缩(Gzip)

​ ● 文件压缩和传输

​ ● 减少 API 响应体积

​ ● 数据存储优化

注意事项

​ ● 压缩数据以 Base64 格式输出,便于传输

​ ● 随机数据或已压缩数据可能无法进一步压缩

​ ● 重复内容越多,压缩效果越好

测试完成gzip压缩用于发送库存变动的payload数据

示例代码

//......
​
var objTxtFile = file.create({
  fileType: 'PLAINTEXT',
  name: 'file.txt',
  encoding: file.Encoding.UTF8,
  contents: JSON.stringify(arrInvData)
});
var gzippedFile = compress.gzip({
  file: objTxtFile,
  level: 4
});
var strGzipText = gzippedFile.getContents();
​
let objBody = {
  "messageId": new Date().getTime().toString(),
  "route": "inventoryUpdate",
  "payload":  strGzipText
};
​
//......

pako.js工具压缩探讨

https://github.com/nodeca/pako

const test = { my: 'super', puper: [456, 567], awesome: 'pako' };
​
const compressed = pako.deflate(JSON.stringify(test));

由于NetSuite的server端, 无法将compressed的数据转化成string,

在client端, 可以使用btoa, windows.btoa来encode转化成字符; 而server端没有这个build-in函数

有一些第三方的btoa的实现方式

      //private property
        let _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
        private method for UTF-8 encoding
        function _utf8_encode (string) {
            if (!string) return '';
            string = string.toString().replace(/rn/g,"n");
            let utftext = "";
            for (let n = 0; n < string.length; n++) {
                let c = string.charCodeAt(n);
                if (c < 128) {
                    utftext += String.fromCharCode(c);
                } else if((c > 127) && (c < 2048)) {
                    utftext += String.fromCharCode((c >> 6) | 192);
                    utftext += String.fromCharCode((c & 63) | 128);
                } else {
                    utftext += String.fromCharCode((c >> 12) | 224);
                    utftext += String.fromCharCode(((c >> 6) & 63) | 128);
                    utftext += String.fromCharCode((c & 63) | 128);
                }
​
            }
            return utftext;
        }
​
        // private method for UTF-8 decoding
        function _utf8_decode (utftext) {
            let string = "";
            let i = 0;
            let c = 0;
            let c1 = 0;
            let c2 = 0;
            let c3 = 0;
            while ( i < utftext.length ) {
                c = utftext.charCodeAt(i);
                if (c < 128) {
                    string += String.fromCharCode(c);
                    i++;
                } else if((c > 191) && (c < 224)) {
                    c2 = utftext.charCodeAt(i+1);
                    string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
                    i += 2;
                } else {
                    c2 = utftext.charCodeAt(i+1);
                    c3 = utftext.charCodeAt(i+2);
                    string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
                    i += 3;
                }
            }
            return string;
        }
​
        // public method for encoding
        const encode = (input) => {
            let output = "";
            let chr1, chr2, chr3, enc1, enc2, enc3, enc4;
            let i = 0;
            input = _utf8_encode(input);
            while (i < input.length) {
                chr1 = input.charCodeAt(i++);
                chr2 = input.charCodeAt(i++);
                chr3 = input.charCodeAt(i++);
                enc1 = chr1 >> 2;
                enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
                enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
                enc4 = chr3 & 63;
                if (isNaN(chr2)) {
                    enc3 = enc4 = 64;
                } else if (isNaN(chr3)) {
                    enc4 = 64;
                }
                output = output +
                    _keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
                    _keyStr.charAt(enc3) + _keyStr.charAt(enc4);
            }
            return output;
        }
​
        // public method for decoding
        const decode = (input) => {
            let output = "";
            let chr1, chr2, chr3;
            let enc1, enc2, enc3, enc4;
            let i = 0;
            input = input.replace(/[^A-Za-z0-9+/=]/g, "");
            while (i < input.length) {
                enc1 = _keyStr.indexOf(input.charAt(i++));
                enc2 = _keyStr.indexOf(input.charAt(i++));
                enc3 = _keyStr.indexOf(input.charAt(i++));
                enc4 = _keyStr.indexOf(input.charAt(i++));
                chr1 = (enc1 << 2) | (enc2 >> 4);
                chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
                chr3 = ((enc3 & 3) << 6) | enc4;
                output = output + String.fromCharCode(chr1);
                if (enc3 != 64) {
                    output = output + String.fromCharCode(chr2);
                }
                if (enc4 != 64) {
                    output = output + String.fromCharCode(chr3);
                }
            }
            output = _utf8_decode(output);
            return output;
        }

解压缩以后, 使用JSON的查看展开查看具体的JSON详细数据, 比如使用工具: https://www.sojson.com/json/json_online.html

如果您需要NetSuite设计开发定制改良目前的功能模块, 请联系我:

优质服务, 期待与您协作

如何在NetSuite中设置RESTlet API的oAuth2.0认证

了解和设置NetSuite的oAuth2.0

本文从如何获取一个oAuth2.0的token展开, 您可通过了解oAuth2.0认证来进一步实现跨系统操作NetSuite数据(以及业务流程)

有什么用

了解和设置NetSuite的oAuth2.0

本文从如何获取一个oAuth2.0的token展开, 让您可以通过了解oAuth2.0的认证, 来进一步跨系统访问NetSuite数据

比如, 在获取了token以后, 您可以进一步使用NetSuite的原生RestLet API来跨系统操作NetSuite数据库

最终操作业务流程

怎么用

NetSuite REST API Browser: Record API v1

你可以直接呼叫restlet节点, 从而NetSuite数据库中的信息, 比如:

image-20251110205900396

调试工具(举例):

1.  https://hoppscotch.io/
1.  postman

咨询服务

We provide people-friendly NetSuite development and consulting services for all sizes of business and projects.

We specialize in customizing NetSuite to better fit your specific business processes. Our philosophy is that in everything we do, people are the most important aspect. We believe that NetSuite is an incredible system and our goal is to help your company utilize NetSuite to its greatest potential, delivering you incredible value.

image-20251110210430326

服务流程

image-20251109195518962

服务内容

image-20251109195734192

image-20251109195806782

服务案例

Signatures for NetSuite

Signatures for NetSuite is a “Built-for-NetSuite” approved eSignature tool that enables you to sign any record in NetSuite. Users can sign using a touchscreen (Chromebook, iPad, etc.), mouse, or Topaz USB signature pad. Screenshots:

Automated Process Testing

This modules gives users the ability to create test cases in NetSuite for their processes. The test cases can be run automatically (on a schedule) so that administrators can find out when something changes or something breaks.

SMS (Text Messaging) Integration

What would having automated SMS communication to and from NetSuite enable you to do? The possibilities are endless, but a few of the examples that the tools in this bundle enable you to do are: appointment reminders, quote approvals, shipment notifications, and bulk customer satisfaction surveys.

Easy Payment Suitelet

This tool enables your customers to pay their invoices securely online (via NetSuite) without having to log into anything

Easy Sale Suitelet

This tool enables users to easily make purchases online from a simple product catalog, resulting in a sales order or cash sale in your NetSuite.

Matrix Item Entry

For clothing companies, the standard NetSuite item entry process on an order can be slow and tedious. Use this simple tool to quickly enter matrix items organized by standard size scales (S-M-L-XL-XXL, etc).

Advanced Reporting Requirements

Are you having trouble getting the data you need from a report or saved search? We can help create advanced saved searches or visual data representation using the new SuiteAnalytics module.

Contract Renewals

We’ve done a lot of work with software companies to automate and enhance managing contracts and contract items. If you’re experiencing challenges or process bottlenecks with contracts in NetSuite, get in touch and let’s talk through it.

Advanced PDF Templates

Need some highly-tailored PDF printout templates created? We are experts in Freemarker templates and BFO PDF creation.

相关内容

oAuth2.0配置详情

  1. 创建Netsuite的Integration记录
  2. 开启Enable Feature中的oAuth 2.0功能
  3. 新建Integration记录(勾选下面的字段)
    1. 其中Redirect URI是oAuth发来第一个get请求时, 必需匹配的; 否则会直接报错无法走流程
  4. 保持记录后会生成Consumer Key / Client ID 和 Consumer Secret / Client Secret
  5. 要记录这2个重要的信息(后面需要用到)

image-20251109201714581

  1. 打开网页https://hoppscotch.io/

  2. 填写内容:

  3. Grant type: Select Authorization Code.
  4. Authorization Endpoint https://{accountid}.app.netsuite.com/app/login/oauth2/authorize.nl
  5. Token Endpoint: https://{accountid}.app.netsuite.com/services/rest/auth/oauth2/v1/token
  6. Client ID, Client Secret 上一步在NetSuite中获取的内容
  7. Scopes: rest_webservices restlets
    1. 或者仅填写Scope: rest_webservices
  8. State: [optiona可选]This can be any random string of ASCII characters. It must include at least 22 characters.
  9. Pass By: Headers
    1. Client Authentication: Send as Basic Auth header
  10. 点击 Request Token/Get New Access Token

image-20251109202334178

  1. 工具会打开的授权页面, 确认登陆

  2. 如果当前浏览器是已登陆NetSuite的状态, 会自动跳转到如下画面, 直接进行授权确认

  3. 点击Continue后, NetSuite会传一个post到指定的节点用于完成确认

image-20251109201813996

如果你需要一个你当前系统的rest的sample, 你在登陆系统的状态下可以下载到

You can download the REST API Postman environment template and collection of sample requests from the SuiteTalk tools download page at https://.app.netsuite.com/app/external/integration/integrationDownloadPage.nl.

NetSuite oAuth 2.0步骤

1. 发送Get请求到NetSuite的Authorization节点

OAuth 2.0授权获取Token的第一步是发送一个Get请求携带特定的URL参数, 用户手工授权登陆后, 用于获取NetSuite的返回一个code从而进行下一步的验证流程.

  1. 节点地址: https://.app.netsuite.com/app/login/oauth2/authorize.nl

  2. 参数:

| Request Parameter | Description |
| —————– | ———————————————————— |
| response_type | The value of the response_type parameter is always code. |
| client_id | Identifies the client.The value of the client ID is provided when the integration record is created. |
| redirect_uri | The application uses the valid redirect URI to handle the authorization code.The value of the redirect URI parameter must match the redirect URI in the corresponding integration record.
这里面的内容要encode
encodeURIComponent(‘https://hoppscotch.io/oauth’) |
| scope | The scope for which the application is requesting access. Values are restlets, rest_webservices, suite_analytics, or mcp. You can use any combination of the scopes, except the mcp. The mcp value for the scope parameter can only be used on its own. For more information about the NetSuite AI Connector Service, see Connect to NetSuite AI Connector Service. |
| 举例说明: | 注意下面的例子要根据你实际情况调整 |
| | https://.app.netsuite.com/app/login/oauth2/authorize.nl?response_type=code&redirect_uri=https%3A%2F%2Fhoppscotch.io%2Foauth&scope=rest_webservices&client_id=e184757ca95d8f73160983f17d01337d65d74149cc858e839df184a26aa3597e |

页面加载后, 如果报错,就要根据情况调整get的URL请求中的参数, 确保数据的准确性, 比如redirect_uri要吻合NetSuite中的记录对应redirect_uri的字段内容, 并且要encodeURIComponent后放到URL地址里面

image-20251110203726123

当授权成功后, NetSuite会跳转页面到该redirect_uri的页面, 并且携带一些额外的参数, 参数内容及说明如下:

第一步授权成功后的重定向页面参数Redirect Parameters for Step One

After authorization, NetSuite initiates a redirect to the Redirect URI, with the following parameters:

| Redirect Parameter | Description |
| —————— | ———————————————————— |
| state | The state parameter in the redirect matches the state parameter in the request in Step One.ImportantTo avoid cross-site request forgery (CSRF) attacks, you must conform to the OAuth 2.0 specification. For more information, see RFC6749 Section 10.12. |
| code | A randomly generated string that is used for request verification in Step Two.The code parameter is only generated if the application was authorized.You must use the value of the code parameter immediately after it is generated. The value for the code parameter has limited time validity. |
| role | Indicates the user’s role for which the access token and refresh token are granted in Step Two.The role parameter is a NetSuite-specific parameter. |
| entity | The ID of the user who authorizes the application or interrupts the flow.The entity parameter is a NetSuite-specific parameter. |
| company | NetSuite account ID (company identifier).The company parameter is a NetSuite-specific parameter. |
| error | The error parameter is only used when an error occurs during the flow. For information about error values, see Troubleshooting OAuth 2.0. |

授权成功的跳转URL地址举例:

https://myapplication.com/netsuite/oauth2callback?state=ykv2XLx1BpT5Q0F3MRPHb94j&role=1000&entity=12&company=1234567&code=70b827f926a512f098b1289f0991abe3c767947a43498c2e2f80ed5aef6a5c50 

授权失败的跳转URL地址举例:

https://myapplication.com/netsuite/oauth2callback?state=ykv2XLx1BpT5Q0F3MRPHb94j&role=1000&entity=12&company=1234567&error=access_denied 

主要我们关注的就是一个code的参数; 这个参数将要用在下一步的POST请求中.

2.发送Post请求到NetSuite的Token节点

程序(可以是自定义的任何程序, 或者Postman, 或者hoppscotch.io这种特定的工具)紧接着要发送一个POST请求到NetSuite的token节点. 这个POST请求必需包含特定的header内容和body内容. 这个步骤完成后的最后将会获取可访问NetSuite系统数据库的Access Token(和Refresh Token 用于刷新Access Token来确保token在失效后获取新的Access Token). 而如果用hoppscotch.io这类的工具, 在配置正确后(详细可见上一步的配置详情), 会自动帮您加密clientid:clientsecret等, 然后发出该post, 然后就会在UI上获取NetSuite的access_token反馈.

2.1 节点地址: https://.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token

2.2 节点post所需的参数

| Request Parameter | Description |
| —————– | ———————————————————— |
| code | 这是第一个get跳转后获取到的code. The code parameter value obtained in Step One. |
| redirect_uri | 这是后台校验用的, redirect_uri必需吻合NetSuite Integration记录中的设置, 和第一个发送的redirect_uri参数内容. The value of the redirect_uri parameter must match the value entered in the corresponding integration record and the value in the request in Step One. |
| grant_type | 这是固定内容authorization_code The value of the grant_type parameter in Step Two is authorization_code. |
| code_verifier | 必须吻合第一步中放回的code_verifier参数内容。The value of the code_verifier must match the value generated in Step One. If the values don’t match, HTTP 400 Bad Response error is returned. For more information, see https://tools.ietf.org/html/rfc7636, sections 4.5 and 4.6. |

  • Request parameters must be encoded based on the HTML specification for the application/x-www-form-urlencoded media type. For more information, see URL Specification 5.1
  • The client authentication method used in the header of the request follows the HTTP Basic authentication scheme. For more information, see RFC 7617. The format is clientid:clientsecret. The string value is Base64url encoded. The following code provides an example.
POST /services/rest/auth/oauth2/v1/token HTTP/1.1
Host: <accountID>.suitetalk.api.netsuite
Authorization: Basic Njc5NGEzMDg2ZTRmNjFhMTIwMzUwZDAxYjg1MjdhZWQzNjMxNDcyZWYzMzQxMjIxMjQ5NWJlNjVhOGZjOGQ0YzpjZGM3YWMyMjE4M2VmNTAyNGU4MWIwZmNlOGVmNDYxYzQ0ZDU4OTZhMWYxODA1ZDRiMzcyY2E2MWM0ZDMyNmFl
Content-Type: application/x-www-form-urlencoded

code=70b827f926a512f098b1289f0991abe3c767947a43498c2e2f80ed5aef6a5c50&redirect_uri=https%3A%2F%2Fmyapplication.com%2Fnetsuite%2Foauth2callback&grant_type=authorization_code&code_verifier=abFOm_isZAwm7PpI9BtJRMEuiMqhU6sUqZlVWSsAAf1QutgIOD~on78mu-JdpbKc_RA7IEcf2e~q0XrKlJ1tE.8Un64PXLKQG16G4lwW-a5de_0aeU2mHnyVPg.Or8cE

第二步中NetSuite返回值

| JSON Response Fields | Description |
| ——————– | ———————————————————— |
| access_token | The value of the access_token parameter is in JSON Web Token (JWT) format. The access token is valid for 60 minutes. |
| refresh_token | The value of the refresh_token parameter is in JSON JWT format. The refresh token is valid for seven days.ImportantIf you use public clients for OAuth 2.0, the refresh token is only valid for two days by default and is for one-time use only. You can change this value on the integration record. The accepted values are between one hour and 720 hours (thirty days in hours). |
| expires_in | The value of the expires_in parameter is always 3600. The value represents the time period during which the access token is valid, in seconds. |
| token_type | The value of the token_type parameter is always bearer. |
| id_token | This parameter is a part of OAuth 2.0, but it is used only in the NetSuite as OIDC Provider feature flow. You don’t need to configure the token_id parameter as a part of the OAuth 2.0 feature flow. For more information, see Step Two POST Request to the Token Endpoint. |

下面是一个response 的例子 in JSON JWT 格式:

            {"access_token":"eyJraWQiOiJzLlNZU1RFTS4yMDIwXzEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIzOzciLCJhdWQiOlsiNkFDQkQ1MUMtNTE0Qi00RjU5LUIxQzMtQ0IzNUZGN0U5QTZBOzQwMzAwNTkiLCI0Nzc2MWI3NzY1MjJlMTg2ZmNkNzdmMTNjMjVlOGNjZjk5YWY5MDFhNjc4YTY2ZTcwMGIxNjFlZWZlOGZhODhkIl0sInNjb3BlIjpbInJlc3Rfd2Vic2VydmljZXMiLCJyZXN0bGV0cyJdLCJpc3MiOiJodHRwczpcL1wvc3lzdGVtLm5ldHN1aXRlLmNvbSIsIm9pdCI6MTYxMTA2NzY1NSwiZXhwIjoxNjExMDcxMjU1LCJpYXQiOjE2MTEwNjc2NTUsImp0aSI6IjQwMzAwNTkuYS41YjMyMzZiOS1mZmVlLTQyZDMtYmQ1Ny00YmU3YjQ0MzlhMzdfMTYxMTA2NzY1NTM1OS4xNjExMDY3NjU1MzU5In0.TVpquJSRujxyZpp9ydnkfQFy8fq2eTRIt-7mA6B9nGvftEQ2pJCu-15qfxYoe6iKU1JEpOhuvA-MAzdI-TvM1ndHT37VRdpcEa3R_kdZuDIT5hAS0G5VRVOQVF6bseHTKm4HIe0bFy8vCIaS6utQ46crF0LnQK_bxYXsQz8nFEwGlk4mOmsKje5ZB_0vzXpHEuYh9sBFdwxhMNUO3P_tFiAF0f0oXXJzAzYTEjA9pH_tr1ymGFoLWCIfKiR1RUavvVVGeL-jiQdZSRNr5cQj4Nz8iixn9bR2R1rEtcoXBzAJ2pSVU9yimLe2bPmzxBggJr839PDUP4IlKwkvzMUoLw",
"refresh_token":"eyJraWQiOiJzLlNZU1RFTS4yMDIwXzEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIzOzciLCJhdWQiOlsiNkFDQkQ1MUMtNTE0Qi00RjU5LUIxQzMtQ0IzNUZGN0U5QTZBOzQwMzAwNTkiLCI0Nzc2MWI3NzY1MjJlMTg2ZmNkNzdmMTNjMjVlOGNjZjk5YWY5MDFhNjc4YTY2ZTcwMGIxNjFlZWZlOGZhODhkIl0sInNjb3BlIjpbInJlc3Rfd2Vic2VydmljZXMiLCJyZXN0bGV0cyJdLCJpc3MiOiJodHRwczpcL1wvc3lzdGVtLm5ldHN1aXRlLmNvbSIsIm9pdCI6MTYxMTA2NzY1NSwiZXhwIjoxNjExNjcyNDU1LCJpYXQiOjE2MTEwNjc2NTUsImp0aSI6IjQwMzAwNTkuci41YjMyMzZiOS1mZmVlLTQyZDMtYmQ1Ny00YmU3YjQ0MzlhMzdfMTYxMTA2NzY1NTM1OS4wIn0.BbSQ86Phg6CKLMJ9gJQurs1NK6niSxFzF2EBFT--KFysI2AV9S1llZgxsRNIrMXsDioaepdWsGzrKepJXq25t5Sr7f-jBwTLK9g9SkFAvvEFsVJCYbdA4_BNZkHKlCC-1mA_yFNZWBYPdfCMGDX39iIDd7LVkaj-oPjpnuRnnK1ntNzxHx0coJiXwj3KfOI0PK7xfG1zbVSW14XOlatWbi80MY0ZQCgF41nFs-Rv-a7r-b51mMrm6kKZx-0MXfKRYT60H3gPXCk2QzkKovKy3kBVjajbtV*P*NS2tF_SbFNWOXJrn4MFzvnnDy0qsxT_Ijy3S5LTgk4YLrlwKv_XoE7A","expires_in":"3600","token_type":"bearer"} 

最终我们POST后获取到的这个access_token内容就是整个oAuth2.0的最终最重要需获取到的access_token.

有了这个access_token(背后是携带这登陆者的用户信息以及角色和权限的), 我们就可以进步访问和操作NetSuite的数据.

Access_Token应用到NetSuite REST API

NetSuite提供标准的RESTLet访问节点, 所支持操作的记录以及操作类型都罗列在文档中:

image-20251110204904637

举例, 我们可发送Get请求来实时获取NetSuite系统中的会计科目信息:

image-20251110204501694

当然REST的操作远远不止于此,可根据实际企业的业务流程展开.

抛砖引玉 希望对于您有所帮助, 如需相关NetSuite协助, 可移步闲鱼下单前期启动

image-20251110210430326

创建transaction事务处理时如何上传附件?

NetSuite系统Build-in Restlet是有局限性的.

搭配一个restlet(请联系闲鱼链接获取bundle或script数据内容), 就可以用rest请求把PDF等附件发送到NetSuite的filecabinet, 并关联到NetSuite的事务处理(比如发票、账单、费用报销单、销售、采购单等等)

image-20251113200828118

当rest呼叫成功后, 登陆NetSuite可以查看到相应的文件已上传到文件柜并且关联到Transcation事务处理:

image-20251113201213516

灵感与文档

Create Integration Records for Applications to Use OAuth 2.0

NetSuite Tutorial: Using Postman with OAuth 2.0

NetSuite REST API Browser

How to setup oAuth 1.0 in NetSuite RESTlet API 如何在NetSuite中设置RESTlet API的oAuth认证-CarlZeng

步骤如下: 1. Got Restlet URL 访问RESTlet的Deployment,这样获取WebService要Post或访问到的具体URL地址, 如果你疑惑RESTlet是什么,那要等我下一篇文章再介绍。 2. Setup Roles for Token user, goes to em

11261762688976_.pic

步骤如下:

1. Got Restlet URL

访问RESTlet的Deployment,这样获取WebService要Post或访问到的具体URL地址,

如果你疑惑RESTlet是什么,那要等我下一篇文章再介绍。

2. Setup Roles for Token user, goes to employee record to add。

这个步骤是指定:授权那个用户访问RESTlet用的;也就是说API Call到NetSuite时所标示的那个Employee,以及具体的角色Role。
可以设置:Full Access
或者Custom Role,这样可以控制这个定制的角色具体的访问权限,比如那些特定的记录有无访问权限,都有那些具体权限(read,create,delete,full等等)

3. Setup > Integration > Manage Integration NEW

比如新建应用:XXX REST API SBX,指定这个应用的名称等信息,请记录Cosumer Key 和Consumer Secret 

4. Setup > Roles > Token 新建Token

比如: 应用,对应的用户,在对应角色;这样就组成了唯一的Access Token,请记录Token ID 和 Token Secret


设置完成,可以测试API连接了;这里使用POSTMAN进行测试(目前postman越来越难用, 正迁移至Bruno)

有空在详细描述,Share一些截图:

POSTMAN

image-20251223151855496

两个NetSuite之间历史交易数据迁移的具体方案-CarlZeng

背景与展望: 比如:公司要上市往往会要求提供过去几年的营业数据和报表等信息, 而这些信息来源于正在一直运营使用的ERP和财务系统是最可靠与真实的。 NetSuite实现的ERP和财务系统的完美结合,随着多年的经营积累和业务流程升级, NetSuite系统会不断改造和适应新的企业流程和应用。 其中免不

背景与展望:

比如:公司要上市往往会要求提供过去几年的营业数据和报表等信息,

而这些信息来源于正在一直运营使用的ERP和财务系统是最可靠与真实的。

NetSuite实现的ERP和财务系统的完美结合,随着多年的经营积累和业务流程升级,

NetSuite系统会不断改造和适应新的企业流程和应用。

其中免不了切换新的NetSuite系统,或者International到OneWorld版本的升级等等。

那现存多年的业务数据如何保存?

笔者今年做的一个项目就是设计和实施一整套:

从一个NetSuite的Transaction和Non-Transaction数据迁移到另外一个新NetSuite的Record Type和Non-Transaction记录中。

成功迁移的数据记录超过4百多万条。

设计思想:

将Transaction以Transaction Body和Transaction Line(两个父子关系的自定义记录类型)的形式重现到新的NetSuite系统中。

’历史数据加载器‘设计包含:

Step 1 – 分析原始NetSuite A中的事务处理数据然后存储到两个自定义记录中

Analyze the Transactions of the Source Instance and Store Results in Two Custom Records 

根据定义要迁移的Transaction Type(比如:sales order,purchase order, invoice,credit memo,vendorbill, payment。。。);自动分析现有Transaction,提取所有的系统字段和自定字段。

Step 2 – 拷贝这两个分析得出结果记录到目标NetSuite B的相应自定义记录中(用CSV向导)

Copy the data of these two Custom Records to the Destination Instance

根据分析得出的要迁徙的具体字段信息(可能通用于不同的Transaction类型),把原始NetSuite A中的数据导出,生成一个个CSV文件,其中的具体要求比如:单个文件大小不许超过5MB,单个的行数不许超过20000行;这些基础的设置在Schedule Script中进行定义。

Step 3 – 动态生成事务处理数据的两个自定义记录表,该自定义记录将用于存储所有的历史数据

Automatically Create the Two New Custom Records which will store All the Transactional Data of the Source System

根据分析得出的具体字段信息,动态生成Transaction Body和Transaction Line这两个Record Type; 用途:用于在目标NetSuite B中存储具体每一个个的Transaction数据。

Step 4 – 拷贝传输原始NetSuite A的数据到目标NetSuite B中

Copy the data from the Source Instance to the Destination Instance

使用NetSuite的CSV导入功能,用’历史数据加载器‘的代码功能动态加载原始NetSuite A中Filecabinet的数据,然后POST到目标NetSuite B的CSV IMPORT queue。

Step 5 – 在目标NetSuite B中给不用的事务处理类型自定义记录的表格,并关联到现有的客户和供应商记录上。

Create a Custom Form for Each “Record Type” that Migrated from the Source System to the Destination System.

美化导入的数据Form格式,并且关联到Customer,Vendor等等记录上,这样实现了,比如:从目标NetSuite B的Customer SubTab中查看过去几年的交易数据。

精彩截图:

 

生成的迁移数据报表

SQL Server帮忙查找遗失的数据

这个处理遗失(未迁移)数据的思路非常新奇,这里要非常感谢我神奇的同事提供的思路。

1.导出两个系统中的Transaction Internal ID

2.导入百万条的数据到SQL Server的数据表中

3. 用SQL 语句查询到未迁徙的数据列表;

4.导入到原始NetSuite A中标记那些没有成功迁移的Transaction

5.运行’历史数据加载器‘针对与这样的Saved Search,迁移这类数据。

总结:

本文仅供对该’历史数据加载器‘的总体浏览,不涉及具体的技术细节讨论。

这个’历史数据加载器‘做成的bundle可以迁移任意两个NetSuite系统中的数据,

功能灵活强大,适用与百万条以上的历史数据迁移。

Hope it helps,

Have a good day 🙂

案例展示NetSuite UI界面中创建动态下拉列表

在NetSuite的主界面或列表中,创建动态下拉子列表,无缝嵌入到商业应用流程中

有什么用

学会如何在NetSuite UI界面中创建动态下拉列表

  • 在主界面(Custom Body Field)中创建动态下拉列表字段
  • 在列表行(Custom Line Item Field)中创建动态下拉列表字段

在现实业务流程中,这样的动态下拉列表应用十分广泛;而NetSuite自带的自定义字段的动态下来过滤功能十分有限。很多的应用场景无法满足,这里就不一一展开说明,懂的都懂。

需求

image-20240618205434547

本案例:要在下拉列表中,将Replacement Vendor字段的内容,动态地根据Replacement Item字段的内容而决定。就是根据Item货品的供应商列表来过滤当前自定义记录界面中的Replacement Vendor字段的下拉内容,从而达到Replacement Vendor字段的下拉列表不展示系统中所有的Vendor,而是限定于特定Item货品(当前行中的货品字段)的供应商列表(这个使用其实也同时可以展示该供应商的此货品采购价)

怎么用

  1. 创建一个系统中的自定义字段
  2. 如果是把动态列表放在主界面,那么该‘容器’字段也可以使用UserEvent的script生成出这个一个动态的自定义字段。
  3. 如果是把动态列表放在主界面下的列表中(比如:销售订单的货品列表中),那么就必须要新建一个自定义列字段;
  4. 而假如是要把动态列表放在主界面下的列表中(比如:主从关系的自定义记录类型Custom Record Type),那么就是新建一个自定字段在在子记录类型中(这个子自定义记录类型在UI中会变成一个列表sublist)
  5. 新建一个Library脚本文件Script(详见下方的PRI_DropDown_lib.js文件全部内容展示)
  6. 创建一个User Event Script记录
  7. 用来新建初始化字段(可选,根据实际需要)
  8. 用来初始化字段的HTML内容;这个初始化内容是根据列表中当前的货品来决定的,UI的编辑状态用户是可以随时在界面中切换货品字段内容,从而需要重新初始化该HTML内容(详见实现在下方的Client Script中)
  9. 创建一个Client Script记录
  10. 在列表的line init事件中,初始化当前行的Vendor列表(根据当前的货品字段内容)
  11. 在用户切换了Item货品字段内容后,重新刷新初始化当前行的Vendor列表

相关内容

实现方法

Create Custom Field

新建自定义字段的定义案例:

image-20240619173308707

说明:这个字段的TYPE,用Free Form Text也是一样的。

User Event

to initial the UI to put-in/inject the dropdown HTML

var intDefProject = currentRecord.getValue('custbody_pri_prj');
var clsProjectDropDown = new CLLIB.PROJECTDROPDOWN(intEntityId,
    '');

// Set entity projects dropdown options HTML
var strInitSelectHtml = clsProjectDropDown.initOptionHtml(
    intDefProject, scriptContext.type);
objFldProject.defaultValue = "<div class="uir-field-wrapper" data-field-type="text"><span id="projectdropdown_fs_lbl_uir_label" class="smallgraytextnolink uir-label "><span id="projectdropdown_fs_lbl" class="labelSpanEdit smallgraytextnolink" style="">"
    + "<a tabindex="-1" title="What's this?" href='javascript:void("help")' style="cursor:help"  class="smallgraytextnolink" onmouseover="this.className='smallgraytext'; return true;" onmouseout="this.className='smallgraytextnolink'; ">"
    + strPrjLbl
    + "</a>"
    + "</span></span><span class="uir-field">"
    + strInitSelectHtml + "</span>" + "</div>";

上面这个方案是直接注入到系统的自定义字段中(在NetSuite系统的自定义字段中定义的)

或者使用全新的Script新增的动态的html字段

var objFldProject = objForm.addField({
    id: 'custpage_pri_prj',
    type: ui.FieldType.INLINEHTML,
    label: strPrjLbl
});
objForm.insertField({
    field: objFldProject,
    nextfield: 'custbody_pri_co',// 'job'
});

objFldProject.defaultValue = "<div class="uir-field-wrapper" data-field-type="text"><span id="projectdropdown_fs_lbl_uir_label" class="smallgraytextnolink uir-label "><span id="projectdropdown_fs_lbl" class="labelSpanEdit smallgraytextnolink" style="">"
    + "<a tabindex="-1" title="What's this?" href='javascript:void("help")' style="cursor:help"  class="smallgraytextnolink" onmouseover="this.className='smallgraytext'; return true;" onmouseout="this.className='smallgraytextnolink'; "> "
    + strPrjLbl
    + "</a>"
    + "</span></span><span class="uir-field">"
    + "<select id="custpage_projectdropdown" name="custpage_projectdropdown" autocomplete="off" lineindex="1" class="input uir-custom-field">"
    + "<option value=""> </option>"
    + "</select>"
    + "</span>" + "</div>" + "";


Client Script dynamic behaviors

pageInit

可选,仅用于使用主界面的系统自带自定义字段来‘加载’动态列表的情况下

/**
             * Function to be executed after page is initialized.
             *
             * @param {Object}
             *            scriptContext
             * @param {Record}
             *            scriptContext.currentRecord - Current form record
             * @param {string}
             *            scriptContext.mode - The mode in which the record is
             *            being accessed (create, copy, or edit)
             *
             * @since 2015.2
             */
function pageInit(scriptContext) {

  ...

    // Bind select option change event
    var clsProjectDropDown = new CLLIB.PROJECTDROPDOWN(intEntityId,
          objDropdownDom, {'currentRecord': currentRecord});
    clsProjectDropDown.bindSelectEvent();

  ...
}

fieldChanged


function fieldChanged(scriptContext) {
  switch (scriptContext.fieldId) {

  case ....

      // Bind select option change event
      var clsProjectDropDown = new CLLIB.PROJECTDROPDOWN(
            intEntityId, objDropdownDom, {'currentRecord': currentRecord});

      if (!intEntityId) {
         clsProjectDropDown.clearSelectOptions();
         return true;
      }

      // Draw entity project dropdown options
      clsProjectDropDown.drawDOM();
        break;
    }
}

pri_RetMgr_vendor_refreshSelectOption

function pri_RetMgr_vendor_refreshSelectOption(currentRecord) {

    var lnIdx = currentRecord.getCurrentSublistIndex({
        sublistId: strSublistId_replc
    });
    var intItemId = currentRecord.getCurrentSublistValue(strSublistId_replc, RETMGRLIB.REC_RETURNMGR_REPLC_LINE.ITEM);
    var intVendorId_cur = currentRecord.getCurrentSublistValue(strSublistId_replc, RETMGRLIB.REC_RETURNMGR_REPLC_LINE.VENDOR);

    try {

        var objDropdownDom = document.getElementById('recmachcustrecord_pri_return_mgr_replc_parent_custrecord_pri_return_mgr_replc_selvnd_fs');
        // Bind select option change event
        var clsVendorDropDown = new DDLIB.DROPDOWN(intItemId, objDropdownDom, {'currentRecord': currentRecord});
        jQuery('#recmachcustrecord_pri_return_mgr_replc_parent_custrecord_pri_return_mgr_replc_selvnd_fs').html(clsVendorDropDown.initOptionHtml(intVendorId_cur));
        clsVendorDropDown.bindSelectEvent();

    } catch (ex) {
        console.log(ex);
    }

    // Special case: After you change the item from Item A to Item B, the original vendor value should/might be cleared(since that vendor is not in new Item B’s vendor list)
    if (intVendorId_cur) {
        var bolVendorAvailable = false;
        var arrProjectResObj = clsVendorDropDown.arrProjectResObj;
        for (var i = 0; arrProjectResObj && i < arrProjectResObj.length; i++) {

            if (intVendorId_cur == arrProjectResObj[i].ID){
                bolVendorAvailable = true;
                break;
            }
        }
        if (bolVendorAvailable === false)
            currentRecord.setCurrentSublistValue({
                sublistId: strSublistId_replc,
                fieldId: RETMGRLIB.REC_RETURNMGR_REPLC_LINE.VENDOR,
                value: ''
            });
    }
}

从这个函数可以得知这个自定义的动态下拉字段,初始化的值也是会被加载的,非常顺滑。


Library – PRI_DropDown_lib.js

used in both Client and UserEvent/Server side. 核心公共函数库

//------------------------------------------------------------------
//Developer: Carl
//Description: Need a dynamic Drop Down on the record/transaction of vendor list.
// Thus, the drop down on record/transaction should filter for item that are related.
//------------------------------------------------------------------

/**
 * @NApiVersion 2.x
 * @NModuleScope Public
 */
define(
    ['N/error', 'N/record', 'N/runtime', 'N/search', './PRI_RM_ReturnManager_lib'],
    /**
     * @param {error}
     *            error
     * @param {record}
     *            record
     * @param {runtime}
     *            runtime
     * @param {search}
     *            search
     * @param {dialog}
     *            dialog
     */
    function (error, record, runtime, search, RETMGRLIB) {

        // ---------------------- Drop Down Class-----------------
        function DROPDOWN(intItemId, objDropdownDom, options) {

            this.intItemId = intItemId;
            this.objDropdownDom = objDropdownDom;

            if (intItemId) {

                this.arrProjectResObj = this.lookupVendorRecords();
            }

            this.strInitSelectFld = "<select id="custpage_projectdropdown" name="custpage_projectdropdown" autocomplete="off" lineindex="1" class="input uir-custom-field">"
                + "<option value=""> </option>"
                + 'REPLACE_OPTIONAL_HTML' + "</select>";

            if (options && typeof (options.currentRecord) != 'undefined') {
                this.currentRecord = options.currentRecord;
            }

        }

        /**
         * lookup Vendor Records
         *
         * @param {String}
         *            strLookupKeyWord Lookup Keyword
         * @returns {Object} objOrderStatusSublist
         */
        DROPDOWN.prototype.lookupVendorRecords = function (strLookupKeyWord) {

            var intItemId = this.intItemId;

            if (!intItemId)
                return [];

            var arrProjectRes = [];
            var objFilters = [['isinactive', 'is', 'F']];
            // if (intItemId) {
            //  objFilters.push('and');
            //  objFilters.push([ RETMGRLIB.REC_PRIPROJECT.CUSTOMER, 'anyof',
            //          [ intItemId ] ]);
            // }
            objFilters.push('and');
            objFilters.push(['internalid', 'anyof', [intItemId]]);
            var objColumns = ['itemid', 'displayname', 'othervendor', 'cost'];
            objColumns.push({name: "internalid", join: "vendor"});

            var objRecordRes = search.create(
                {
                    type: 'item',
                    filters: objFilters,
                    columns: objColumns
                }).run().getRange({
                start: 0,
                end: 1000
            });

            for (var i = 0; objRecordRes && i < objRecordRes.length; i++) {

                arrProjectRes.push({
                    ID: objRecordRes[i].getValue({
                        name: "internalid",
                        join: "vendor"
                    }),
                    // NAME: objRecordRes[i].getValue('itemid'),
                    COST: objRecordRes[i].getValue('cost'),
                    VALUE: objRecordRes[i].getText('othervendor')
                });
            }

            return arrProjectRes;
        };

        /**
         * Dynamically clear select options
         *
         * @returns {Boolean}
         */
        DROPDOWN.prototype.clearSelectOptions = function () {

            jQuery("#custpage_projectdropdown").replaceWith(
                this.strInitSelectFld.replace('REPLACE_OPTIONAL_HTML',
                    ''));

            return true;
        };

        /**
         * Draw select option DOM element
         */
        DROPDOWN.prototype.drawDOM = function () {

            var objDropdownDom = this.objDropdownDom;
            var arrProjectResObj = this.arrProjectResObj ? this.arrProjectResObj : this.lookupVendorRecords();

            var strOptionHtml = '';
            for (var idx = 0; idx < arrProjectResObj.length; idx++) {
                strOptionHtml += '<option value="'
                    + arrProjectResObj[idx].ID
                    + '" data-status='
                    + arrProjectResObj[idx].COST
                    + ' title="'
                    + JSON.stringify(arrProjectResObj[idx]).replace(
                        /"/g, "'") + '">'
                    + arrProjectResObj[idx].VALUE + '</option>';
            }
            var strInitSelectFld = this.strInitSelectFld.replace(
                'REPLACE_OPTIONAL_HTML', strOptionHtml);

            jQuery("#custpage_projectdropdown").replaceWith(
                strInitSelectFld);

            this.bindSelectEvent();
        };

        /**
         * Bind Selection Operation <br>
         * Note: Only used in Client Side
         */
        DROPDOWN.prototype.bindSelectEvent = function () {

            jQuery(document).ready(
                function () {
                    jQuery("#custpage_projectdropdown").change(
                        function () {

                            // alert(jQuery(this).val());
                            nlapiSetCurrentLineItemValue('recmachcustrecord_pri_return_mgr_replc_parent', RETMGRLIB.REC_RETURNMGR_REPLC_LINE.VENDOR,
                                jQuery(this).val());

                            // var strStatus = jQuery(this).find(
                            // ":selected").data("status");
                        });
                });
        };

        /**
         * Initial Option HTML, can use in server side
         *
         * @param {integer}
         *            intDefSelectVal Default Contact record type Id
         * @param {string}
         *            strUserEventType User Event Type
         * @returns {string}
         */
        DROPDOWN.prototype.initOptionHtml = function (
            intDefSelectVal, strUserEventType) {

            // if (!this.intItemId)
            // return '';

            var arrProjectResObj = this.arrProjectResObj ? this.arrProjectResObj : this.lookupVendorRecords();

            var strOptionHtml = '';
            for (var idx = 0; idx < arrProjectResObj.length; idx++) {
                if (intDefSelectVal
                    && arrProjectResObj[idx].ID == intDefSelectVal)
                    strOptionHtml += '<option selected="selected" value="'
                        + arrProjectResObj[idx].ID
                        + '" data-status='
                        + arrProjectResObj[idx].COST
                        + ' title="'
                        + JSON.stringify(arrProjectResObj[idx])
                            .replace(/"/g, "'") + '">'
                        + arrProjectResObj[idx].VALUE + '</option>';
                else
                    strOptionHtml += '<option value="'
                        + arrProjectResObj[idx].ID
                        + '" data-status='
                        + arrProjectResObj[idx].COST
                        + ' title="'
                        + JSON.stringify(arrProjectResObj[idx])
                            .replace(/"/g, "'") + '">'
                        + arrProjectResObj[idx].VALUE + '</option>';
            }

            var strInitSelectFld = this.strInitSelectFld;
            if (strUserEventType == 'view')
                strInitSelectFld = strInitSelectFld.replace(
                    '<select id="custpage_projectdropdown"',
                    "<select id="custpage_projectdropdown" disabled");

            strInitSelectFld = strInitSelectFld.replace(
                'REPLACE_OPTIONAL_HTML', strOptionHtml);

            return strInitSelectFld;
        };

        return {
            DROPDOWN: DROPDOWN
        };

    });
  • bindSelectEvent,当完成了HTML展示动态更新的下拉子列表后,绑定新的点击事件;当用户选取新的列表选项后,我们将值传递给另一个系统的字段(或者其他的商业逻辑定义)

衍生阅读

来吧,约个时间当面谈,我很乐意效劳,有什么NetSuite系统定制开发中的疑难杂症我可以帮忙的吗?

image-20240619215304248

随手记录 – 如何下载网页中视频

image-20240615213247707

Firefox插件名称和版本信息

image-20240615213422690

银行支付 接口-CarlZeng

如果您只和工商银行做~那消费者用其他银行的卡就无法支付了~ 建议和第三方在线支付公司合作~ 支付宝接口:https://www.alipay.com/cooperate/btools_shop.htm 财付通接口:https://www.tenpay.com/zft/admin_opentrans.shtml 网银在线接口:http://www.chinabank.com.cn/in…

如果您只和工商银行做~那消费者用其他银行的卡就无法支付了~

建议和第三方在线支付公司合作~

支付宝接口:https://www.alipay.com/cooperate/btools_shop.htm

财付通接口:https://www.tenpay.com/zft/admin_opentrans.shtml

网银在线接口:http://www.chinabank.com.cn/index/index.shtml

上海银联电子支付服务有限公司
http://www.chinapay.com/cpportal/download/download.jsp

远程接入 > CS程序远程接入至总部-CarlZeng

应用背景 目前,分部门或者业务部门和总公司之间的财务数据传输需求很明显和迫切。 常见的:K3,U8。。。等等ERP或财务系统;总公司要求集团化管理,集中管理,权限集中分配 而带宽差异化,以及上下行速率地下等问题,很影响连接传统V*P*N。 虚拟化远程接入 的 产品列表:

应用背景

目前,分部门或者业务部门和总公司之间的财务数据传输需求很明显和迫切。

常见的:K3,U8。。。等等ERP或财务系统;总公司要求集团化管理,集中管理,权限集中分配

而带宽差异化,以及上下行速率地下等问题,很影响连接传统V*P*N。

虚拟化远程接入

产品列表: