docs: add ERP recipe + Odoo guide (#11594)
* initial * finish first draft * finish odoo and erp guides * add mention of completeCartWorkflow * lint content * iteration to intro * add meta image * small fix
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
Stripe,
|
||||
PhotoSolid,
|
||||
BuildingsSolid,
|
||||
CircleStackSolid,
|
||||
} from "@medusajs/icons"
|
||||
|
||||
# Medusa Development Resources
|
||||
@@ -117,10 +118,10 @@ Follow the [Medusa Docs](!docs!/learn) to become an advanced Medusa developer.
|
||||
href: "/recipes/digital-products",
|
||||
},
|
||||
{
|
||||
type: "filler",
|
||||
text: "Find more recipes to implement your use case.",
|
||||
href: "/recipes"
|
||||
}
|
||||
icon: CircleStackSolid,
|
||||
title: "Integrate ERP",
|
||||
href: "/recipes/erp",
|
||||
},
|
||||
]} />
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
import { Prerequisites, WorkflowDiagram } from "docs-ui"
|
||||
|
||||
export const metadata = {
|
||||
title: `Integrate Odoo with Medusa`,
|
||||
}
|
||||
|
||||
# {metadata.title}
|
||||
|
||||
In this guide, you will learn how to implement the integration layer between Odoo and Medusa.
|
||||
|
||||
When you install a Medusa application, you get a fully-fledged commerce platform that supports customizations. However, your business might already be using other systems such as an ERP to centralize data and processes. Medusa's framework facilitates integrating the ERP system and using its data to enrich your commerce platform.
|
||||
|
||||
Odoo is a suite of open-source business apps that covers all your business needs, including an ERP system. You can use Odoo to store products and their prices, manage orders, and more.
|
||||
|
||||
This guide will teach you how to implement the general integration between Medusa and Odoo. You will learn how to connect to Odoo's APIs and fetch data such as products. You can then expand on this integration to implement your business requirements. You can also refer to [this recipe](../page.mdx) to find general examples of ERP integration use cases and how to implement them.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Install a Medusa Application
|
||||
|
||||
<Prerequisites items={[
|
||||
{
|
||||
text: "Node.js v20+",
|
||||
link: "https://nodejs.org/en/download"
|
||||
},
|
||||
{
|
||||
text: "Git CLI tool",
|
||||
link: "https://git-scm.com/downloads"
|
||||
},
|
||||
{
|
||||
text: "PostgreSQL",
|
||||
link: "https://www.postgresql.org/download/"
|
||||
}
|
||||
]} />
|
||||
|
||||
Start by installing the Medusa application on your machine with the following command:
|
||||
|
||||
```bash
|
||||
npx create-medusa-app@latest
|
||||
```
|
||||
|
||||
You will first be asked for the project's name. You can also optionally choose to install the [Next.js starter storefront](../../../nextjs-starter/page.mdx).
|
||||
|
||||
Afterward, the installation process will start, installing the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name.
|
||||
|
||||
<Note title="Why is the storefront installed separately">
|
||||
|
||||
The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](!docs!/learn/fundamentals/api-routes). Learn more about Medusa's architecture in [this documentation](!docs!/learn/introduction/architecture).
|
||||
|
||||
</Note>
|
||||
|
||||
Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard.
|
||||
|
||||
<Note title="Ran into Errors">
|
||||
|
||||
Check out the [troubleshooting guides](../../../troubleshooting/create-medusa-app-errors/page.mdx) for help.
|
||||
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Install JSONRPC Package
|
||||
|
||||
[Odoo's APIs](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html) are based on the XML-RPC and JSON-RPC protocols. So, to connect to Odoo's APIs, you need a JSON-RPC client library.
|
||||
|
||||
Run the following command in the Medusa application to install the `json-rpc-2.0` package:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install json-rpc-2.0
|
||||
```
|
||||
|
||||
You will use this package in the next steps to connect to Odoo's APIs.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Odoo Module
|
||||
|
||||
To integrate third-party systems into Medusa, you create a custom [module](!docs!/learn/fundamentals/modules). A module is a re-usable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup.
|
||||
|
||||
In this step, you'll create an Odoo Module that provides the interface to connect to and interact with Odoo. You will later use this module when implementing the product syncing logic.
|
||||
|
||||
<Note>
|
||||
|
||||
Learn more about modules in [this documentation](!docs!/learn/fundamentals/modules).
|
||||
|
||||
</Note>
|
||||
|
||||
### Create Module Directory
|
||||
|
||||
A module is created under the `src/modules` directory of your Medusa application. So, create the directory `src/modules/odoo`.
|
||||
|
||||

|
||||
|
||||
### Create Service
|
||||
|
||||
You define a module's functionalities in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to the database, which is useful if your module defines tables in the database, or connect to a third-party service.
|
||||
|
||||
Medusa registers the module's service in the [Medusa container](!docs!/learn/fundamentals/medusa-container), allowing you to easily resolve the service from other customizations and use its methods.
|
||||
|
||||
<Note title="What is the Medusa Container?">
|
||||
|
||||
The Medusa application registers resources, such as a module's service or the [logging tool](!docs!/learn/debugging-and-testing/logging), in the Medusa container so that you can resolve them from other customizations, as you'll see in later sections. Learn more about it in [this documentation](!docs!/learn/fundamentals/medusa-container).
|
||||
|
||||
</Note>
|
||||
|
||||
In this section, you'll create the Odoo Module's service and the methods necessary to connect to Odoo.
|
||||
|
||||
To create the service, create the file `src/modules/odoo/service.ts` with the following content:
|
||||
|
||||

|
||||
|
||||
```ts title="src/modules/odoo/service.ts"
|
||||
import { JSONRPCClient } from "json-rpc-2.0"
|
||||
|
||||
type Options = {
|
||||
url: string
|
||||
dbName: string
|
||||
username: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export default class OdooModuleService {
|
||||
private options: Options
|
||||
private client: JSONRPCClient
|
||||
|
||||
constructor({}, options: Options) {
|
||||
this.options = options
|
||||
|
||||
this.client = new JSONRPCClient((jsonRPCRequest) => {
|
||||
fetch(`${options.url}/jsonrpc`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(jsonRPCRequest),
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
// Use client.receive when you received a JSON-RPC response.
|
||||
return response
|
||||
.json()
|
||||
.then((jsonRPCResponse) => this.client.receive(jsonRPCResponse))
|
||||
} else if (jsonRPCRequest.id !== undefined) {
|
||||
return Promise.reject(new Error(response.statusText))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You create an `OdooModuleService` class that has two class properties:
|
||||
|
||||
1. `options`: An object that holds the Odoo Module's options. Those include the API key, URL, database name, and username. You'll learn how to pass those to the module later.
|
||||
2. `client`: An instance of the `JSONRPCClient` class from the `json-rpc-2.0` package. You'll use this client to connect to Odoo's APIs.
|
||||
|
||||
The service's constructor accepts as a second parameter the module's options. So, you use those to initialize the `options` property and create the `client` property. The `client` property is initialized with a function that sends a JSON-RPC request to Odoo's API and receives the response.
|
||||
|
||||
Next, you will add the methods to log in and fetch data from Odoo.
|
||||
|
||||
### Login Method
|
||||
|
||||
Before sending any request to Odoo's APIs, you need to have an authenticated UID of the user. So, you'll implement a method to retrieve that UID when it's not set.
|
||||
|
||||
Start by adding a `uid` property to the `OdooModuleService` class:
|
||||
|
||||
```ts title="src/modules/odoo/service.ts"
|
||||
export default class OdooModuleService {
|
||||
private uid?: number
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Then, add the following `login` method:
|
||||
|
||||
```ts title="src/modules/odoo/service.ts"
|
||||
export default class OdooModuleService {
|
||||
// ...
|
||||
async login() {
|
||||
this.uid = await this.client.request("call", {
|
||||
service: "common",
|
||||
method: "authenticate",
|
||||
args: [
|
||||
this.options.dbName,
|
||||
this.options.username,
|
||||
this.options.apiKey,
|
||||
{},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `login` method sends a JSON-RPC request to [Odoo's API to authenticate the user](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html#logging-in). It uses the `client` property to send a request with the `service`, `method`, and `args` properties.
|
||||
|
||||
If the authentication was successful, Odoo returns a UID, which you store in the `uid` property.
|
||||
|
||||
### Fetch Products Method
|
||||
|
||||
You can fetch many data from Odoo based on your business requirements, or create data in Odoo. For this guide, you'll only learn how to fetch products. You will use this method later to sync products from Odoo to Medusa.
|
||||
|
||||
First, add the following types to `src/modules/odoo/service.ts`:
|
||||
|
||||
```ts title="src/modules/odoo/service.ts"
|
||||
export type Pagination = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type OdooProduct = {
|
||||
id: number
|
||||
display_name: string
|
||||
is_published: boolean
|
||||
website_url: string
|
||||
name: string
|
||||
list_price: number
|
||||
description: string | false
|
||||
description_sale: string | false
|
||||
product_variant_ids: OdooProductVariant[]
|
||||
qty_available: number
|
||||
location_id: number | false
|
||||
taxes_id: number[]
|
||||
hs_code: string | false
|
||||
allow_out_of_stock_order: boolean
|
||||
is_kits: boolean
|
||||
image_1920: string
|
||||
image_1024: string
|
||||
image_512: string
|
||||
image_256: string
|
||||
image_128: string
|
||||
attribute_line_ids: {
|
||||
attribute_id: {
|
||||
display_name: string
|
||||
}
|
||||
value_ids: {
|
||||
display_name: string
|
||||
}[]
|
||||
}[]
|
||||
currency_id: {
|
||||
id: number
|
||||
display_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type OdooProductVariant = Omit<
|
||||
OdooProduct,
|
||||
"product_variant_ids" | "attribute_line_ids"
|
||||
> & {
|
||||
product_template_variant_value_ids: {
|
||||
id: number
|
||||
name: string
|
||||
attribute_id: {
|
||||
display_name: string
|
||||
}
|
||||
}[]
|
||||
code: string
|
||||
}
|
||||
```
|
||||
|
||||
You define the following types:
|
||||
|
||||
- `Pagination`: An object that holds the pagination options for fetching products.
|
||||
- `OdooProduct`: An object that represents an Odoo product. You define the properties that you'll fetch from Odoo's API. You can add more properties based on your business requirements.
|
||||
- `OdooProductVariant`: An object that represents an Odoo product variant. You define the properties that you'll fetch from Odoo's API. You can add more properties based on your business requirements.
|
||||
|
||||
Then, add the following `listProducts` method to the `OdooModuleService` class:
|
||||
|
||||
```ts title="src/modules/odoo/service.ts"
|
||||
export default class OdooModuleService {
|
||||
// ...
|
||||
async listProducts(filters?: any, pagination?: Pagination) {
|
||||
if (!this.uid) {
|
||||
await this.login()
|
||||
}
|
||||
|
||||
const { offset, limit } = pagination || { offset: 0, limit: 10 }
|
||||
|
||||
const ids = await this.client.request("call", {
|
||||
service: "object",
|
||||
method: "execute_kw",
|
||||
args: [
|
||||
this.options.dbName,
|
||||
this.uid,
|
||||
this.options.apiKey,
|
||||
"product.template",
|
||||
"search",
|
||||
filters || [[
|
||||
["is_product_variant", "=", false],
|
||||
]], {
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// TODO retrieve product details based on ids
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the `listProducts` method, you first check if the user is authenticated, and call the `login` method otherwise. Then, you send a JSON-RPC request to retrieve product IDs from Odoo with pagination and filter options. Odoo's APIs require you to first retrieve the IDs of the products and then fetch the details of each product.
|
||||
|
||||
To retrieve the products, replace the `TODO` with the following:
|
||||
|
||||
```ts title="src/modules/odoo/service.ts"
|
||||
// product fields to retrieve
|
||||
const productSpecifications = {
|
||||
id: {},
|
||||
display_name: {},
|
||||
is_published: {},
|
||||
website_url: {},
|
||||
name: {},
|
||||
list_price: {},
|
||||
description: {},
|
||||
description_sale: {},
|
||||
qty_available: {},
|
||||
location_id: {},
|
||||
taxes_id: {},
|
||||
hs_code: {},
|
||||
allow_out_of_stock_order: {},
|
||||
is_kits: {},
|
||||
image_1920: {},
|
||||
image_1024: {},
|
||||
image_512: {},
|
||||
image_256: {},
|
||||
currency_id: {
|
||||
fields: {
|
||||
display_name: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// retrieve products
|
||||
const products: OdooProduct[] = await this.client.request("call", {
|
||||
service: "object",
|
||||
method: "execute_kw",
|
||||
args: [
|
||||
this.options.dbName,
|
||||
this.uid,
|
||||
this.options.apiKey,
|
||||
type,
|
||||
"web_read",
|
||||
[ids],
|
||||
{
|
||||
specification: {
|
||||
...productSpecifications,
|
||||
product_variant_ids: {
|
||||
fields: {
|
||||
...productSpecifications,
|
||||
product_template_variant_value_ids: {
|
||||
fields: {
|
||||
name: {},
|
||||
attribute_id: {
|
||||
fields: {
|
||||
display_name: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
context: {
|
||||
show_attribute: false,
|
||||
},
|
||||
},
|
||||
code: {},
|
||||
},
|
||||
context: {
|
||||
show_code: false,
|
||||
},
|
||||
},
|
||||
attribute_line_ids: {
|
||||
fields: {
|
||||
attribute_id: {
|
||||
fields: {
|
||||
display_name: {},
|
||||
},
|
||||
},
|
||||
value_ids: {
|
||||
fields: {
|
||||
display_name: {},
|
||||
},
|
||||
context: {
|
||||
show_attribute: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return products
|
||||
```
|
||||
|
||||
You first define the `productSpecifications` object that holds the fields you want to fetch for each product and its variants. So, if you want to add more fields, you can add them in this object.
|
||||
|
||||
Then, you send a request to Odoo to fetch the products' details based on the IDs you retrieved earlier. You use the `productSpecifications` object to define the fields you want to fetch for each product and its variants. Finally, you return the fetched products.
|
||||
|
||||
You will use the `listProducts` method to sync products from Odoo to Medusa in the next steps.
|
||||
|
||||
### Export Module Definition
|
||||
|
||||
The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service.
|
||||
|
||||
So, create the file `src/modules/odoo/index.ts` with the following content:
|
||||
|
||||

|
||||
|
||||
```ts title="src/modules/odoo/index.ts"
|
||||
import OdooModuleService from "./service"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
export const ODOO_MODULE = "odoo"
|
||||
|
||||
export default Module(ODOO_MODULE, {
|
||||
service: OdooModuleService,
|
||||
})
|
||||
```
|
||||
|
||||
You use the `Module` function from the Modules SDK to create the module's definition. It accepts two parameters:
|
||||
|
||||
1. The module's name, which is `odoo`.
|
||||
2. An object with a required property `service` indicating the module's service.
|
||||
|
||||
### Add Module to Medusa's Configurations
|
||||
|
||||
Once you finish building the module, add it to Medusa's configurations to start using it.
|
||||
|
||||
In `medusa-config.ts`, add a `modules` property and pass an array with your custom module:
|
||||
|
||||
```ts title="medusa-config.ts"
|
||||
module.exports = defineConfig({
|
||||
// ...
|
||||
modules: [
|
||||
{
|
||||
resolve: "./src/modules/odoo",
|
||||
options: {
|
||||
url: process.env.ODOO_URL,
|
||||
dbName: process.env.ODOO_DB_NAME,
|
||||
username: process.env.ODOO_USERNAME,
|
||||
apiKey: process.env.ODOO_API_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. Modules that accept options also have an `options` property. You pass the options you defined in the `OdooModuleService` class to the module.
|
||||
|
||||
Then, set the environment variables in the `.env` file or system environment variables:
|
||||
|
||||
```plain
|
||||
ODOO_URL=https://medusa8.odoo.com
|
||||
ODOO_DB_NAME=medusa8
|
||||
ODOO_USERNAME=test@gmail.com # username or email
|
||||
ODOO_API_KEY=12345...
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `ODOO_URL`: The URL of your Odoo instance, which is of the format `https://<domain>.odoo.com`.
|
||||
- `ODOO_DB_NAME`: The name of the database in your Odoo instance, which is the same as the domain in the URL.
|
||||
- `ODOO_USERNAME`: The username or email of an Odoo user.
|
||||
- `ODOO_API_KEY`: The API key of an Odoo user, or the user's password. To retrieve an API Key:
|
||||
- On your Odoo dashboard, click on the user's avatar at the top right and choose "My Profile" from the dropdown.
|
||||
|
||||

|
||||
|
||||
- On your profile's page, click the "Account Security" tab, then the "New API Key" button.
|
||||
|
||||

|
||||
|
||||
- In the pop-up that opens, enter your password.
|
||||
- Enter the API Key's name, and set the expiration to "Persistent Key", then click the "Generate Key" button.
|
||||
|
||||

|
||||
|
||||
- Copy the generated API Key and use it as the `ODOO_API_KEY` environment variable's value.
|
||||
|
||||
You will test that the Odoo Module works as expected in the next steps.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Sync Products from Odoo to Medusa
|
||||
|
||||
There are different use cases you can implement when integrating an ERP like Odoo. One of them is syncing products from the ERP to Medusa. This way, you can manage products in Odoo and have them reflected in your commerce platform.
|
||||
|
||||
To implement the syncing functionality, you need to create a [workflow](!docs!/learn/fundamentals/workflows). A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow similar to how you create a JavaScript function, but with additional features like defining rollback logic for each step, performing long actions asynchronously, and tracking the progress of the steps.
|
||||
|
||||
After defining the workflow, you can execute it in other customizations, such as periodically or when an event occurs.
|
||||
|
||||
In this section, you'll create a workflow that syncs products from Odoo to Medusa. Then, you'll execute that workflow once a day using a [scheduled job](!docs!/learn/fundamentals/scheduled-jobs). The workflow has the following steps:
|
||||
|
||||
<WorkflowDiagram
|
||||
workflow={{
|
||||
name: "get-products-from-erp",
|
||||
steps: [
|
||||
{
|
||||
type: "step",
|
||||
name: "getProductsFromErp",
|
||||
description: "Fetch products from Odoo",
|
||||
depth: 1,
|
||||
},
|
||||
{
|
||||
type: "step",
|
||||
name: "useQueryGraphStep",
|
||||
description: "Get Medusa store configurations to use when creating the products.",
|
||||
depth: 1,
|
||||
link: "/references/helper-steps/useQueryGraphStep",
|
||||
},
|
||||
{
|
||||
type: "step",
|
||||
name: "useQueryGraphStep",
|
||||
description: "Get Medusa sales channels to use when creating the products.",
|
||||
depth: 1,
|
||||
link: "/references/helper-steps/useQueryGraphStep",
|
||||
},
|
||||
{
|
||||
type: "step",
|
||||
name: "useQueryGraphStep",
|
||||
description: "Get Medusa shipping profiles to use when creating the products.",
|
||||
depth: 1,
|
||||
link: "/references/helper-steps/useQueryGraphStep",
|
||||
},
|
||||
{
|
||||
type: "step",
|
||||
name: "createProductsWorkflow",
|
||||
description: "Create new products in Medusa.",
|
||||
depth: 1,
|
||||
link: "/references/medusa-workflows/createProductsWorkflow",
|
||||
},
|
||||
{
|
||||
type: "step",
|
||||
name: "updateProductsWorkflow",
|
||||
description: "Update existing products in Medusa.",
|
||||
depth: 1,
|
||||
link: "/references/medusa-workflows/updateProductsWorkflow",
|
||||
}
|
||||
]
|
||||
}}
|
||||
hideLegend
|
||||
/>
|
||||
|
||||
The only step you'll need to implement is the `getProductsFromErp` step. The other steps are available through Medusa's `@medusajs/medusa/core-flows` package.
|
||||
|
||||
### getProductsFromErp
|
||||
|
||||
The first step of the workflow is to retrieve the products from the ERP. So, create the file `src/workflows/sync-from-erp.ts` with the following content:
|
||||
|
||||

|
||||
|
||||
```ts title="src/workflows/sync-from-erp.ts"
|
||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
type Input = {
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
const getProductsFromErp = createStep(
|
||||
"get-products-from-erp",
|
||||
async (input: Input, { container }) => {
|
||||
const odooModuleService = container.resolve("odoo")
|
||||
|
||||
const products = await odooModuleService.listProducts(undefined, input)
|
||||
|
||||
return new StepResponse(products)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
You create a step using `createStep` from the Workflows SDK. It accepts two parameters:
|
||||
|
||||
1. The step's name, which is `get-products-from-erp`.
|
||||
2. An async function that executes the step's logic. The function receives two parameters:
|
||||
- The input data for the step, which are the pagination fields `offset` and `limit`.
|
||||
- An object holding the workflow's context, including the [Medusa Container](!docs!learn/fundamentals/medusa-container) that allows you to resolve framework and commerce tools.
|
||||
|
||||
In this step, you resolve the Odoo Module's service from the container and use its `listProducts` method to fetch products from Odoo. You pass the pagination options from the input data to the method.
|
||||
|
||||
A step must return an instance of `StepResponse` which accepts as a parameter the data to return, which is in this case the products.
|
||||
|
||||
### Create Workflow
|
||||
|
||||
You can now create the workflow that syncs the products from Odoo to Medusa.
|
||||
|
||||
In the same `src/workflows/sync-from-erp.ts` file, add the following imports:
|
||||
|
||||
```ts title="src/workflows/sync-from-erp.ts"
|
||||
import {
|
||||
createWorkflow, transform, WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import {
|
||||
createProductsWorkflow, updateProductsWorkflow, useQueryGraphStep,
|
||||
} from "@medusajs/medusa/core-flows"
|
||||
import {
|
||||
CreateProductWorkflowInputDTO, UpdateProductWorkflowInputDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
```
|
||||
|
||||
Then, add the workflow after the step:
|
||||
|
||||
```ts title="src/workflows/sync-from-erp.ts"
|
||||
export const syncFromErpWorkflow = createWorkflow(
|
||||
"sync-from-erp",
|
||||
(input: Input) => {
|
||||
const odooProducts = getProductsFromErp(input)
|
||||
|
||||
// @ts-ignore
|
||||
const { data: stores } = useQueryGraphStep({
|
||||
entity: "store",
|
||||
fields: [
|
||||
"default_sales_channel_id",
|
||||
],
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const { data: shippingProfiles } = useQueryGraphStep({
|
||||
entity: "shipping_profile",
|
||||
fields: ["id"],
|
||||
pagination: {
|
||||
take: 1,
|
||||
},
|
||||
}).config({ name: "shipping-profile" })
|
||||
|
||||
const externalIdsFilters = transform({
|
||||
odooProducts,
|
||||
}, (data) => {
|
||||
return data.odooProducts.map((product) => `${product.id}`)
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const { data: existingProducts } = useQueryGraphStep({
|
||||
entity: "product",
|
||||
fields: ["id", "external_id", "variants.*"],
|
||||
filters: {
|
||||
// @ts-ignore
|
||||
external_id: externalIdsFilters,
|
||||
},
|
||||
}).config({ name: "existing-products" })
|
||||
|
||||
// TODO prepare products to create and update
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
You create a workflow using `createWorkflow` from the Workflows SDK. It accepts the workflow's unique name as a first parameter.
|
||||
|
||||
It accepts as a second parameter a constructor function, which is the workflow's implementation. The function receives the pagination options as a parameter. In the workflow, you:
|
||||
|
||||
- Call the `getProductsFromErp` step to fetch products from Odoo.
|
||||
- Use the [useQueryGraphStep](/references/helper-steps/useQueryGraphStep) to fetch the Medusa store configurations, sales channels, and shipping profiles. You'll use this data when creating the products in a later step.
|
||||
- The `useQueryGraphStep` uses [Query](!docs!/learn/fundamentals/module-links/query), which is a tool that retrieves data across modules.
|
||||
- To figure out which products need to be updated, you retrieve products filtered by their `external_id` field, which you'll set to the Odoo product's ID when you create the products next.
|
||||
- Notice that you use `transform` from the Workflows SDK to create the external IDs filters. That's because data manipulation is not allowed in a workflow. You can learn more about this and other restrictions in [this documentation](!docs!/learn/fundamentals/workflows/constructor-constraints).
|
||||
|
||||
Next, you need to prepare the products that should be created or updated. To do that, replace the `TODO` with the following:
|
||||
|
||||
```ts title="src/workflows/sync-from-erp.ts"
|
||||
const {
|
||||
productsToCreate,
|
||||
productsToUpdate,
|
||||
} = transform({
|
||||
existingProducts,
|
||||
odooProducts,
|
||||
shippingProfiles,
|
||||
stores,
|
||||
}, (data) => {
|
||||
const productsToCreate: CreateProductWorkflowInputDTO[] = []
|
||||
const productsToUpdate: UpdateProductWorkflowInputDTO[] = []
|
||||
|
||||
data.odooProducts.forEach((odooProduct) => {
|
||||
const product: CreateProductWorkflowInputDTO | UpdateProductWorkflowInputDTO = {
|
||||
external_id: `${odooProduct.id}`,
|
||||
title: odooProduct.display_name,
|
||||
description: odooProduct.description || odooProduct.description_sale || "",
|
||||
status: odooProduct.is_published ? "published" : "draft",
|
||||
options: odooProduct.attribute_line_ids.length ? odooProduct.attribute_line_ids.map((attribute) => {
|
||||
return {
|
||||
title: attribute.attribute_id.display_name,
|
||||
values: attribute.value_ids.map((value) => value.display_name),
|
||||
}
|
||||
}) : [
|
||||
{
|
||||
title: "Default",
|
||||
values: ["Default"],
|
||||
},
|
||||
],
|
||||
hs_code: odooProduct.hs_code || "",
|
||||
handle: odooProduct.website_url.replace("/shop/", ""),
|
||||
variants: [],
|
||||
shipping_profile_id: data.shippingProfiles[0].id,
|
||||
sales_channels: [
|
||||
{
|
||||
id: data.stores[0].default_sales_channel_id || "",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const existingProduct = data.existingProducts.find((p) => p.external_id === product.external_id)
|
||||
if (existingProduct) {
|
||||
product.id = existingProduct.id
|
||||
}
|
||||
|
||||
if (odooProduct.product_variant_ids?.length) {
|
||||
product.variants = odooProduct.product_variant_ids.map((variant) => {
|
||||
const options = {}
|
||||
if (variant.product_template_variant_value_ids.length) {
|
||||
variant.product_template_variant_value_ids.forEach((value) => {
|
||||
options[value.attribute_id.display_name] = value.name
|
||||
})
|
||||
} else {
|
||||
product.options?.forEach((option) => {
|
||||
options[option.title] = option.values[0]
|
||||
})
|
||||
}
|
||||
return {
|
||||
id: existingProduct ? existingProduct.variants.find((v) => v.sku === variant.code)?.id : undefined,
|
||||
title: variant.display_name.replace(`[${variant.code}] `, ""),
|
||||
sku: variant.code || undefined,
|
||||
options,
|
||||
prices: [
|
||||
{
|
||||
amount: variant.list_price,
|
||||
currency_code: variant.currency_id.display_name.toLowerCase(),
|
||||
},
|
||||
],
|
||||
manage_inventory: false, // change to true if syncing inventory from Odoo
|
||||
metadata: {
|
||||
external_id: `${variant.id}`,
|
||||
},
|
||||
}
|
||||
})
|
||||
} else {
|
||||
product.variants?.push({
|
||||
id: existingProduct ? existingProduct.variants[0].id : undefined,
|
||||
title: "Default",
|
||||
options: {
|
||||
Default: "Default",
|
||||
},
|
||||
// @ts-ignore
|
||||
prices: [
|
||||
{
|
||||
amount: odooProduct.list_price,
|
||||
currency_code: odooProduct.currency_id.display_name.toLowerCase(),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
external_id: `${odooProduct.id}`,
|
||||
},
|
||||
manage_inventory: false, // change to true if syncing inventory from Odoo
|
||||
})
|
||||
}
|
||||
|
||||
if (existingProduct) {
|
||||
productsToUpdate.push(product as UpdateProductWorkflowInputDTO)
|
||||
} else {
|
||||
productsToCreate.push(product as CreateProductWorkflowInputDTO)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
productsToCreate,
|
||||
productsToUpdate,
|
||||
}
|
||||
})
|
||||
|
||||
// TODO create and update the products
|
||||
```
|
||||
|
||||
You use `transform` again to prepare the products to create and update. It receives two parameters:
|
||||
|
||||
- An object with the data you'll use in the transform function.
|
||||
- The transform function, which receives the object from the first parameter, and returns the data that can be used in the rest of the workflow.
|
||||
|
||||
In the transform function, you:
|
||||
|
||||
- Create the `productsToCreate` and `productsToUpdate` arrays to hold the products that should be created and updated, respectively.
|
||||
- Iterate over the products fetched from Odoo and create a product object for each. You set the product's properties based on the Odoo product's properties. If you want to add more properties, you can do so at this point.
|
||||
- Most importantly, you set the `external_id` to the Odoo product's ID, which allows you later to identify the product later when updating it or for other operations.
|
||||
- You also set the product's variants either to Odoo's variants or to a default variant. You set the product variant's Odoo ID in the `metadata.external_id` field, which allows you to identify the variant later when updating it or for other operations.
|
||||
- To determine if a product already exists, you check if the product's `external_id` matches an existing product's `external_id`. You add it to the products to be updated. You apply a similar logic for the variants.
|
||||
- Finally, you return an object with the `productsToCreate` and `productsToUpdate` arrays.
|
||||
|
||||
You can now create and update the products in the workflow. Replace the `TODO` with the following:
|
||||
|
||||
```ts title="src/workflows/sync-from-erp.ts"
|
||||
createProductsWorkflow.runAsStep({
|
||||
input: {
|
||||
products: productsToCreate,
|
||||
},
|
||||
})
|
||||
|
||||
updateProductsWorkflow.runAsStep({
|
||||
input: {
|
||||
products: productsToUpdate,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse({
|
||||
odooProducts,
|
||||
})
|
||||
```
|
||||
|
||||
You use the `createProductsWorkflow` and `updateProductsWorkflow` to create and update the products returned from the transform function. Since both of these are workflows, you use the `runAsStep` method to run them as steps in the current workflow.
|
||||
|
||||
Finally, a workflow must return a instance of `WorkflowResponse` passing it as a parameter the data to return, which in this case is the products fetched from Odoo.
|
||||
|
||||
You can now execute this workflow in other customizations, such as a scheduled job.
|
||||
|
||||
### Create Scheduled Job
|
||||
|
||||
In Medusa, you can run a task at a specified interval using a [scheduled job](!docs!/learn/fundamentals/scheduled-jobs). A scheduled job is an asynchronous function that runs at a regular interval during the Medusa application's runtime to perform tasks such as syncing products from Odoo to Medusa.
|
||||
|
||||
To create a scheduled job, create the file `src/jobs/sync-products-from-erp.ts` with the following content:
|
||||
|
||||

|
||||
|
||||
```ts title="src/jobs/sync-products-from-erp.ts"
|
||||
import {
|
||||
MedusaContainer,
|
||||
} from "@medusajs/framework/types"
|
||||
import { syncFromErpWorkflow } from "../workflows/sync-from-erp"
|
||||
import { OdooProduct } from "../modules/odoo/service"
|
||||
|
||||
export default async function syncProductsJob(container: MedusaContainer) {
|
||||
const limit = 10
|
||||
let offset = 0
|
||||
let total = 0
|
||||
let odooProducts: OdooProduct[] = []
|
||||
|
||||
console.log("Syncing products...")
|
||||
|
||||
do {
|
||||
odooProducts = (await syncFromErpWorkflow(container).run({
|
||||
input: {
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
})).result.odooProducts
|
||||
|
||||
offset += limit
|
||||
total += odooProducts.length
|
||||
} while (odooProducts.length > 0)
|
||||
|
||||
console.log(`Synced ${total} products`)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "daily-product-sync",
|
||||
schedule: "0 0 * * *", // Every day at midnight
|
||||
}
|
||||
```
|
||||
|
||||
In this file, you export:
|
||||
|
||||
- An asynchronous function, which is the task to execute at the specified schedule.
|
||||
- A configuration object having the following properties:
|
||||
- `name`: A unique name for the scheduled job.
|
||||
- `schedule`: A [cron expression](https://crontab.guru/) string indicating the schedule to run the job at. The specified schedule indicates that this job should run every day at midnight.
|
||||
|
||||
The scheduled job function accepts the Medusa container as a parameter. In the function, you define the pagination options for the products to fetch from Odoo. You then run the `syncFromErpWorkflow` workflow with the pagination options. You increment the offset by the limit each time you run the workflow until you fetch all the products.
|
||||
|
||||
---
|
||||
|
||||
## Test it Out
|
||||
|
||||
To test out syncing the products from Odoo to Medusa, first, change the schedule of the job in `src/jobs/sync-products-from-erp.ts` to run every minute:
|
||||
|
||||
```ts title="src/jobs/sync-products-from-erp.ts"
|
||||
export const config = {
|
||||
name: "daily-product-sync",
|
||||
schedule: "* * * * *", // Every minute
|
||||
}
|
||||
```
|
||||
|
||||
Then, start the Medusa application with the following command:
|
||||
|
||||
```bash npm2yarn
|
||||
npm run dev
|
||||
```
|
||||
|
||||
A minute later, you should find the message `Syncing products...` in the console. Once the job finishes, you should see the message `Synced <number> products`, indicating the number of products synced.
|
||||
|
||||
You can also confirm that the products were synced by checking the products in the Medusa Admin dashboard.
|
||||
|
||||
If you encounter any issues, make sure the module options are set correctly as explained in [this section](#add-module-to-medusas-configurations).
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
You now have the foundation for integrating Odoo with Medusa. You can expand on this integration to implement more use cases, such as syncing orders, restricting purchases of products based on custom rules, and checking inventory in Odoo before adding to the cart. You can find the approach to implement these use cases in [this recipe](../page.mdx).
|
||||
|
||||
If you're new to Medusa, check out the [main documentation](!docs!/learn), where you'll get a more in-depth learning of all the concepts you've used in this guide and more.
|
||||
|
||||
To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](../../../commerce-modules/page.mdx).
|
||||
|
||||
For other general guides related to [deployment](../../../deployment/page.mdx), [storefront development](../../../storefront-development/page.mdx), [integrations](../../../integrations/page.mdx), and more, check out the [Development Resources](../../../page.mdx).
|
||||
@@ -0,0 +1,755 @@
|
||||
import { Prerequisites } from "docs-ui"
|
||||
|
||||
export const ogImage = "https://res.cloudinary.com/dza7lstvk/image/upload/v1740556002/Medusa%20Resources/erp-guide_sucmxz.jpg"
|
||||
|
||||
export const metadata = {
|
||||
title: `Integrate ERP with Medusa`,
|
||||
openGraph: {
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1600,
|
||||
height: 836,
|
||||
type: "image/jpeg"
|
||||
}
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1600,
|
||||
height: 836,
|
||||
type: "image/jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# {metadata.title}
|
||||
|
||||
In this recipe, you'll learn about the general approach to integrating an ERP system with Medusa.
|
||||
|
||||
Businesses often rely on an ERP system to centralize their data and custom business rules and operations. This includes using the ERP to store special product prices, manage custom business rules that change how their customers make purchases, and handle the fulfillment and processing of orders.
|
||||
|
||||
When integrating the ERP system with other ecommerce platforms, you'll face complications maintaining data consistency across systems and customizing the platform's existing flows to accommodate the ERP system's data and operations. For example, the ecommerce platform may not support purchasing products with custom pricing or restricting certain products from purchase under certain conditions.
|
||||
|
||||
Medusa's framework for customization solves these challenges by giving you a durable execution engine to orchestrate operations through custom flows, and the flexibility to customize the platform's existing flows. You can wrap existing flows with custom logic, inject custom features into existing flows, and create new flows that interact with the ERP system and sync data between the two systems.
|
||||
|
||||

|
||||
|
||||
In this recipe, you'll learn how to implement some common use cases when integrating an ERP system with Medusa. This includes how to purchase products with custom pricing, restrict products from purchase under conditions in the ERP, sync orders to the ERP, and more.
|
||||
|
||||
You can use the code snippets in the recipe as a starting point for your ERP integration, making changes as necessary for your use case. You can also implement other use cases using the same Medusa concepts.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisite: Install Medusa
|
||||
|
||||
If you don't have a Medusa application yet, you can install it with the following command:
|
||||
|
||||
<Prerequisites items={[
|
||||
{
|
||||
text: "Node.js v20+",
|
||||
link: "https://nodejs.org/en/download"
|
||||
},
|
||||
{
|
||||
text: "Git CLI tool",
|
||||
link: "https://git-scm.com/downloads"
|
||||
},
|
||||
{
|
||||
text: "PostgreSQL",
|
||||
link: "https://www.postgresql.org/download/"
|
||||
}
|
||||
]} />
|
||||
|
||||
```bash
|
||||
npx create-medusa-app@latest
|
||||
```
|
||||
|
||||
You'll first be asked for the project's name. You can also optionally choose to install the [Next.js starter storefront](../../nextjs-starter/page.mdx).
|
||||
|
||||
Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name.
|
||||
|
||||
<Note title="Why is the storefront installed separately">
|
||||
|
||||
The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](!docs!/learn/fundamentals/api-routes). Learn more about Medusa's architecture in [this documentation](!docs!/learn/introduction/architecture).
|
||||
|
||||
</Note>
|
||||
|
||||
Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterwards, you can log in with the new user and explore the dashboard.
|
||||
|
||||
<Note title="Ran into Errors">
|
||||
|
||||
Check out the [troubleshooting guides](../../troubleshooting/create-medusa-app-errors/page.mdx) for help.
|
||||
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Integrate ERP in a Module
|
||||
|
||||
Before you start integrating the ERP system into existing or new flows in Medusa, you must build the integration layer that allows you to communicate with the ERP in your customizations.
|
||||
|
||||
In Medusa, you implement integrations or features around a single commerce domain in a [module](!docs!/learn/fundamentals/modules). A module is a reusable package that can interact with the database or external APIs. The module integrates into your Medusa application without side effects to the existing setup.
|
||||
|
||||
So, you can create a module that exports a class called a service, and in that service, you implement the logic to connect to your ERP system, fetch data from it, and send data to it. The service may look like this:
|
||||
|
||||
```ts title="src/modules/erp/service.ts"
|
||||
type Options = {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export default class ErpModuleService {
|
||||
private options: Options
|
||||
private client
|
||||
|
||||
constructor({}, options: Options) {
|
||||
this.options = options
|
||||
// TODO initialize client that connects to ERP
|
||||
}
|
||||
|
||||
async getProducts() {
|
||||
// assuming client has a method to fetch products
|
||||
return this.client.getProducts()
|
||||
}
|
||||
|
||||
// TODO add more methods
|
||||
}
|
||||
```
|
||||
|
||||
You can then use the module's service in the custom flows and customizations that you'll see in later sections.
|
||||
|
||||
### How to Create Module
|
||||
|
||||
Refer to [this documentation](!docs!/learn/fundamentals/modules) on how to create a module. You can also refer to the [Odoo Integration guide](./odoo/page.mdx) as an example of how to build a module that integrates an ERP into Medusa.
|
||||
|
||||
The rest of this recipe assumes you have an ERP Module with some methods to retrieve products, prices, and other relevant data.
|
||||
|
||||
---
|
||||
|
||||
## Sync Products from ERP
|
||||
|
||||
If you store products in the ERP system, you want to sync them into Medusa to allow customers to purchase them. You may sync them once or periodically to keep the products in Medusa up-to-date with the ERP.
|
||||
|
||||
Syncing data between systems is a big challenge and it's often the pitfall of most ecommerce platforms, as you need to ensure data consistency and handle errors gracefully. Medusa solves this challenge by providing a durable execution engine to complete tasks that span multiple systems, allowing you to orchestrate your operations across systems in Medusa instead of managing it yourself.
|
||||
|
||||
Medusa's workflows are a series of queries and actions, called steps, that complete a task. You construct a workflow similar to how you create a JavaScript function, but with additional features like defining rollback logic for each step, performing long actions asynchronously, and tracking the progress of the steps. You can then use these workflows in other customizations, such as:
|
||||
|
||||
- [API routes](!docs!/learn/fundamentals/api-routes) to allow clients to trigger the workflow's execution.
|
||||
- [Subscribers](!docs!/learn/fundamentals/events-and-subscribers) to trigger the workflow when an event occurs.
|
||||
- [Scheduled jobs](!docs!/learn/fundamentals/scheduled-jobs) to run the workflow periodically.
|
||||
|
||||
So, to sync products from the ERP system to Medusa, you can create a custom workflow that fetches the products from the ERP system and adds them to Medusa. Then, you can create a scheduled job that syncs the products once a day, for example.
|
||||
|
||||
### 1. Create Workflow to Sync Products
|
||||
|
||||
The workflow that syncs products from the ERP system to Medusa will have a step that fetches the product from the ERP, and another step that adds the product to Medusa.
|
||||
|
||||
For example, you can create the following workflow:
|
||||
|
||||
```ts title="src/workflows/sync-products.ts"
|
||||
import {
|
||||
createStep, createWorkflow, StepResponse,
|
||||
transform, WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
|
||||
|
||||
const getProductsFromErpStep = createStep(
|
||||
"get-products-from-erp",
|
||||
async (_, { container }) => {
|
||||
const erpModuleService = container.resolve("erp")
|
||||
|
||||
const products = await erpModuleService.getProducts()
|
||||
|
||||
return new StepResponse(products)
|
||||
}
|
||||
)
|
||||
|
||||
export const syncFromErpWorkflow = createWorkflow(
|
||||
"sync-from-erp",
|
||||
() => {
|
||||
const erpProducts = getProductsFromErpStep()
|
||||
|
||||
const productsToCreate = transform({
|
||||
erpProducts,
|
||||
}, (data) => {
|
||||
// TODO prepare ERP products to be created in Medusa
|
||||
return data.erpProducts.map((erpProduct) => {
|
||||
return {
|
||||
title: erpProduct.title,
|
||||
external_id: erpProduct.id,
|
||||
variants: erpProduct.variants.map((variant) => ({
|
||||
title: variant.title,
|
||||
metadata: {
|
||||
external_id: variant.id,
|
||||
},
|
||||
})),
|
||||
// other data...
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createProductsWorkflow.runAsStep({
|
||||
input: {
|
||||
products: productsToCreate,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse({
|
||||
erpProducts,
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
In the above file, you first create a `getProductsFromErpStep` that resolves the ERP Module's service from the [Medusa container](!docs!/learn/fundamentals/medusa-container), which is a registry of framework and commerce tools, including your modules, that you can access in your customizations. You can then call the `getProducts` method in the ERP Module's service to fetch the products from the ERP and return them.
|
||||
|
||||
Then, you create a `syncFromErpWorkflow` that executes the `getProductsFromErpStep` to get the products from the ERP, then prepare the products to be created in Medusa. For example, you can set the product's title, and specify its ID in the ERP using the `external_id` field. Also, assuming the ERP products have variants, you can map the variants to Medusa's format, setting the variant's title and its ERP ID in the metadata.
|
||||
|
||||
Finally, you pass the products to be created to the `createProductsWorkflow`, which is a built-in Medusa workflow that creates products.
|
||||
|
||||
Learn more about creating workflows and steps in [this documentation](!docs!/learn/fundamentals/workflows).
|
||||
|
||||
<Note title="Tip">
|
||||
|
||||
Find a detailed example of implementing this workflow in the [Odoo Integration guide](./odoo/page.mdx).
|
||||
|
||||
</Note>
|
||||
|
||||
### 2. Create Scheduled Job to Sync Products
|
||||
|
||||
After creating a workflow, you can create a scheduled job that runs the workflow periodically to sync the products from the ERP to Medusa.
|
||||
|
||||
For example, you can create the following scheduled job:
|
||||
|
||||
```ts title="src/scheduled-jobs/sync-products.ts"
|
||||
import {
|
||||
MedusaContainer,
|
||||
} from "@medusajs/framework/types"
|
||||
import { syncFromErpWorkflow } from "../workflows/sync-from-erp"
|
||||
|
||||
export default async function syncProductsJob(container: MedusaContainer) {
|
||||
await syncFromErpWorkflow(container).run({})
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "daily-product-sync",
|
||||
schedule: "0 0 * * *", // Every day at midnight
|
||||
}
|
||||
```
|
||||
|
||||
You create a scheduled job that runs once a day, executing the `syncFromErpWorkflow` to sync the products from the ERP to Medusa.
|
||||
|
||||
Learn more about creating scheduled jobs in [this documentation](!docs!/learn/fundamentals/scheduled-jobs).
|
||||
|
||||
---
|
||||
|
||||
## Retrieve Custom Prices from ERP
|
||||
|
||||
Consider you store products in an ERP system with fixed prices, or prices based on different conditions. You want to display these prices in the storefront and allow customers to purchase products with these prices. To do that, you need the mechanism to fetch the custom prices from the ERP system and add the product to the cart with the custom price.
|
||||
|
||||
To do that, you can build a custom workflow that uses the ERP Module to retrieve the custom price of a product variant, then add the product to the cart with that price.
|
||||
|
||||
<Note title="Tip">
|
||||
|
||||
You can also follow [this guide](../../examples/guides/custom-item-price/page.mdx) for a step-by-step guide on how to add items with custom prices to the cart.
|
||||
|
||||
</Note>
|
||||
|
||||
### 1. Create Step to Get Variant Price
|
||||
|
||||
One of the steps in the custom add-to-cart workflow is to retrieve the custom price of the product variant from the ERP system. The step's implementation will differ based on the ERP system you're integrating. Here's a general implementation of how the step would look like:
|
||||
|
||||
```ts title="src/workflows/steps/get-erp-price.ts"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
export type GetVariantErpPriceStepInput = {
|
||||
variant_external_id: string
|
||||
currencyCode: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export const getVariantErpPriceStep = createStep(
|
||||
"get-variant-erp-price",
|
||||
async (input: GetVariantErpPriceStepInput, { container }) => {
|
||||
const { variant_external_id, currencyCode, quantity } = input
|
||||
|
||||
const erpModuleService = container.resolve("erp")
|
||||
|
||||
const price = await erpModuleService.getErpPrice(
|
||||
variant_external_id,
|
||||
currencyCode
|
||||
)
|
||||
|
||||
return new StepResponse(
|
||||
price * quantity
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
You create the step using the `createStep` from the Workflows SDK. The step has a function that receives the variant's ID in the ERP, the currency code, and the quantity as input.
|
||||
|
||||
The function then resolves the ERP Module's service from the [Medusa container](!docs!/learn/fundamentals/medusa-container) and, assuming you have a `getErpPrice` method in your ERP Module's service, you call it to retrieve that price, and return it multiplied by the quantity.
|
||||
|
||||
### 2. Create Custom Add-to-Cart Workflow
|
||||
|
||||
You can now create the custom workflow that uses the previous step to retrieve a variant's custom price from the ERP, then add it to the cart with that price.
|
||||
|
||||
For example, you can create the following workflow:
|
||||
|
||||
```ts title="src/workflows/add-custom-to-cart.ts"
|
||||
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"
|
||||
import { getVariantErpPriceStep } from "./steps/get-erp-price"
|
||||
|
||||
type AddCustomToCartWorkflowInput = {
|
||||
cart_id: string
|
||||
item: {
|
||||
variant_id: string
|
||||
quantity: number
|
||||
}
|
||||
}
|
||||
|
||||
export const addCustomToCartWorkflow = createWorkflow(
|
||||
"add-custom-to-cart",
|
||||
({ cart_id, item }: AddCustomToCartWorkflowInput) => {
|
||||
// Retrieve the cart's currency code
|
||||
const { data: carts } = useQueryGraphStep({
|
||||
entity: "cart",
|
||||
filters: { id: cart_id },
|
||||
fields: ["id", "currency_code"],
|
||||
})
|
||||
|
||||
// Retrieve the variant's metadata to get its ERP ID
|
||||
const { data: variants } = useQueryGraphStep({
|
||||
entity: "variant",
|
||||
fields: [
|
||||
"id",
|
||||
"metadata",
|
||||
],
|
||||
filters: {
|
||||
id: item.variant_id,
|
||||
},
|
||||
options: {
|
||||
throwIfKeyNotFound: true,
|
||||
},
|
||||
}).config({ name: "retrieve-variant" })
|
||||
|
||||
// get the variant's price from the ERP
|
||||
const price = getVariantErpPriceStep({
|
||||
variant_external_id: variants[0].metadata?.external_id as string,
|
||||
currencyCode: carts[0].currency_code,
|
||||
quantity: item.quantity,
|
||||
})
|
||||
|
||||
// prepare to add the variant to the cart
|
||||
const itemToAdd = transform({
|
||||
item,
|
||||
price,
|
||||
variants,
|
||||
}, (data) => {
|
||||
return [{
|
||||
...data.item,
|
||||
unit_price: data.price,
|
||||
metadata: data.variants[0].metadata,
|
||||
}]
|
||||
})
|
||||
|
||||
// add the variant to the cart
|
||||
addToCartWorkflow.runAsStep({
|
||||
input: {
|
||||
items: itemToAdd,
|
||||
cart_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
In this workflow, you first fetch the details of the cart and the variant from the database using the [useQueryGraphStep](/references/helper-steps/useQueryGraphStep) that Medusa defines.
|
||||
|
||||
Then, you use the `getVariantErpPriceStep` you created to retrieve the price from the ERP. You pass it the variant's ERP ID, supposedly stored in the variant's metadata as shown in the [previous section](#1-create-workflow-to-sync-products), the cart's currency code, and the quantity of the item.
|
||||
|
||||
Finally, you prepare the item to be added to the cart, setting the unit price to the price you retrieved from the ERP. You then call the `addToCartWorkflow` to add the item to the cart.
|
||||
|
||||
### 3. Execute Workflow in API Route
|
||||
|
||||
To allow clients to add products to the cart with custom prices, you can create an [API route](!docs!/learn/fundamentals/api-routes) that exposes the workflow's functionality. An API Route is an endpoint that exposes commerce features to external applications and clients, such as storefronts.
|
||||
|
||||
For example, you can create the following API route:
|
||||
|
||||
```ts title="src/api/store/cart/[id]/custom-line-items/route.ts"
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
|
||||
import { HttpTypes } from "@medusajs/framework/types"
|
||||
import { addCustomToCartWorkflow } from "../../../../../workflows/add-custom-to-cart"
|
||||
|
||||
export const POST = async (
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const { id } = req.params
|
||||
const item = req.body
|
||||
|
||||
await addCustomToCartWorkflow(req.scope)
|
||||
.run({
|
||||
input: {
|
||||
cart_id: id,
|
||||
item,
|
||||
},
|
||||
})
|
||||
|
||||
res.status(200).json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
In this API route, you receive the cart's ID from the route's parameters, and the item to be added to the cart from the request body. You then call the `addCustomToCartWorkflow` to add the item to the cart with the custom price.
|
||||
|
||||
Learn more about how to create an API route in [this documentation](!docs!/learn/fundamentals/api-routes). You can also add request body validation as explained in [this documentation](!docs!/learn/fundamentals/api-routes/validation).
|
||||
|
||||
---
|
||||
|
||||
## Restrict Purchase with Custom ERP Rules
|
||||
|
||||
Your ERP may store restrictions on who can purchase what products. For example, you may allow only some companies to purchase certain products.
|
||||
|
||||
Since Medusa implements the add-to-cart functionality within a workflow, it allows you to inject custom logic into the workflow using [workflow hooks](!docs!/learn/fundamentals/workflows/workflow-hooks). A workflow hook is a point in a workflow where you can inject a step and perform a custom functionality.
|
||||
|
||||
So, to implement the use case of product-purchase restriction, you can use the `validate` hook of the `addToCartWorkflow` or the [completeCartWorkflow](/references/medusa-workflows/completeCartWorkflow) to check if the customer is allowed to purchase the product. For example:
|
||||
|
||||
```ts title="src/workflows/hooks/validate-purchase.ts"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
import { addToCartWorkflow } from "@medusajs/medusa/core-flows"
|
||||
|
||||
addToCartWorkflow.hooks.validate(
|
||||
async ({ input, cart }, { container }) => {
|
||||
const erpModuleService = container.resolve("erp")
|
||||
const productModuleService = container.resolve("product")
|
||||
const customerModuleService = container.resolve("customer")
|
||||
|
||||
const customer = cart.customer_id ? await customerModuleService.retrieveCustomer(cart.customer_id) : undefined
|
||||
const productVariants = await productModuleService.listProductVariants({
|
||||
id: input.items.map((item) => item.variant_id).filter(Boolean) as string[],
|
||||
}, {
|
||||
relations: ["product"],
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
productVariants.map(async (productVariant) => {
|
||||
if (!productVariant.product?.external_id) {
|
||||
// product isn't in ERP
|
||||
return
|
||||
}
|
||||
|
||||
const isAllowed = await erpModuleService.canCompanyPurchaseProduct(
|
||||
productVariant.product.external_id,
|
||||
customer?.company_name || undefined
|
||||
)
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Company ${customer?.company_name || ""} is not allowed to purchase product ${productVariant.product.id}`
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
You consume a hook using the workflow's `hooks` property. In the above example, you consume the `validate` hook of the `addToCartWorkflow` to inject a step.
|
||||
|
||||
In the step, you resolve the ERP Module's service, the Product Module's service, and the Customer Module's service from the [Medusa container](!docs!/learn/fundamentals/medusa-container). You then retrieve the cart's customer and the product variants to be added to the cart.
|
||||
|
||||
Then, for each product variant, you use a `canCompanyPurchaseProduct` method in the ERP Module's service that checks if the customer's company is allowed to purchase the product. If not, you throw a `MedusaError` with a message that the company is not allowed to purchase the product.
|
||||
|
||||
So, only customers who are allowed in the ERP system to purchase a product can add it to the cart.
|
||||
|
||||
Learn more about workflow hooks and how to consume them in [this documentation](/docs/learn/fundamentals/workflows/workflow-hooks).
|
||||
|
||||
---
|
||||
|
||||
## Two-Way Order Syncing
|
||||
|
||||
After a customer places an order in Medusa, you may want to sync the order to the ERP system where you handle its fulfillment and processing. However, you may also want to sync the order back to Medusa, where you handle customer-service related operations, such as returns and refunds.
|
||||
|
||||
As explained earlier, workflows facilitate the orchestration of operations across systems while maintaining data consistency. So, you can create two workflows:
|
||||
|
||||
1. A workflow that syncs the order from Medusa to the ERP system. You can execute this workflow in a [subscriber](!docs!/learn/fundamentals/events-and-subscribers) that is triggered when an order is created in Medusa.
|
||||
2. A workflow that syncs the order from the ERP system back to Medusa. Then, you can create a webhook listener in Medusa that executes the workflow, and use the webhook in the ERP system to send order updates to Medusa.
|
||||
|
||||
### 1. Sync Order to ERP
|
||||
|
||||
To sync the order from Medusa to the ERP system, you can create a custom workflow that sends the order's details from Medusa to the ERP system. For example:
|
||||
|
||||
```ts title="src/workflows/sync-order-to-erp.ts"
|
||||
import { createStep, createWorkflow, StepResponse, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"
|
||||
import { OrderDTO } from "@medusajs/framework/types"
|
||||
|
||||
type StepInput = {
|
||||
order: OrderDTO
|
||||
}
|
||||
|
||||
export const syncOrderToErpStep = createStep(
|
||||
"sync-order-to-erp",
|
||||
async ({ order }: StepInput, { container }) => {
|
||||
const erpModuleService = container.resolve("erp")
|
||||
|
||||
const erpOrderId = await erpModuleService.createOrder(order)
|
||||
|
||||
return new StepResponse(erpOrderId, erpOrderId)
|
||||
},
|
||||
async (erpOrderId, { container }) => {
|
||||
if (!erpOrderId) {
|
||||
return
|
||||
}
|
||||
|
||||
const erpModuleService = container.resolve("erp")
|
||||
await erpModuleService.deleteOrder(erpOrderId)
|
||||
}
|
||||
)
|
||||
|
||||
type WorkflowInput = {
|
||||
order_id: string
|
||||
}
|
||||
|
||||
export const syncOrderToErpWorkflow = createWorkflow(
|
||||
"sync-order-to-erp",
|
||||
({ order_id }: WorkflowInput) => {
|
||||
// @ts-ignore
|
||||
const { data: orders } = useQueryGraphStep({
|
||||
entity: "order",
|
||||
fields: [
|
||||
"*",
|
||||
"shipping_address.*",
|
||||
"billing_address.*",
|
||||
"items.*",
|
||||
],
|
||||
filters: {
|
||||
id: order_id,
|
||||
},
|
||||
options: {
|
||||
throwIfKeyNotFound: true,
|
||||
},
|
||||
})
|
||||
|
||||
const erpOrderId = syncOrderToErpStep({
|
||||
order: orders[0] as unknown as OrderDTO,
|
||||
})
|
||||
|
||||
updateOrderWorkflow.runAsStep({
|
||||
input: {
|
||||
id: order_id,
|
||||
user_id: "",
|
||||
metadata: {
|
||||
external_id: erpOrderId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse(erpOrderId)
|
||||
})
|
||||
```
|
||||
|
||||
You first create a `syncOrderToErpStep` that receives an order's details, resolves the ERP Module's service from the Medusa container, and calls a `createOrder` method in the ERP Module's service that creates the order in the ERP.
|
||||
|
||||
Notice that you pass to `createStep` a third-parameter function. This is the compensation function that defines how to rollback changes. So, when an error occurs during the workflow's execution, the step deletes the order from the ERP system. This ensures that the data remains consistent across systems.
|
||||
|
||||
<Note>
|
||||
|
||||
Learn more about the compensation function in [this documentation](!docs!/learn/fundamentals/workflows/compensation-function).
|
||||
|
||||
</Note>
|
||||
|
||||
Then, you create a `syncOrderToErpWorkflow` that retrieves the order's details from the database using the `useQueryGraphStep`, then executes the `syncOrderToErpStep` to sync the order to the ERP system. Finally, you update the order in Medusa to set the ERP order's ID in the `metadata` field.
|
||||
|
||||
You can now use this workflow whenever an order is placed. To do that, you can create a subscriber that listens to the `order.created` event and executes the workflow:
|
||||
|
||||
```ts title="src/subscribers/sync-order-to-erp.ts"
|
||||
import type {
|
||||
SubscriberArgs,
|
||||
SubscriberConfig,
|
||||
} from "@medusajs/framework"
|
||||
import { syncOrderToErpWorkflow } from "../workflows/sync-order-to-erp"
|
||||
|
||||
export default async function productCreateHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const { result } = await syncOrderToErpWorkflow(container)
|
||||
.run({
|
||||
input: {
|
||||
order_id: data.id,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Order synced to ERP with id: ${result}`)
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "order.placed",
|
||||
}
|
||||
```
|
||||
|
||||
You create a subscriber that listens to the `order.placed` event. When the event is triggered, the subscriber executes the `syncOrderToErpWorkflow` to sync the order to the ERP system.
|
||||
|
||||
Learn more about events and subscribers in [this documentation](!docs!/learn/fundamentals/events-and-subscribers).
|
||||
|
||||
### 2. Sync Order from ERP to Medusa
|
||||
|
||||
To sync the order from the ERP system back to Medusa, create first the workflow that receives the updated order data and reflects them in Medusa:
|
||||
|
||||
```ts title="src/workflows/sync-order-from-erp.ts"
|
||||
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"
|
||||
|
||||
type Input = {
|
||||
order_erp_data: any
|
||||
}
|
||||
|
||||
export const syncOrderFromErpWorkflow = createWorkflow(
|
||||
"sync-order-from-erp",
|
||||
({ order_erp_data }: Input) => {
|
||||
const { data: orders } = useQueryGraphStep({
|
||||
entity: "order",
|
||||
fields: ["*"],
|
||||
filters: {
|
||||
// @ts-ignore
|
||||
metadata: {
|
||||
external_id: order_erp_data.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const orderUpdateData = transform({
|
||||
order_erp_data,
|
||||
orders,
|
||||
}, (data) => {
|
||||
return {
|
||||
id: data.orders[0].id,
|
||||
user_id: "",
|
||||
status: data.order_erp_data.status,
|
||||
}
|
||||
})
|
||||
|
||||
const order = updateOrderWorkflow.runAsStep({
|
||||
input: orderUpdateData,
|
||||
})
|
||||
|
||||
return new WorkflowResponse(order)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
In the workflow, you retrieve the order from the database using the `useQueryGraphStep`, assuming that the ERP order's ID is stored in the order's metadata. Then, you prepare the order's data to be updated in Medusa, setting the order's status to the status you received from the ERP system. Finally, you update the order using the `updateOrderWorkflow`.
|
||||
|
||||
You can now create an API route that receives webhook updates from the ERP system and executes the workflow:
|
||||
|
||||
```ts title="src/api/erp/order-updates/route.ts"
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { syncOrderFromErpWorkflow } from "../../workflows/sync-order-from-erp"
|
||||
|
||||
export async function POST(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const webhookData = req.rawBody
|
||||
|
||||
// TODO construct the order object from the webhook data
|
||||
|
||||
// execute the workflow
|
||||
await syncOrderFromErpWorkflow(req.scope).run({
|
||||
input: {
|
||||
order_erp_data, // pass constructed order object
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
In the API route, you receive the raw webhook data from the request body. You can then construct the order object from the webhook data based on the ERP system's format.
|
||||
|
||||
Then, you call the `syncOrderFromErpWorkflow` to sync the order from the ERP system back to Medusa.
|
||||
|
||||
Finally, to ensure the webhook's raw data is received, you need to configure the middleware that runs before the route handler to preserve the raw body data. To do that, add the following middleware configuration in `src/api/middlewares.ts`:
|
||||
|
||||
```ts title="src/api/middlewares.ts"
|
||||
import { defineMiddlewares } from "@medusajs/framework/http"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
method: ["POST"],
|
||||
bodyParser: { preserveRawBody: true },
|
||||
matcher: "/erp/order-updates",
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
You configure the body parser of `POST` requests to the `/erp/order-updates` route to preserve the raw body data.
|
||||
|
||||
You can now receive webhook requests from your ERP system and sync the order data back to Medusa.
|
||||
|
||||
<Note>
|
||||
|
||||
Learn more about middlewares in [this documentation](!docs!/learn/fundamentals/api-routes/middlewares).
|
||||
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Validate Inventory Availability in ERP
|
||||
|
||||
An ERP system often manages the inventory of products, with the ability to track the stock levels and availability of products. When a customer is purchasing a product through Medusa, you want to ensure that the product is available in the ERP system before allowing the purchase.
|
||||
|
||||
Similar to the [product-purchase restriction](#restrict-purchase-with-custom-erp-rules) use case, you can use the `validate` hook of the `addToCartWorkflow` or the [completeCartWorkflow](/references/medusa-workflows/completeCartWorkflow) to check the product's availability in the ERP system. For example:
|
||||
|
||||
```ts title="src/workflows/hooks/validate-inventory.ts"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
import { addToCartWorkflow } from "@medusajs/medusa/core-flows"
|
||||
|
||||
addToCartWorkflow.hooks.validate(
|
||||
async ({ input }, { container }) => {
|
||||
const erpModuleService = container.resolve("erp")
|
||||
const productModuleService = container.resolve("product")
|
||||
|
||||
const productVariants = await productModuleService.listProductVariants({
|
||||
id: input.items.map((item) => item.variant_id).filter(Boolean) as string[],
|
||||
}, {
|
||||
relations: ["product"],
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
productVariants.map(async (productVariant) => {
|
||||
const erpVariant = await erpModuleService.getQty(productVariant.metadata?.external_id)
|
||||
const item = input.items.find((item) => item.variant_id === productVariant.id)!
|
||||
|
||||
if (erpVariant.qty_available < item.quantity && !erpVariant.allow_out_of_stock_order) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Not enough stock for product ${productVariant.product?.id}`
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
You consume the `validate` hook of the `addToCartWorkflow` to inject a step function. In the step, you resolve the services of the ERP Module and the Product Module from the Medusa container. Then, you loop over the product variants to be added to the cart, and for each variant, you call a `getQty` method in the ERP Module's service to get the variant's quantity available.
|
||||
|
||||
If the available quantity in the ERP is less than the quantity to be added to the cart, and the ERP doesn't allow out-of-stock orders for the variant, you throw an error that the product is out of stock.
|
||||
|
||||
So, only products that have sufficient quantity in the ERP system can be added to the cart.
|
||||
|
||||
---
|
||||
|
||||
## Implement More Use Cases
|
||||
|
||||
The use cases covered in this guide are some common ERP integration scenarios that you can implement with Medusa. However, you can implement more use cases based on your ERP system's capabilities and your business requirements.
|
||||
|
||||
Refer to the [main documentation](!docs!/learn) to learn more about Medusa's concepts and how to implement customizations. You can also use the feedback form at the end of this guide to suggest more use cases you'd like to see implemented.
|
||||
@@ -1,365 +0,0 @@
|
||||
import { AcademicCapSolid } from "@medusajs/icons"
|
||||
import { LearningPath } from "docs-ui"
|
||||
|
||||
export const metadata = {
|
||||
title: `Integrate Ecommerce Stack Recipe`,
|
||||
}
|
||||
|
||||
# {metadata.title}
|
||||
|
||||
This recipe provides the general steps to integrate your ecommerce stack with Medusa.
|
||||
|
||||
## Overview
|
||||
|
||||
Integrating third-party systems, such as ERP or a CMS, into your ecommerce stack requires:
|
||||
|
||||
- Establishing connections with the different systems based on each of their APIs.
|
||||
- Building flows that span across multiple systems.
|
||||
- Maintaining data consistency and syncing between your systems.
|
||||
|
||||
Medusa’s architecture and functionalities allow you to integrate third-party systems and build flows around them. It also provides error-handling mechanisms and webhook capabilities that prevent data inconsistency between your systems.
|
||||
|
||||
---
|
||||
|
||||
## Integrate External System with a Module
|
||||
|
||||
To integrate an external system, such as an ERP, into your Medusa application, create a module whose service has methods to connect to the external system.
|
||||
|
||||
Then, resolve the module's main service in other resources, such as API routes or workflows, to perform actions in the external system.
|
||||
|
||||
<Card
|
||||
href="!docs!/learn/fundamentals/modules"
|
||||
title="Create a Module"
|
||||
text="Learn how to create a module in Medusa."
|
||||
icon={AcademicCapSolid}
|
||||
/>
|
||||
|
||||
<Details summaryContent="Example: Create a module integrating an ERP system">
|
||||
|
||||
This example showcases how to create a module that integrates to a dummy ERP system.
|
||||
|
||||
Start by creating the directory `src/modules/erp` for your module.
|
||||
|
||||
Then, create the file `src/modules/erp/service.ts` with the following content:
|
||||
|
||||
export const serviceHighlights = [
|
||||
["4", "ErpModuleOptions", "The module's expected options."],
|
||||
["9", "client_", "The client to connect to the external system."],
|
||||
["12", "create", "Create the client using Axios. Can instead use an SDK if the external system has one."],
|
||||
["20", "getProductData", "This method retrieves the product's data from the ERP system."],
|
||||
["26", "createProduct", "This method creates a product in the ERP system."],
|
||||
["32", "deleteProduct", "This method deletes the product in the ERP system."]
|
||||
]
|
||||
|
||||
```ts title="src/modules/erp/service.ts" highlights={serviceHighlights}
|
||||
import axios, { AxiosInstance } from "axios"
|
||||
import { ProductDTO } from "@medusajs/framework/types"
|
||||
|
||||
type ErpModuleOptions = {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
class ErpModuleService {
|
||||
private client_: AxiosInstance
|
||||
|
||||
constructor({}, { apiKey }: ErpModuleOptions) {
|
||||
this.client_ = axios.create({
|
||||
baseURL: `https://api.erp-example.com`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async getProductData(id: string) {
|
||||
const { data: erpProduct } = await this.client_.get(`/product/${id}`)
|
||||
|
||||
return erpProduct
|
||||
}
|
||||
|
||||
async createProduct(data: ProductDTO) {
|
||||
const { data: erpProduct } = await this.client_.post(`/product`, data)
|
||||
|
||||
return erpProduct
|
||||
}
|
||||
|
||||
async deleteProduct(id: string) {
|
||||
await this.client_.delete(`/product/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default ErpModuleService
|
||||
```
|
||||
|
||||
This creates the module's main service. Few things to note:
|
||||
|
||||
- The module accepts an `apiKey` option, used to authenticate to the dummy ERP system. The module's main service accesses this option in the second parameter of the constructor.
|
||||
- The module uses axios to create a client in the constructor. The client is used in the service's methods when connecting to the ERP system. If the system you're integrating has an SDK, you can initialize it in the constructor, instead.
|
||||
- The `getProductData` method retrieves a product's details from the ERP system by sending a `GET` request using the client.
|
||||
- The `createProduct` method creates a product in the ERP system by sending a `POST` request using the client.
|
||||
- The `deleteProduct` method deletes a product in the ERP system by sending a `DELETE` request using the client.
|
||||
|
||||
<Note title="Tip">
|
||||
|
||||
You can store the product's ID in the external system using the `metadata` property of the `Product` data model in the Product Module. Alternatively, you can create a [data model](!docs!/learn/fundamentals/modules#1-create-data-model) in your module to store data related to the external system.
|
||||
|
||||
</Note>
|
||||
|
||||
Then, create the module's definition file at `src/modules/erp/index.ts` with the following content:
|
||||
|
||||
```ts title="src/modules/erp/index.ts"
|
||||
import ErpModuleService from "./service"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
export default Module("erp", {
|
||||
service: ErpModuleService,
|
||||
})
|
||||
```
|
||||
|
||||
Finally, add the module to the `modules` object in `medusa-config.ts`:
|
||||
|
||||
```ts title="medusa-config.ts" highlights={[["7", "ERP_API_KEY", "The environment variable holding the API key of the ERP system."]]}
|
||||
module.exports = defineConfig({
|
||||
// ...
|
||||
modules: [
|
||||
{
|
||||
resolve: "./src/modules/erp",
|
||||
options: {
|
||||
apiKey: process.env.ERP_API_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
</Details>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Build Flows Across Systems
|
||||
|
||||
With Medusa’s workflows, build flows with steps that perform actions across systems.
|
||||
|
||||
For example, you can create a workflow that creates a product in integrated systems like ERPs, WMSs, and CMSs.
|
||||
|
||||
Workflows can be executed from anywhere. So, taking the workflow described above, you can listen to the `product.created` event using a subscriber and execute the workflow whenever the event is triggered.
|
||||
|
||||

|
||||
|
||||
Workflows guarantee data consistency through their compensation feature. You can provide a compensation function to steps that roll back the actions of that step. Then, if an error occurs in any step, the actions of previous steps are rolled back using their compensation function.
|
||||
|
||||
<Card
|
||||
href="!docs!/learn/fundamentals/workflows"
|
||||
title="Workflows"
|
||||
text="Learn more about Workflows and how to create them."
|
||||
icon={AcademicCapSolid}
|
||||
/>
|
||||
|
||||
<Details summaryContent="Example: Create products across systems with workflows">
|
||||
|
||||
For example, create the following workflow in `src/workflows/create-product.ts`:
|
||||
|
||||
export const workflowHighlights = [
|
||||
["19", "createInErpStep", "A step that creates a product in the ERP system."],
|
||||
["22", "erpModuleService", "Resolve the ERP Module's main service."],
|
||||
["25", "productModuleService", "Resolve the Product Module's main service."],
|
||||
["29", "retrieve", "Retrieve the created product's data."],
|
||||
["31", "createProduct", "Create the product in the ERP system."],
|
||||
["35", "update", "Update the product in Medusa with the ID of the ERP product."],
|
||||
["44", "erpId", "Pass the ERP product's ID to the compensation function."],
|
||||
["45", "productId", "Pass the product's ID to the compensation function."],
|
||||
["47", "", "Define a compensation function that rolls back changes when an error occurs."],
|
||||
["54", "deleteProduct", "Undo creating the product in the ERP system by deleting it."],
|
||||
["55", "update", "Update the product in Medusa to remove the ERP product's ID."]
|
||||
]
|
||||
|
||||
```ts title="src/workflows/create-product.ts" highlights={workflowHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports"
|
||||
import {
|
||||
createStep,
|
||||
StepResponse,
|
||||
createWorkflow,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { IProductModuleService } from "@medusajs/framework/types"
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import ErpModuleService from "../modules/erp/service"
|
||||
|
||||
type WorkflowInput = {
|
||||
productId: string
|
||||
}
|
||||
|
||||
type WorkflowOutput = {
|
||||
erpProduct: any
|
||||
}
|
||||
|
||||
const createInErpStep = createStep(
|
||||
"create-in-erp",
|
||||
async ({ productId }: WorkflowInput, { container }) => {
|
||||
const erpModuleService: ErpModuleService = container.resolve(
|
||||
"erpModuleService"
|
||||
)
|
||||
const productModuleService: IProductModuleService = container
|
||||
.resolve(Modules.PRODUCT)
|
||||
|
||||
const createdProductData = await productModuleService
|
||||
.retrieveProduct(productId)
|
||||
|
||||
const erpProduct = await erpModuleService.createProduct(
|
||||
createdProductData
|
||||
)
|
||||
|
||||
await productModuleService.updateProducts(productId, {
|
||||
metadata: {
|
||||
erp_id: erpProduct.id
|
||||
}
|
||||
})
|
||||
|
||||
return new StepResponse({
|
||||
erpProduct
|
||||
}, {
|
||||
erpId: erpProduct.id,
|
||||
productId
|
||||
})
|
||||
}, async ({ erpId, productId }, { container}) => {
|
||||
const erpModuleService: ErpModuleService = container.resolve(
|
||||
"erpModuleService"
|
||||
)
|
||||
const productModuleService: IProductModuleService = container
|
||||
.resolve(Modules.PRODUCT)
|
||||
|
||||
await erpModuleService.deleteProduct(erpId)
|
||||
await productModuleService.updateProducts(productId, {
|
||||
metadata: {}
|
||||
})
|
||||
})
|
||||
|
||||
const createProductWorkflow = createWorkflow<
|
||||
WorkflowInput, WorkflowOutput
|
||||
>("create-product-in-systems", function (input) {
|
||||
const erpData = createInErpStep(input)
|
||||
|
||||
return new WorkflowResponse(erpData)
|
||||
})
|
||||
|
||||
export default createProductWorkflow
|
||||
```
|
||||
|
||||
This workflow has one step that:
|
||||
|
||||
- Retrieves the product's data using the Product Module's main service.
|
||||
- Create the product in the ERP system using the ERP Module's main service.
|
||||
- Updates the product in Medusa by setting the ID of the ERP product in the product's `metadata` property.
|
||||
|
||||
The step also has a compensation function that rolls back changes when an error occurs. It deletes the product in the ERP system and removes the ID of the ERP product in the Medusa product.
|
||||
|
||||
Then, create the subscriber at `src/subscribers/create-product.ts`:
|
||||
|
||||
```ts title="src/subscribers/create-product.ts"
|
||||
import type {
|
||||
SubscriberConfig,
|
||||
SubscriberArgs,
|
||||
} from "@medusajs/framework"
|
||||
import createProductWorkflow from "../workflows/create-product"
|
||||
|
||||
export default async function handleProductUpdate({
|
||||
event: { data },
|
||||
container
|
||||
}: SubscriberArgs<{id: string}>) {
|
||||
createProductWorkflow(container)
|
||||
.run({
|
||||
input: {
|
||||
productId: data.id
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Created product across systems.")
|
||||
})
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "product.created",
|
||||
}
|
||||
```
|
||||
|
||||
The subscriber executes the workflow whenever the `product.created` event is triggered, passing it the ID of the created product.
|
||||
|
||||
</Details>
|
||||
|
||||
---
|
||||
|
||||
## Create Webhook Listeners
|
||||
|
||||
You can provide webhook listeners that your external systems call when their data is updated. This lets you synchronize data between your systems. To create webhook listeners, create an API route.
|
||||
|
||||
For example, suppose an administrator changes the product data in the ERP system. The system then sends a request to the webhook you define in your Medusa application, which updates the product data in the application.
|
||||
|
||||
<Card
|
||||
href="!docs!/learn/fundamentals/api-routes"
|
||||
title="Create an API Route"
|
||||
text="Learn how to create an API Route in Medusa."
|
||||
icon={AcademicCapSolid}
|
||||
/>
|
||||
|
||||
<Details summaryContent="Example: Create a webhook listener for ERP changes">
|
||||
|
||||
For example, create the file `src/api/webhooks/erp/update/route.ts` with the following content:
|
||||
|
||||
```ts title="src/api/webhooks/erp/update/route.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports"
|
||||
import {
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "@medusajs/framework/http"
|
||||
import {
|
||||
IProductModuleService,
|
||||
UpdateProductDTO
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
Modules
|
||||
} from "@medusajs/framework/utils"
|
||||
|
||||
type WebhookReq = {
|
||||
id: string
|
||||
updatedData: UpdateProductDTO
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: MedusaRequest<WebhookReq>,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { id, updatedData} = req.body
|
||||
|
||||
const productService: IProductModuleService = req.scope
|
||||
.resolve(
|
||||
Modules.PRODUCT
|
||||
)
|
||||
|
||||
await productService.updateProducts(id, updatedData)
|
||||
|
||||
res.status(200)
|
||||
}
|
||||
```
|
||||
|
||||
This creates a webhook listener for an ERP system. It receives the ID of a product and its updated data, assuming that’s how your ERP system sends the data.
|
||||
|
||||
Then, create the file `src/api/middlewares.ts` with the following content:
|
||||
|
||||
```ts title="src/api/middlewares.ts"
|
||||
import { defineMiddlewares } from "@medusajs/framework/http"
|
||||
import { raw } from "body-parser"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
method: ["POST", "PUT"],
|
||||
matcher: "/webhooks/*",
|
||||
bodyParser: false,
|
||||
middlewares: [raw({ type: "application/json" })],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
This replaces the default JSON middleware with the raw middleware, which is useful for webhook routes.
|
||||
|
||||
</Details>
|
||||
@@ -115,7 +115,6 @@ export const generatedEditDates = {
|
||||
"app/recipes/digital-products/examples/standard/page.mdx": "2025-02-13T15:24:15.868Z",
|
||||
"app/recipes/digital-products/page.mdx": "2025-01-06T11:19:35.623Z",
|
||||
"app/recipes/ecommerce/page.mdx": "2024-10-22T11:01:01.218Z",
|
||||
"app/recipes/integrate-ecommerce-stack/page.mdx": "2024-12-09T13:03:35.846Z",
|
||||
"app/recipes/marketplace/examples/vendors/page.mdx": "2025-02-20T07:20:47.970Z",
|
||||
"app/recipes/marketplace/page.mdx": "2024-10-03T13:07:44.153Z",
|
||||
"app/recipes/multi-region-store/page.mdx": "2024-10-03T13:07:13.813Z",
|
||||
@@ -190,7 +189,7 @@ export const generatedEditDates = {
|
||||
"app/troubleshooting/s3/page.mdx": "2025-01-24T13:47:24.994Z",
|
||||
"app/troubleshooting/page.mdx": "2024-07-18T08:57:11+02:00",
|
||||
"app/usage/page.mdx": "2024-05-13T18:55:11+03:00",
|
||||
"app/page.mdx": "2025-01-09T11:40:57.826Z",
|
||||
"app/page.mdx": "2025-02-25T07:36:59.541Z",
|
||||
"app/commerce-modules/auth/_events/_events-table/page.mdx": "2024-07-03T19:27:13+03:00",
|
||||
"app/commerce-modules/auth/auth-flows/page.mdx": "2025-01-13T11:31:35.361Z",
|
||||
"app/commerce-modules/auth/_events/page.mdx": "2024-07-03T19:27:13+03:00",
|
||||
@@ -5932,6 +5931,8 @@ export const generatedEditDates = {
|
||||
"references/types/types/types.BasePaymentCollectionStatus/page.mdx": "2025-02-11T11:36:50.377Z",
|
||||
"references/utils/enums/utils.PaymentActions/page.mdx": "2025-02-11T11:36:54.941Z",
|
||||
"references/utils/enums/utils.PaymentCollectionStatus/page.mdx": "2025-02-11T11:36:54.928Z",
|
||||
"app/recipes/erp/page.mdx": "2025-02-25T11:11:02.259Z",
|
||||
"app/recipes/erp/odoo/page.mdx": "2025-02-25T11:10:05.938Z",
|
||||
"app/commerce-modules/product/selling-products/page.mdx": "2025-02-13T13:27:09.270Z",
|
||||
"references/js_sdk/admin/Admin/properties/js_sdk.admin.Admin.draftOrder/page.mdx": "2025-02-24T10:48:44.721Z",
|
||||
"references/js_sdk/admin/Client/methods/js_sdk.admin.Client.throwError_/page.mdx": "2025-02-24T10:48:47.018Z",
|
||||
|
||||
@@ -916,8 +916,12 @@ export const filesMap = [
|
||||
"pathname": "/recipes/ecommerce"
|
||||
},
|
||||
{
|
||||
"filePath": "/www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx",
|
||||
"pathname": "/recipes/integrate-ecommerce-stack"
|
||||
"filePath": "/www/apps/resources/app/recipes/erp/odoo/page.mdx",
|
||||
"pathname": "/recipes/erp/odoo"
|
||||
},
|
||||
{
|
||||
"filePath": "/www/apps/resources/app/recipes/erp/page.mdx",
|
||||
"pathname": "/recipes/erp"
|
||||
},
|
||||
{
|
||||
"filePath": "/www/apps/resources/app/recipes/marketplace/examples/restaurant-delivery/page.mdx",
|
||||
|
||||
@@ -152,6 +152,23 @@ export const generatedSidebar = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"loaded": true,
|
||||
"isPathHref": true,
|
||||
"type": "link",
|
||||
"path": "/recipes/erp",
|
||||
"title": "Integrate ERP",
|
||||
"children": [
|
||||
{
|
||||
"loaded": true,
|
||||
"isPathHref": true,
|
||||
"type": "link",
|
||||
"path": "/recipes/erp/odoo",
|
||||
"title": "Odoo Integration",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"loaded": true,
|
||||
"isPathHref": true,
|
||||
@@ -185,14 +202,6 @@ export const generatedSidebar = [
|
||||
"title": "Ecommerce",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"loaded": true,
|
||||
"isPathHref": true,
|
||||
"type": "link",
|
||||
"path": "/recipes/integrate-ecommerce-stack",
|
||||
"title": "Integrate Ecommerce Stack",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"loaded": true,
|
||||
"isPathHref": true,
|
||||
|
||||
@@ -128,6 +128,11 @@ const nextConfig = {
|
||||
destination: "/deployment",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/recipes/integrate-ecommerce-stack",
|
||||
destination: "/recipes/erp",
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
outputFileTracingExcludes: {
|
||||
|
||||
@@ -41,6 +41,18 @@ export const recipesSidebar = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
path: "/recipes/erp",
|
||||
title: "Integrate ERP",
|
||||
children: [
|
||||
{
|
||||
type: "link",
|
||||
path: "/recipes/erp/odoo",
|
||||
title: "Odoo Integration",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
path: "/recipes/b2b",
|
||||
@@ -63,11 +75,6 @@ export const recipesSidebar = [
|
||||
path: "/recipes/ecommerce",
|
||||
title: "Ecommerce",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
path: "/recipes/integrate-ecommerce-stack",
|
||||
title: "Integrate Ecommerce Stack",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
path: "/recipes/multi-region-store",
|
||||
|
||||
Reference in New Issue
Block a user