sfcc-cartridge-development について
Salesforce Commerce Cloud の開発者向けに、SFRA と B2C Commerce Script API を使った cartridge の構築と拡張を解説します。コントローラー、テンプレート、フォーム、OCAPI/SCAPI 連携、定期ジョブを扱います。
主な機能
- 01cartridge の構築とレイヤリング
- 02SFRA コントローラーの作成と拡張
- 03ISML テンプレートとデータモデルの作成
- 04フォームとサーバー側検証の処理
- 05OCAPI と SCAPI の連携
- 06定期データ処理ジョブの作成
活用シーン
- 01SFRA へのカスタム機能追加
- 02既存コントローラー・テンプレート・モデルの拡張
- 03独自のチェックアウトや決済フローの実装
- 04商品や注文データの同期スケジュール設定
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill sfcc-cartridge-developmentインストール後、Claude Code で/sfcc-cartridge-developmentを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
tile.jsonevals/cartridge-structure-and-scheduled-job/criteria.jsontask.mdevals/controller-extension-and-isml-template/criteria.jsontask.mdevals/ocapi-hooks-and-form-validation/criteria.jsontask.md詳細
SFCC カートリッジ開発
概要
Storefront Reference Architecture(SFRA)、サーバーサイド JavaScript コントローラー、ISML テンプレート、モデル、B2C Commerce Script API を使用して、Salesforce Commerce Cloud(SFCC)向けのカスタムカートリッジを構築します。このスキルでは、カートリッジのレイヤリングとオーバーライドの仕組み、server.js を使用したルート処理、フォーム処理、OCAPI/SCAPI 統合、スケジュールされたデータ処理での Job Framework の使用方法を扱います。
このスキルを使用する場面
- SFRA の機能を拡張するカスタム機能カートリッジを構築する場合
- 既存の SFRA コントローラー、テンプレート、モデルをオーバーライドまたは拡張する場合
- SFCC でカスタムチェックアウトのステップや決済統合を実装する場合
- データのインポート/エクスポート(商品フィード、注文同期)用のスケジュール済みジョブを作成する場合
- ヘッドレスストアフロント向けに OCAPI フックや SCAPI 統合を構築する場合
基本的な手順
-
カートリッジの構造とレイヤリングを設定する
SFCC では、レイヤリングにカートリッジパスを使用します。パスの上位にあるカートリッジが、下位にあるカートリッジをオーバーライドします。カスタムカートリッジは
app_storefront_baseを拡張します。int_acme_custom/ ├── cartridge/ │ ├── controllers/ # Server-side JS controllers │ ├── models/ # Data model wrappers │ ├── scripts/ # Business logic helpers │ ├── templates/ │ │ └── default/ # ISML templates │ ├── forms/ │ │ └── default/ # Form definitions (XML) │ ├── static/ │ │ └── default/ │ │ ├── css/ │ │ └── js/ │ └── int_acme_custom.properties # Cartridge metadata └── package.jsonBusiness Manager でカートリッジパスを設定します。
int_acme_custom:app_storefront_baseint_acme_custom.properties:## cartridge.properties demandware.cartridges.int_acme_custom.multipleLanguageStorefront=true -
サーバーサイドコントローラーを作成する
SFRA のコントローラーでは、ルートの登録に
server.jsを使用します。// controllers/CustomPage.js 'use strict'; var server = require('server'); var cache = require('*/cartridge/scripts/middleware/cache'); var consentTracking = require('*/cartridge/scripts/middleware/consentTracking'); /** * CustomPage-Show : Renders a custom content page * @name CustomPage-Show * @function * @memberof CustomPage * @param {middleware} - server.middleware.https * @param {middleware} - consentTracking.consent * @param {middleware} - cache.applyDefaultCache * @param {querystringparameter} - cid : content asset ID * @param {renders} - isml * @param {serverfunction} - get */ server.get('Show', server.middleware.https, consentTracking.consent, cache.applyDefaultCache, function (req, res, next) { var ContentMgr = require('dw/content/ContentMgr'); var ContentModel = require('*/cartridge/models/content'); var contentId = req.querystring.cid; var apiContent = ContentMgr.getContent(contentId); if (!apiContent) { res.setStatusCode(404); res.render('error/notFound'); return next(); } var contentModel = new ContentModel(apiContent); res.render('custom/contentPage', { content: contentModel, breadcrumbs: [ { htmlValue: 'Home', url: '/' }, { htmlValue: contentModel.name, url: '' } ] }); next(); } ); /** * CustomPage-Submit : Handles form POST submissions */ server.post('Submit', server.middleware.https, function (req, res, next) { var Transaction = require('dw/system/Transaction'); var CustomObjectMgr = require('dw/object/CustomObjectMgr'); var form = req.form; var name = form.name; var email = form.email; // Validate input if (!name || !email) { res.json({ success: false, error: 'Name and email are required.' }); return next(); } try { Transaction.wrap(function () { var co = CustomObjectMgr.createCustomObject('AcmeSubmissions', email); co.custom.name = name; co.custom.submittedAt = new Date(); }); res.json({ success: true, message: 'Submission received.' }); } catch (e) { var Logger = require('dw/system/Logger'); Logger.error('Submission failed: {0}', e.message); res.json({ success: false, error: 'An error occurred. Please try again.' }); } next(); } ); module.exports = server.exports(); -
既存の SFRA コントローラーを拡張する
server.extendを使用して、既存のコントローラーのルートを追加または変更します。// controllers/Cart.js — extending app_storefront_base Cart 'use strict'; var server = require('server'); var page = module.superModule; // Reference to the base Cart controller server.extend(page); /** * Cart-Show : Append custom data to the Cart page */ server.append('Show', function (req, res, next) { var viewData = res.getViewData(); // Add custom upsell products to the cart page var ProductMgr = require('dw/catalog/ProductMgr'); var ArrayList = require('dw/util/ArrayList'); var upsells = new ArrayList(); var basket = require('dw/order/BasketMgr').getCurrentBasket(); if (basket) { var items = basket.getAllProductLineItems(); for (var i = 0; i < items.length; i++) { var recommendations = items[i].product.getRecommendations(); for (var j = 0; j < Math.min(recommendations.length, 2); j++) { upsells.push(recommendations[j].getRecommendedItem()); } } } viewData.upsellProducts = upsells.toArray().slice(0, 4); res.setViewData(viewData); next(); }); /** * Cart-AddCustomItem : New route added to the Cart controller */ server.post('AddCustomItem', function (req, res, next) { var BasketMgr = require('dw/order/BasketMgr'); var Transaction = require('dw/system/Transaction'); var ProductMgr = require('dw/catalog/ProductMgr'); var productId = req.form.pid; var quantity = parseInt(req.form.quantity, 10) || 1; var product = ProductMgr.getProduct(productId); if (!product || !product.isOnline()) { res.json({ error: true, message: 'Product not available.' }); return next(); } var basket = BasketMgr.getCurrentOrNewBasket(); Transaction.wrap(function () { var pli = basket.createProductLineItem(productId, basket.getDefaultShipment()); pli.setQuantityValue(quantity); }); res.json({ success: true, itemCount: basket.productQuantityTotal }); next(); }); module.exports = server.exports(); -
ISML テンプレートを作成する
<--- templates/default/custom/contentPage.isml ---> <isdecorate template="common/layout/page"> <isscript> var assets = require('*/cartridge/scripts/assets'); assets.addCss('/css/custom/content.css'); assets.addJs('/js/custom/content.js'); </isscript> <div class="container custom-content-page"> <div class="row"> <div class="col-12"> <nav aria-label="Breadcrumb"> <ol class="breadcrumb"> <isloop items="${pdict.breadcrumbs}" var="crumb" status="loopstatus"> <isif condition="${loopstatus.last}"> <li class="breadcrumb-item active">${crumb.htmlValue}</li> <iselse/> <li class="breadcrumb-item"> <a href="${crumb.url}">${crumb.htmlValue}</a> </li> </isif> </isloop> </ol> </nav> <h1>${pdict.content.name}</h1> <div class="content-body"> <isprint value="${pdict.content.body}" encoding="off"/> </div> </div> </div> </div> </isdecorate> -
データモデルのラッパーを作成する
// models/content.js 'use strict'; var URLUtils = require('dw/web/URLUtils'); /** * Content model wrapping a dw.content.Content API object * @param {dw.content.Content} contentObj - Content API object * @constructor */ function ContentModel(contentObj) { this.id = contentObj.ID; this.name = contentObj.name || contentObj.ID; this.body = contentObj.custom.body ? contentObj.custom.body.markup : ''; this.online = contentObj.online; this.url = URLUtils.url('CustomPage-Show', 'cid', contentObj.ID).toString(); this.pageTitle = contentObj.pageTitle || this.name; this.pageDescription = contentObj.pageDescription || ''; this.pageKeywords = contentObj.pageKeywords || ''; } module.exports = ContentModel; -
データ処理用のスケジュール済みジョブを構築する
// scripts/jobs/syncInventory.js 'use strict'; var Status = require('dw/system/Status'); var Logger = require('dw/system/Logger').getLogger('inventory-sync', 'acme'); var HTTPClient = require('dw/net/HTTPClient'); var Transaction = require('dw/system/Transaction'); var ProductInventoryMgr = require('dw/catalog/ProductInventoryMgr'); /** * Job step: Fetch inventory from external ERP and update SFCC * @param {dw.util.HashMap} params - Job step parameters * @returns {dw.system.Status} - Job status */ function execute(params) { var apiUrl = params.get('apiUrl'); var apiKey = params.get('apiKey'); var inventoryListId = params.get('inventoryListId') || 'default'; var httpClient = new HTTPClient(); httpClient.open('GET', apiUrl); httpClient.setRequestHeader('Authorization', 'Bearer ' + apiKey); httpClient.setRequestHeader('Accept', 'application/json'); httpClient.setTimeout(30000); httpClient.send(); if (httpClient.statusCode !== 200) { Logger.error('ERP API returned status {0}', httpClient.statusCode); return new Status(Status.ERROR, 'API_ERROR', 'ERP API returned ' + httpClient.statusCode); } var inventory = JSON.parse(httpClient.text); var inventoryList = ProductInventoryMgr.getInventoryList(inventoryListId); if (!inventoryList) { return new Status(Status.ERROR, 'LIST_NOT_FOUND', 'Inventory list not found'); } var updated = 0; var errors = 0; inventory.items.forEach(function (item) { try { Transaction.wrap(function () { var record = inventoryList.getRecord(item.sku); if (!record) { record = inventoryList.createRecord(item.sku); } record.setAllocation(item.quantity); if (item.inStockDate) { record.setInStockDate(new Date(item.inStockDate)); } }); updated++; } catch (e) { Logger.error('Failed to update SKU {0}: {1}', item.sku, e.message); errors++; } }); Logger.info('Inventory sync complete: {0} updated, {1} errors', updated, errors); return new Status(Status.OK, 'SYNC_COMPLETE', updated + ' records updated'); } module.exports.execute = execute;
例
注文作成用の OCAPI フック
// hooks/order/ocapiHooks.js
'use strict';
var Status = require('dw/system/Status');
var Logger = require('dw/system/Logger').getLogger('ocapi-hooks', 'acme');
/**
* OCAPI after-POST hook for order creation
* Called after a new order is placed via OCAPI
*/
exports.afterPOST = function (order) {
try {
// Send order data to external analytics
var HTTPClient = require('dw/net/HTTPClient');
var httpClient = new HTTPClient();
httpClient.open('POST', 'https://analytics.acme.com/orders');
httpClient.setRequestHeader('Content-Type', 'application/json');
httpClient.send(JSON.stringify({
orderId: order.orderNo,
total: order.totalGrossPrice.value,
currency: order.currencyCode,
itemCount: order.productLineItems.length,
customerEmail: order.customerEmail,
}));
if (httpClient.statusCode !== 200) {
Logger.warn('Analytics push failed for order {0}: HTTP {1}',
order.orderNo, httpClient.statusCode);
}
} catch (e) {
Logger.error('OCAPI hook error: {0}', e.message);
}
return new Status(Status.OK);
};
hooks.json に登録します。
{
"hooks": [
{
"name": "dw.ocapi.shop.order.afterPOST",
"script": "./hooks/order/ocapiHooks"
}
]
}
フォーム定義とサーバーサイドの検証
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.demandware.com/xml/form/2008-04-19">
<field formid="name" label="form.contactus.name"
type="string" mandatory="true" max-length="100"/>
<field formid="email" label="form.contactus.email"
type="string" mandatory="true" max-length="254"
regexp="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"/>
<field formid="message" label="form.contactus.message"
type="string" mandatory="true" max-length="2000"/>
<action formid="submit" label="form.contactus.submit" valid-form="true"/>
</form>
// controllers/ContactUs.js
'use strict';
var server = require('server');
server.get('Show', function (req, res, next) {
var contactForm = server.forms.getForm('contactus');
contactForm.clear();
res.render('contactus/form', { contactForm: contactForm });
next();
});
server.post('Submit', function (req, res, next) {
var contactForm = server.forms.getForm('contactus');
if (contactForm.valid) {
var Transaction = require('dw/system/Transaction');
var CustomObjectMgr = require('dw/object/CustomObjectMgr');
Transaction.wrap(function () {
var co = CustomObjectMgr.createCustomObject(
'ContactSubmission',
require('dw/util/UUIDUtils').createUUID()
);
co.custom.name = contactForm.name.value;
co.custom.email = contactForm.email.value;
co.custom.message = contactForm.message.value;
});
res.json({ success: true });
} else {
res.json({
success: false,
fields: {
name: contactForm.name.error || null,
email: contactForm.email.error || null,
message: contactForm.message.error || null,
}
});
}
next();
});
module.exports = server.exports();
ベストプラクティス
- カートリッジのレイヤリング規則に従います -- カスタムカートリッジでベースカートリッジをオーバーライドします。コントローラーを置き換えるのではなく、
module.superModuleを使用して拡張します server.replaceよりもserver.appendを使用します -- 追加することで、元のコントローラーロジックと他のカートリッジ拡張が維持されます。置き換えると処理の連鎖が途切れます- すべてのデータベース書き込みを
Transaction.wrap()で囲みます -- SFCC では、永続的な変更すべてに明示的なトランザクションが必要です。トランザクションがないと、エラーが表面化せずに失敗します - データベースに直接アクセスせず、Script API を使用します -- SFCC には直接 SQL アクセスがありません。データ操作には必ず
*Mgrクラス(ProductMgr、BasketMgr、OrderMgr)を使用します - カテゴリ別ロガーでログを記録します --
Logger.getLogger(category, prefix)を使用すると、Log Center でカテゴリ別にログメッセージを絞り込めます - サイト固有の値をハードコードしません -- API キーや機能フラグなど、設定可能な値には Site Preferences(Business Manager で設定するカスタムサイト設定)を使用します
- SFCC サンドボックスでテストします -- ステージング環境または本番環境にデプロイする前に、必ずサンドボックスインスタンスで開発とテストを行います
- SFCC の lint ルールを使用します --
'use strict'を徹底し、コントローラーでnext()の呼び出し漏れがないか確認します。呼び出し漏れがあると、リクエストがハングします
よくある落とし穴
| 問題 | 解決策 |
|---|---|
| コントローラールートが見つからない(404) | カートリッジがカートリッジパスに含まれていること(Business Manager > Sites > Manage Sites > Cartridges)と、コントローラーファイル名がルートと一致していることを確認します |
module.superModule が null を返す | ベースカートリッジがカートリッジパスでカスタムカートリッジより後に指定されていることを確認します。順序が重要です |
| テンプレートの変更が反映されない | Business Manager で SFCC テンプレートキャッシュをクリアします(Administration > Sites > Manage Sites > Cache)。ISML テンプレートは頻繁にキャッシュされます |
| カスタムオブジェクトが永続化されない | 処理が Transaction.wrap() 内で実行されていることを確認します。Business Manager でカスタムオブジェクトのタイプが定義されていることも確認します(Administration > Site Development > Custom Objects) |
| ジョブステップがエラーを報告せずに失敗する | すべてのジョブステップから Status オブジェクトを返します。失敗時に Status.ERROR を返すと、ジョブフレームワークが失敗を正しく報告できます |
ISML の <isprint> で HTML が二重にエスケープされる | 信頼できる HTML コンテンツ(CMS 本文のマークアップなど)には <isprint> で encoding="off" を使用します。ユーザー生成コンテンツにはデフォルトのエンコーディングを使用します |
関連スキル
- @erp-integration
- @ecommerce-caching
- @ecommerce-seo
- @pci-dss-compliance
- @product-data-modeling