feat(orchestration,core-flows,medusa,product,types,utils): product import/export uses workflows (#5811)

This commit is contained in:
Riqwan Thamir
2023-12-12 12:09:25 +00:00
committed by GitHub
parent 6f96ced40f
commit 07107f3565
30 changed files with 1601 additions and 357 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/orchestration": patch
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
---
feat(orchestration,core-flows,medusa): product import uses workflows
@@ -0,0 +1,471 @@
import fs from "fs/promises"
import path, { resolve, sep } from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { simpleSalesChannelFactory } from "../../../../factories"
import adminSeeder from "../../../../helpers/admin-seeder"
import productSeeder from "../../../../helpers/product-seeder"
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
const setupServer = require("../../../../environment-helpers/setup-server")
const userSeeder = require("../../../../helpers/user-seeder")
const adminReqConfig = {
headers: {
"x-medusa-access-token": "test_token",
},
}
const env: Record<any, any> = {
MEDUSA_FF_MEDUSA_V2: true,
}
jest.setTimeout(180000)
describe("Batch job of product-export type", () => {
let medusaProcess
let dbConnection
let exportFilePath = ""
let topDir = ""
let shutdownServer
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
medusaProcess = await setupServer({
cwd,
uploadDir: __dirname,
env,
verbose: true,
})
})
afterAll(async () => {
if (topDir !== "") {
await fs.rm(resolve(__dirname, topDir), { recursive: true })
}
const db = useDb()
await db.shutdown()
await medusaProcess.kill()
await shutdownServer()
})
beforeEach(async () => {
const container = getContainer()
await createDefaultRuleTypes(container)
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
await simpleSalesChannelFactory(dbConnection, {
id: "test-channel",
is_default: true,
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
// @ts-ignore
try {
const isFileExists = (await fs.stat(exportFilePath))?.isFile()
if (isFileExists) {
const [, relativeRoot] = exportFilePath
.replace(__dirname, "")
.split(sep)
if ((await fs.stat(resolve(__dirname, relativeRoot)))?.isDirectory()) {
topDir = relativeRoot
}
await fs.unlink(exportFilePath)
}
} catch (err) {
// noop
}
})
it("should export a csv file containing the expected products", async () => {
const api = useApi()
const productPayload = {
title: "Test export product",
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
options: [{ title: "size" }, { title: "color" }],
variants: [
{
title: "Test variant",
inventory_quantity: 10,
sku: "test-variant-sku-product-export",
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: [{ value: "large" }, { value: "green" }],
},
],
}
const createProductRes = await api.post(
"/admin/products",
productPayload,
adminReqConfig
)
const productId = createProductRes.data.product.id
const variantId = createProductRes.data.product.variants[0].id
const batchPayload = {
type: "product-export",
context: {
filterable_fields: {
title: "Test export product",
},
},
}
const batchJobRes = await api.post(
"/admin/batch-jobs",
batchPayload,
adminReqConfig
)
const batchJobId = batchJobRes.data.batch_job.id
expect(batchJobId).toBeTruthy()
// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
exportFilePath = path.resolve(__dirname, batchJob.result.file_key)
const isFileExists = (await fs.stat(exportFilePath)).isFile()
expect(isFileExists).toBeTruthy()
const fileSize = (await fs.stat(exportFilePath)).size
expect(batchJob.result?.file_size).toBe(fileSize)
const data = (await fs.readFile(exportFilePath)).toString()
const [, ...lines] = data.split("\r\n").filter((l) => l)
expect(lines.length).toBe(1)
const lineColumn = lines[0].split(";")
expect(lineColumn[0]).toBe(productId)
expect(lineColumn[2]).toBe(productPayload.title)
expect(lineColumn[4]).toBe(productPayload.description)
expect(lineColumn[23]).toBe(variantId)
expect(lineColumn[24]).toBe(productPayload.variants[0].title)
expect(lineColumn[25]).toBe(productPayload.variants[0].sku)
})
it("should export a csv file containing the expected products including new line char in the cells", async () => {
const api = useApi()
const productPayload = {
title: "Test export product",
description: "test-product-description\ntest line 2",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
options: [{ title: "size" }, { title: "color" }],
variants: [
{
title: "Test variant",
inventory_quantity: 10,
sku: "test-variant-sku-product-export",
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: [{ value: "large" }, { value: "green" }],
},
],
}
const createProductRes = await api.post(
"/admin/products",
productPayload,
adminReqConfig
)
const productId = createProductRes.data.product.id
const variantId = createProductRes.data.product.variants[0].id
const batchPayload = {
type: "product-export",
context: {
filterable_fields: {
title: "Test export product",
},
},
}
const batchJobRes = await api.post(
"/admin/batch-jobs",
batchPayload,
adminReqConfig
)
const batchJobId = batchJobRes.data.batch_job.id
expect(batchJobId).toBeTruthy()
// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
exportFilePath = path.resolve(__dirname, batchJob.result.file_key)
const isFileExists = (await fs.stat(exportFilePath)).isFile()
expect(isFileExists).toBeTruthy()
const fileSize = (await fs.stat(exportFilePath)).size
expect(batchJob.result?.file_size).toBe(fileSize)
const data = (await fs.readFile(exportFilePath)).toString()
const [, ...lines] = data.split("\r\n").filter((l) => l)
expect(lines.length).toBe(1)
const lineColumn = lines[0].split(";")
expect(lineColumn[0]).toBe(productId)
expect(lineColumn[2]).toBe(productPayload.title)
expect(lineColumn[4]).toBe(`"${productPayload.description}"`)
expect(lineColumn[23]).toBe(variantId)
expect(lineColumn[24]).toBe(productPayload.variants[0].title)
expect(lineColumn[25]).toBe(productPayload.variants[0].sku)
})
it("should export a csv file containing a limited number of products", async () => {
const api = useApi()
const batchPayload = {
type: "product-export",
context: {
batch_size: 1,
filterable_fields: { collection_id: "test-collection" },
order: "created_at",
},
}
const batchJobRes = await api.post(
"/admin/batch-jobs",
batchPayload,
adminReqConfig
)
const batchJobId = batchJobRes.data.batch_job.id
expect(batchJobId).toBeTruthy()
// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
exportFilePath = path.resolve(__dirname, batchJob.result.file_key)
const isFileExists = (await fs.stat(exportFilePath)).isFile()
expect(isFileExists).toBeTruthy()
const data = (await fs.readFile(exportFilePath)).toString()
const [, ...lines] = data.split("\r\n").filter((l) => l)
expect(lines.length).toBe(4)
const csvLine = lines[0].split(";")
expect(csvLine[0]).toBe("test-product")
})
it("should be able to import an exported csv file", async () => {
const api = useApi()
const batchPayload = {
type: "product-export",
context: {
batch_size: 1,
filterable_fields: { collection_id: "test-collection" },
order: "created_at",
},
}
const batchJobRes = await api.post(
"/admin/batch-jobs",
batchPayload,
adminReqConfig
)
let batchJobId = batchJobRes.data.batch_job.id
expect(batchJobId).toBeTruthy()
// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
exportFilePath = path.resolve(__dirname, batchJob.result.file_key)
const isFileExists = (await fs.stat(exportFilePath)).isFile()
expect(isFileExists).toBeTruthy()
const data = (await fs.readFile(exportFilePath)).toString()
const [header, ...lines] = data.split("\r\n").filter((l) => l)
expect(lines.length).toBe(4)
const csvLine = lines[0].split(";")
expect(csvLine[0]).toBe("test-product")
expect(csvLine[2]).toBe("Test product")
csvLine[2] = "Updated test product"
lines.splice(0, 1, csvLine.join(";"))
await fs.writeFile(exportFilePath, [header, ...lines].join("\r\n"))
const importBatchJobRes = await api.post(
"/admin/batch-jobs",
{
type: "product-import",
context: {
fileKey: exportFilePath,
},
},
adminReqConfig
)
batchJobId = importBatchJobRes.data.batch_job.id
expect(batchJobId).toBeTruthy()
shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
const productsResponse = await api.get("/admin/products", adminReqConfig)
expect(productsResponse.data.count).toBe(5)
expect(productsResponse.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: csvLine[0],
handle: csvLine[1],
title: csvLine[2],
}),
])
)
})
})
@@ -0,0 +1,406 @@
import fs from "fs"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { simpleProductFactory } from "../../../../factories"
import { simpleProductCollectionFactory } from "../../../../factories/simple-product-collection-factory"
import adminSeeder from "../../../../helpers/admin-seeder"
import batchJobSeeder from "../../../../helpers/batch-job-seeder"
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
const setupServer = require("../../../../environment-helpers/setup-server")
const userSeeder = require("../../../../helpers/user-seeder")
const adminReqConfig = {
headers: {
"x-medusa-access-token": "test_token",
},
}
function getImportFile() {
return path.resolve("__tests__", "product", "admin", "product-import.csv")
}
function copyTemplateFile() {
const csvTemplate = path.resolve(
"__tests__",
"product",
"admin",
"product-import-template.csv"
)
const destination = getImportFile()
fs.copyFileSync(csvTemplate, destination)
}
jest.setTimeout(1000000)
function cleanTempData() {
// cleanup tmp ops files
const opsFiles = path.resolve("__tests__", "product", "admin", "imports")
fs.rmSync(opsFiles, { recursive: true, force: true })
}
const env: Record<any, any> = {
MEDUSA_FF_MEDUSA_V2: true,
}
describe("Product import batch job", () => {
let dbConnection
let shutdownServer
let medusaProcess
const collectionHandle1 = "test-collection1"
const collectionHandle2 = "test-collection2"
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
env.UPLOAD_DIR = __dirname
cleanTempData() // cleanup if previous process didn't manage to do it
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
medusaProcess = await setupServer({
cwd,
uploadDir: __dirname,
env,
verbose: true,
})
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
cleanTempData()
await medusaProcess.kill()
await shutdownServer()
})
beforeEach(async () => {
const container = getContainer()
await createDefaultRuleTypes(container)
await batchJobSeeder(dbConnection)
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
await simpleProductCollectionFactory(dbConnection, [
{
handle: collectionHandle1,
},
{
handle: collectionHandle2,
},
])
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should import a csv file", async () => {
jest.setTimeout(1000000)
const api = useApi()
copyTemplateFile()
const existingProductToBeUpdated = await simpleProductFactory(
dbConnection,
{
id: "existing-product-id",
title: "Test product",
options: [{ id: "opt-1-id", title: "Size" }],
variants: [
{
id: "existing-variant-id",
title: "Initial tile",
sku: "test-sku-4",
options: [
{
option_id: "opt-1-id",
value: "Large",
},
],
},
],
}
)
const response = await api.post(
"/admin/batch-jobs",
{
type: "product-import",
context: {
fileKey: "product-import.csv",
},
},
adminReqConfig
)
const batchJobId = response.data.batch_job.id
expect(batchJobId).toBeTruthy()
// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
const productsResponse = await api.get("/admin/products", adminReqConfig)
expect(productsResponse.data.count).toBe(3)
expect(productsResponse.data.products).toEqual(
expect.arrayContaining([
// NEW PRODUCT
expect.objectContaining({
title: "Test product",
description:
"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",
handle: "test-product-product-1",
is_giftcard: false,
status: "draft",
thumbnail: "test-image.png",
variants: [
// NEW VARIANT
expect.objectContaining({
title: "Test variant",
sku: "test-sku-1",
barcode: "test-barcode-1",
ean: null,
upc: null,
// inventory_quantity: 10,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "eur",
amount: 100,
region_id: "region-product-import-0",
}),
expect.objectContaining({
currency_code: "usd",
amount: 110,
}),
expect.objectContaining({
currency_code: "dkk",
amount: 130,
region_id: "region-product-import-1",
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "option 1 value red",
}),
expect.objectContaining({
value: "option 2 value 1",
}),
]),
}),
],
type: null,
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
title: "test-option-1",
}),
expect.objectContaining({
title: "test-option-2",
}),
]),
tags: expect.arrayContaining([
expect.objectContaining({
value: "123_1",
}),
]),
collection: expect.objectContaining({
handle: collectionHandle1,
}),
}),
expect.objectContaining({
title: "Test product",
description:
"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",
handle: "test-product-product-1-1",
is_giftcard: false,
status: "draft",
thumbnail: "test-image.png",
variants: [
// NEW VARIANT
expect.objectContaining({
title: "Test variant",
sku: "test-sku-1-1",
barcode: "test-barcode-1-1",
ean: null,
upc: null,
// inventory_quantity: 10,
prices: expect.arrayContaining([
expect.objectContaining({
currency_code: "eur",
amount: 100,
region_id: "region-product-import-0",
}),
expect.objectContaining({
currency_code: "usd",
amount: 110,
}),
expect.objectContaining({
currency_code: "dkk",
amount: 130,
region_id: "region-product-import-1",
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
value: "option 1 value red",
}),
expect.objectContaining({
value: "option 2 value 1",
}),
]),
}),
],
type: null,
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
title: "test-option-1",
}),
expect.objectContaining({
title: "test-option-2",
}),
]),
tags: [],
collection: expect.objectContaining({
handle: collectionHandle1,
}),
}),
// // UPDATED PRODUCT
expect.objectContaining({
id: existingProductToBeUpdated?.id,
title: "Test product",
description: "test-product-description",
handle: "test-product-product-2",
is_giftcard: false,
status: "draft",
thumbnail: "test-image.png",
profile_id: expect.any(String),
variants: expect.arrayContaining([
// UPDATED VARIANT
expect.objectContaining({
id: "existing-variant-id",
title: "Test variant changed",
sku: "test-sku-4",
barcode: "test-barcode-4",
options: [
expect.objectContaining({
value: "Large",
option_id: "opt-1-id",
}),
],
}),
// CREATED VARIANT
expect.objectContaining({
title: "Test variant",
product_id: existingProductToBeUpdated.id,
sku: "test-sku-2",
barcode: "test-barcode-2",
ean: null,
upc: null,
// inventory_quantity: 10,
allow_backorder: false,
manage_inventory: true,
prices: [
expect.objectContaining({
currency_code: "dkk",
amount: 110,
region_id: "region-product-import-2",
}),
],
options: [
expect.objectContaining({
value: "Small",
option_id: "opt-1-id",
}),
],
}),
// CREATED VARIANT
expect.objectContaining({
title: "Test variant",
product_id: existingProductToBeUpdated.id,
sku: "test-sku-3",
barcode: "test-barcode-3",
ean: null,
upc: null,
// inventory_quantity: 10,
allow_backorder: false,
manage_inventory: true,
prices: [
expect.objectContaining({
currency_code: "usd",
amount: 120,
region_id: null,
}),
],
options: [
expect.objectContaining({
value: "Medium",
option_id: "opt-1-id",
}),
],
}),
]),
images: [
expect.objectContaining({
url: "test-image.png",
}),
],
options: [
expect.objectContaining({
product_id: existingProductToBeUpdated.id,
id: "opt-1-id",
title: "Size",
}),
],
type: expect.objectContaining({ value: "test-type" }),
tags: [
expect.objectContaining({
value: "123",
}),
],
collection: expect.objectContaining({
handle: collectionHandle2,
}),
}),
])
)
})
})
@@ -0,0 +1,6 @@
Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url
,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png
,test-product-product-1-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,,TRUE,,,Test variant,test-sku-1-1,test-barcode-1-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png
existing-product-id,test-product-product-2,Test product,,test-product-description,draft,test-image.png,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,Size,Small,,,test-image.png
existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,Size,Medium,,,test-image.png
existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,existing-variant-id,Test variant changed,test-sku-4,test-barcode-4,10,FALSE,TRUE,,,,,,,,,,,,,Size,Large,,,test-image.png
1 Product Id Product Handle Product Title Product Subtitle Product Description Product Status Product Thumbnail Product Weight Product Length Product Width Product Height Product HS Code Product Origin Country Product MID Code Product Material Product Collection Title Product Collection Handle Product Type Product Tags Product Discountable Product External Id Variant Id Variant Title Variant SKU Variant Barcode Variant Inventory Quantity Variant Allow Backorder Variant Manage Inventory Variant Weight Variant Length Variant Width Variant Height Variant HS Code Variant Origin Country Variant MID Code Variant Material Price ImportLand [EUR] Price USD Price denmark [DKK] Price Denmark [DKK] Option 1 Name Option 1 Value Option 2 Name Option 2 Value Image 1 Url
2 test-product-product-1 Test product Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal. draft Test collection 1 test-collection1 123_1 TRUE Test variant test-sku-1 test-barcode-1 10 FALSE TRUE 1.00 1.10 1.30 test-option-1 option 1 value red test-option-2 option 2 value 1 test-image.png
3 test-product-product-1-1 Test product Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal. draft Test collection 1 test-collection1 TRUE Test variant test-sku-1-1 test-barcode-1-1 10 FALSE TRUE 1.00 1.10 1.30 test-option-1 option 1 value red test-option-2 option 2 value 1 test-image.png
4 existing-product-id test-product-product-2 Test product test-product-description draft test-image.png Test collection test-collection2 test-type 123 TRUE Test variant test-sku-2 test-barcode-2 10 FALSE TRUE 1.10 Size Small test-image.png
5 existing-product-id test-product-product-2 Test product test-product-description draft Test collection test-collection2 test-type 123 TRUE Test variant test-sku-3 test-barcode-3 10 FALSE TRUE 1.20 Size Medium test-image.png
6 existing-product-id test-product-product-2 Test product test-product-description draft Test collection test-collection2 test-type 123 TRUE existing-variant-id Test variant changed test-sku-4 test-barcode-4 10 FALSE TRUE Size Large test-image.png
@@ -0,0 +1,96 @@
import { AbstractFileService } from "@medusajs/medusa"
import * as fs from "fs"
import mkdirp from "mkdirp"
import { resolve } from "path"
import stream from "stream"
export default class LocalFileService extends AbstractFileService {
constructor({}, options) {
super({}, options)
this.upload_dir_ =
process.env.UPLOAD_DIR ?? options.upload_dir ?? "uploads/images"
if (!fs.existsSync(this.upload_dir_)) {
fs.mkdirSync(this.upload_dir_)
}
}
upload(file) {
return new Promise((resolvePromise, reject) => {
const path = resolve(this.upload_dir_, file.originalname)
let content = ""
if (file.filename) {
content = fs.readFileSync(
resolve(process.cwd(), "uploads", file.filename)
)
}
const pathSegments = path.split("/")
pathSegments.splice(-1)
const dirname = pathSegments.join("/")
mkdirp.sync(dirname, { recursive: true })
fs.writeFile(path, content.toString(), (err) => {
if (err) {
reject(err)
}
resolvePromise({ url: path })
})
})
}
delete({ fileKey }) {
return new Promise((resolvePromise, reject) => {
const path = resolve(this.upload_dir_, fileKey)
fs.unlink(path, (err) => {
if (err) {
reject(err)
}
resolvePromise("file unlinked")
})
})
}
async getUploadStreamDescriptor({ name, ext }) {
const fileKey = `${name}.${ext}`
const path = resolve(this.upload_dir_, fileKey)
const isFileExists = fs.existsSync(path)
if (!isFileExists) {
await this.upload({ originalname: fileKey })
}
const pass = new stream.PassThrough()
pass.pipe(fs.createWriteStream(path))
return {
writeStream: pass,
promise: Promise.resolve(),
url: `${this.upload_dir_}/${fileKey}`,
fileKey,
}
}
async getDownloadStream({ fileKey }) {
return new Promise((resolvePromise, reject) => {
try {
const path = resolve(this.upload_dir_, fileKey)
const data = fs.readFileSync(path)
const readable = stream.Readable()
readable._read = function () {}
readable.push(data.toString())
readable.push(null)
resolvePromise(readable)
} catch (e) {
reject(e)
}
})
}
async getPresignedDownloadUrl({ fileKey }) {
return `${this.upload_dir_}/${fileKey}`
}
}
@@ -23,7 +23,6 @@ export async function createProductVariantsPrepareData({
container,
data,
}: WorkflowArguments<WorkflowTypes.ProductWorkflow.CreateProductVariantsWorkflowInputDTO>): Promise<CreateProductVariantsPreparedData> {
const featureFlagRouter = container.resolve("featureFlagRouter")
const productVariants: ProductWorkflow.CreateProductVariantsInputDTO[] =
data.productVariants || []
@@ -18,7 +18,10 @@ export async function updateProductVariants({
const productModuleService: ProductTypes.IProductModuleService =
container.resolve(ModulesDefinition[Modules.PRODUCT].registrationName)
for (const [product_id, variantsUpdateData = []] of productVariantsMap) {
for (const [
product_id,
variantsUpdateData = [],
] of productVariantsMap) {
updateVariantsData.push(
...(variantsUpdateData as unknown as UpdateProductVariantOnlyDTO[]).map(
(update) => ({ ...update, product_id })
@@ -95,7 +95,9 @@ export async function upsertVariantPrices({
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
currency_code: region_currency_code ?? price.currency_code,
currency_code: (
region_currency_code ?? price.currency_code
).toLowerCase(),
}
moneyAmountsToUpdate.push(priceToUpdate)
@@ -104,7 +106,9 @@ export async function upsertVariantPrices({
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
currency_code: region_currency_code ?? price.currency_code,
currency_code: (
region_currency_code ?? price.currency_code
).toLowerCase(),
rules: region_rules ?? {},
}
@@ -19,7 +19,7 @@ import { pickBy } from "lodash"
import { ProductStatus } from "../../../../models"
import PriceListService from "../../../../services/price-list"
import { FilterableProductProps } from "../../../../types/product"
import { listAndCountProductWithIsolatedProductModule } from "../products/list-products"
import { listProducts } from "../../../../utils"
/**
* @oas [get] /admin/price-lists/{id}/products
@@ -196,8 +196,8 @@ export default async (req: Request, res) => {
}
if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
;[products, count] = await listAndCountProductWithIsolatedProductModule(
req,
;[products, count] = await listProducts(
req.scope,
filterableFields,
req.listConfig
)
@@ -1,5 +1,5 @@
import { Workflows, createProducts } from "@medusajs/core-flows"
import { IInventoryService, WorkflowTypes } from "@medusajs/types"
import { createProducts, Workflows } from "@medusajs/core-flows"
import {
IsArray,
IsBoolean,
@@ -45,7 +45,7 @@ import { EntityManager } from "typeorm"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { ProductStatus } from "../../../../models"
import { Logger } from "../../../../types/global"
import { validator } from "../../../../utils"
import { retrieveProduct, validator } from "../../../../utils"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
/**
@@ -257,7 +257,11 @@ export default async (req, res) => {
let rawProduct
if (isMedusaV2Enabled) {
rawProduct = await getProductWithIsolatedProductModule(req, product.id)
rawProduct = await retrieveProduct(
req.scope,
product.id,
defaultAdminProductRemoteQueryObject
)
} else {
rawProduct = await productService.retrieve(product.id, {
select: defaultAdminProductFields,
@@ -272,26 +276,6 @@ export default async (req, res) => {
res.json({ product: pricedProduct })
}
async function getProductWithIsolatedProductModule(req, id) {
// TODO: Add support for fields/expands
const remoteQuery = req.scope.resolve("remoteQuery")
const variables = { id }
const query = {
product: {
__args: variables,
...defaultAdminProductRemoteQueryObject,
},
}
const [product] = await remoteQuery(query)
product.profile_id = product.profile?.id
return product
}
class ProductVariantOptionReq {
@IsString()
value: string
@@ -1,6 +1,7 @@
import { CreateProductVariants } from "@medusajs/core-flows"
import { IInventoryService, WorkflowTypes } from "@medusajs/types"
import { FlagRouter, MedusaV2Flag } from "@medusajs/utils"
import { CreateProductVariants } from "@medusajs/core-flows"
import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
@@ -10,7 +11,12 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { EntityManager } from "typeorm"
import {
defaultAdminProductFields,
defaultAdminProductRelations,
defaultAdminProductRemoteQueryObject,
} from "."
import {
PricingService,
ProductService,
@@ -21,10 +27,7 @@ import {
CreateProductVariantInput,
ProductVariantPricesCreateReq,
} from "../../../../types/product-variant"
import { Type } from "class-transformer"
import { EntityManager } from "typeorm"
import { validator } from "../../../../utils/validator"
import { getProductWithIsolatedProductModule } from "./get-product"
import { retrieveProduct, validator } from "../../../../utils"
import { createVariantsTransaction } from "./transaction/create-product-variant"
/**
@@ -157,10 +160,10 @@ export default async (req, res) => {
},
})
rawProduct = await getProductWithIsolatedProductModule(
req,
rawProduct = await retrieveProduct(
req.scope,
id,
req.retrieveConfig
defaultAdminProductRemoteQueryObject
)
} else {
await manager.transaction(async (transactionManager) => {
@@ -5,8 +5,9 @@ import {
SalesChannelService,
} from "../../../../services"
import { MedusaError, MedusaV2Flag, promiseAll } from "@medusajs/utils"
import { MedusaV2Flag, promiseAll } from "@medusajs/utils"
import { FindParams } from "../../../../types/common"
import { retrieveProduct } from "../../../../utils"
import { defaultAdminProductRemoteQueryObject } from "./index"
/**
@@ -76,10 +77,10 @@ export default async (req, res) => {
let rawProduct
if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
rawProduct = await getProductWithIsolatedProductModule(
req,
rawProduct = await retrieveProduct(
req.scope,
id,
req.retrieveConfig
defaultAdminProductRemoteQueryObject
)
} else {
rawProduct = await productService.retrieve(id, req.retrieveConfig)
@@ -118,35 +119,4 @@ export default async (req, res) => {
res.json({ product })
}
export async function getProductWithIsolatedProductModule(
req,
id,
retrieveConfig
) {
// TODO: Add support for fields/expands
const remoteQuery = req.scope.resolve("remoteQuery")
const variables = { id }
const query = {
product: {
__args: variables,
...defaultAdminProductRemoteQueryObject,
},
}
const [product] = await remoteQuery(query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id: ${id} not found`
)
}
product.profile_id = product.profile?.id
return product
}
export class AdminGetProductParams extends FindParams {}
@@ -1,23 +1,19 @@
import { IsNumber, IsOptional, IsString } from "class-validator"
import {
PriceListService,
PricingService,
ProductService,
ProductVariantInventoryService,
SalesChannelService,
} from "../../../../services"
import {
IInventoryService,
IPricingModuleService,
IProductModuleService,
} from "@medusajs/types"
import { MedusaV2Flag, promiseAll } from "@medusajs/utils"
import { listProducts } from "../../../../utils"
import { IInventoryService } from "@medusajs/types"
import { MedusaV2Flag } from "@medusajs/utils"
import { Type } from "class-transformer"
import { Product } from "../../../../models"
import { PricedProduct } from "../../../../types/pricing"
import { FilterableProductProps } from "../../../../types/product"
import { defaultAdminProductRemoteQueryObject } from "./index"
/**
* @oas [get] /admin/products
@@ -252,12 +248,11 @@ export default async (req, res) => {
let count
if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
const [products, count_] =
await listAndCountProductWithIsolatedProductModule(
req,
req.filterableFields,
req.listConfig
)
const [products, count_] = await listProducts(
req.scope,
req.filterableFields,
req.listConfig
)
rawProducts = products
count = count_
@@ -305,171 +300,6 @@ export default async (req, res) => {
})
}
async function getVariantsFromPriceList(req, priceListId) {
const remoteQuery = req.scope.resolve("remoteQuery")
const pricingModuleService: IPricingModuleService = req.scope.resolve(
"pricingModuleService"
)
const productModuleService: IProductModuleService = req.scope.resolve(
"productModuleService"
)
const [priceList] = await pricingModuleService.listPriceLists(
{ id: [priceListId] },
{
relations: [
"price_set_money_amounts",
"price_set_money_amounts.price_set",
],
select: ["price_set_money_amounts.price_set.id"],
}
)
const priceSetIds = priceList.price_set_money_amounts?.map(
(psma) => psma.price_set?.id
)
const query = {
product_variant_price_set: {
__args: {
price_set_id: priceSetIds,
},
fields: ["variant_id", "price_set_id"],
},
}
const variantPriceSets = await remoteQuery(query)
const variantIds = variantPriceSets.map((vps) => vps.variant_id)
return await productModuleService.listVariants(
{
id: variantIds,
},
{
select: ["product_id"],
}
)
}
export async function listAndCountProductWithIsolatedProductModule(
req,
filterableFields,
listConfig
) {
// TODO: Add support for fields/expands
const remoteQuery = req.scope.resolve("remoteQuery")
const featureFlagRouter = req.scope.resolve("featureFlagRouter")
const productIdsFilter: Set<string> = new Set()
const variantIdsFilter: Set<string> = new Set()
const promises: Promise<void>[] = []
// This is not the best way of handling cross filtering but for now I would say it is fine
const salesChannelIdFilter = filterableFields.sales_channel_id
delete filterableFields.sales_channel_id
if (salesChannelIdFilter) {
const salesChannelService = req.scope.resolve(
"salesChannelService"
) as SalesChannelService
promises.push(
salesChannelService
.listProductIdsBySalesChannelIds(salesChannelIdFilter)
.then((productIdsInSalesChannel) => {
let filteredProductIds =
productIdsInSalesChannel[salesChannelIdFilter]
if (filterableFields.id) {
filterableFields.id = Array.isArray(filterableFields.id)
? filterableFields.id
: [filterableFields.id]
const salesChannelProductIdsSet = new Set(filteredProductIds)
filteredProductIds = filterableFields.id.filter((productId) =>
salesChannelProductIdsSet.has(productId)
)
}
filteredProductIds.map((id) => productIdsFilter.add(id))
})
)
}
const priceListId = filterableFields.price_list_id
delete filterableFields.price_list_id
if (priceListId) {
if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
const variants = await getVariantsFromPriceList(req, priceListId)
variants.forEach((pv) => variantIdsFilter.add(pv.id))
} else {
// TODO: it is working but validate the behaviour.
// e.g pricing context properly set.
// At the moment filtering by price list but not having any customer id or
// include discount forces the query to filter with price list id is null
const priceListService = req.scope.resolve(
"priceListService"
) as PriceListService
promises.push(
priceListService
.listPriceListsVariantIdsMap(priceListId)
.then((priceListVariantIdsMap) => {
priceListVariantIdsMap[priceListId].map((variantId) =>
variantIdsFilter.add(variantId)
)
})
)
}
}
const discountConditionId = filterableFields.discount_condition_id
delete filterableFields.discount_condition_id
if (discountConditionId) {
// TODO implement later
}
await promiseAll(promises)
if (productIdsFilter.size > 0) {
filterableFields.id = Array.from(productIdsFilter)
}
if (variantIdsFilter.size > 0) {
filterableFields.variants = { id: Array.from(variantIdsFilter) }
}
const variables = {
filters: filterableFields,
order: listConfig.order,
skip: listConfig.skip,
take: listConfig.take,
}
const query = {
product: {
__args: variables,
...defaultAdminProductRemoteQueryObject,
},
}
const {
rows: products,
metadata: { count },
} = await remoteQuery(query)
products.forEach((product) => {
product.profile_id = product.profile?.id
})
return [products, count]
}
/**
* Parameters used to filter and configure the pagination of the retrieved products.
*/
@@ -1,3 +1,4 @@
import { Workflows, updateProducts } from "@medusajs/core-flows"
import { DistributedTransaction } from "@medusajs/orchestration"
import {
FlagRouter,
@@ -5,7 +6,6 @@ import {
MedusaV2Flag,
promiseAll,
} from "@medusajs/utils"
import { updateProducts, Workflows } from "@medusajs/core-flows"
import { Type } from "class-transformer"
import {
IsArray,
@@ -45,6 +45,7 @@ import {
ProductVariantPricesUpdateReq,
UpdateProductVariantInput,
} from "../../../../types/product-variant"
import { retrieveProduct } from "../../../../utils"
import {
createVariantsTransaction,
revertVariantTransaction,
@@ -292,7 +293,11 @@ export default async (req, res) => {
let rawProduct
if (isMedusaV2Enabled) {
rawProduct = await getProductWithIsolatedProductModule(req, id)
rawProduct = await retrieveProduct(
req.scope,
id,
defaultAdminProductRemoteQueryObject
)
} else {
rawProduct = await productService.retrieve(id, {
select: defaultAdminProductFields,
@@ -305,26 +310,6 @@ export default async (req, res) => {
res.json({ product })
}
async function getProductWithIsolatedProductModule(req, id) {
// TODO: Add support for fields/expands
const remoteQuery = req.scope.resolve("remoteQuery")
const variables = { id }
const query = {
product: {
__args: variables,
...defaultAdminProductRemoteQueryObject,
},
}
const [product] = await remoteQuery(query)
product.profile_id = product.profile?.id
return product
}
class ProductVariantOptionReq {
@IsString()
value: string
@@ -1,7 +1,10 @@
import { FlagRouter } from "@medusajs/utils"
import { Request } from "express"
import { IdMap, MockManager } from "medusa-test-utils"
import { AdminPostBatchesReq, defaultAdminProductRelations, } from "../../../../api"
import {
AdminPostBatchesReq,
defaultAdminProductRelations,
} from "../../../../api"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { User } from "../../../../models"
import { BatchJobStatus } from "../../../../types/batch-job"
@@ -117,6 +120,7 @@ describe("Product export strategy", () => {
batchJobService: batchJobServiceMock as any,
productService: productServiceMock as any,
featureFlagRouter: new FlagRouter({}),
remoteQuery: (() => {}) as any,
})
it("should generate the appropriate template", async () => {
@@ -264,6 +268,7 @@ describe("Product export strategy", () => {
productService: productServiceWithNoDataMock as any,
manager: MockManager,
featureFlagRouter: new FlagRouter({}),
remoteQuery: (() => {}) as any,
})
await productExportStrategy.prepareBatchJobForProcessing(
@@ -283,6 +288,7 @@ describe("Product export strategy", () => {
productService: productServiceMock as any,
manager: MockManager,
featureFlagRouter: new FlagRouter({}),
remoteQuery: (() => {}) as any,
})
await productExportStrategy.prepareBatchJobForProcessing(
@@ -381,6 +387,7 @@ describe("Product export strategy with sales Channels", () => {
fileService: fileServiceMock as any,
batchJobService: batchJobServiceMock as any,
productService: productServiceMock as any,
remoteQuery: (() => {}) as any,
featureFlagRouter: new FlagRouter({
[SalesChannelFeatureFlag.key]: true,
}),
@@ -1,8 +1,7 @@
import { EntityManager } from "typeorm"
import { MedusaContainer } from "@medusajs/types"
import { FlagRouter, MedusaV2Flag, createContainerLike } from "@medusajs/utils"
import { humanizeAmount } from "medusa-core-utils"
import { FlagRouter } from "@medusajs/utils"
import { EntityManager } from "typeorm"
import { defaultAdminProductRelations } from "../../../api"
import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces"
import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories"
@@ -11,19 +10,19 @@ import { Product, ProductVariant } from "../../../models"
import { BatchJobService, ProductService } from "../../../services"
import { BatchJobStatus, CreateBatchJobInput } from "../../../types/batch-job"
import { FindProductConfig } from "../../../types/product"
import { csvCellContentFormatter } from "../../../utils"
import { csvCellContentFormatter, listProducts } from "../../../utils"
import { prepareListQuery } from "../../../utils/get-query-config"
import {
DynamicProductExportDescriptor,
ProductExportBatchJob,
ProductExportBatchJobContext,
ProductExportInjectedDependencies,
ProductExportPriceData,
DynamicProductExportDescriptor,
ProductExportBatchJob,
ProductExportBatchJobContext,
ProductExportInjectedDependencies,
ProductExportPriceData,
} from "./types"
import {
productCategoriesColumnsDefinition,
productColumnsDefinition,
productSalesChannelColumnsDefinition,
productCategoriesColumnsDefinition,
productColumnsDefinition,
productSalesChannelColumnsDefinition,
} from "./types/columns-definition"
export default class ProductExportStrategy extends AbstractBatchJobStrategy {
@@ -37,6 +36,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
protected readonly productService_: ProductService
protected readonly fileService_: IFileService
protected readonly featureFlagRouter_: FlagRouter
protected readonly remoteQuery_: any
protected readonly defaultRelations_ = [
...defaultAdminProductRelations,
@@ -68,6 +68,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
productService,
fileService,
featureFlagRouter,
remoteQuery,
}: ProductExportInjectedDependencies) {
super({
manager,
@@ -75,8 +76,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
productService,
fileService,
featureFlagRouter,
remoteQuery,
})
this.remoteQuery_ = remoteQuery
this.manager_ = manager
this.batchJobService_ = batchJobService
this.productService_ = productService
@@ -135,6 +138,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
}
async preProcessBatchJob(batchJobId: string): Promise<void> {
const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled(
MedusaV2Flag.key
)
return await this.atomicPhase_(async (transactionManager) => {
const batchJob = (await this.batchJobService_
.withTransaction(transactionManager)
@@ -144,12 +151,29 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
const limit = batchJob.context?.list_config?.take ?? this.DEFAULT_LIMIT
const { list_config = {}, filterable_fields = {} } = batchJob.context
const [productList, count] = await this.productService_
.withTransaction(transactionManager)
.listAndCount(filterable_fields, {
...(list_config ?? {}),
take: Math.min(batchJob.context.batch_size ?? Infinity, limit),
} as FindProductConfig)
let productList: Product[] = []
let count = 0
const container = createContainerLike(
this.__container__
) as MedusaContainer
if (isMedusaV2Enabled) {
;[productList, count] = await listProducts(
container,
filterable_fields,
{
...list_config,
take: null,
}
)
} else {
;[productList, count] = await this.productService_
.withTransaction(transactionManager)
.listAndCount(filterable_fields, {
...(list_config ?? {}),
take: Math.min(batchJob.context.batch_size ?? Infinity, limit),
} as FindProductConfig)
}
const productCount = batchJob.context?.batch_size ?? count
let products: Product[] = productList
@@ -162,13 +186,25 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
while (offset < productCount) {
if (!products?.length) {
products = await this.productService_
.withTransaction(transactionManager)
.list(filterable_fields, {
...list_config,
skip: offset,
take: Math.min(productCount - offset, limit),
} as FindProductConfig)
if (isMedusaV2Enabled) {
;[productList, count] = await listProducts(
container,
filterable_fields,
{
...list_config,
skip: offset,
take: Math.min(productCount - offset, limit),
}
)
} else {
products = await this.productService_
.withTransaction(transactionManager)
.list(filterable_fields, {
...list_config,
skip: offset,
take: Math.min(productCount - offset, limit),
} as FindProductConfig)
}
}
const shapeData = this.getProductRelationsDynamicColumnsShape(products)
@@ -669,7 +705,8 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
if (
this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key)
) {
const salesChannelCount = product?.sales_channels?.length ?? 0
const salesChannelCount =
(product as Product)?.sales_channels?.length ?? 0
salesChannelsColumnCount = Math.max(
salesChannelsColumnCount,
salesChannelCount
@@ -1,8 +1,14 @@
/* eslint-disable valid-jsdoc */
import { computerizeAmount, MedusaError } from "medusa-core-utils"
import {
CreateProductVariants,
UpdateProductVariants,
createProducts,
updateProducts,
} from "@medusajs/core-flows"
import { ProductWorkflow } from "@medusajs/types"
import { FlagRouter, MedusaV2Flag, promiseAll } from "@medusajs/utils"
import { MedusaError, computerizeAmount } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { FlagRouter, promiseAll } from "@medusajs/utils"
import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces"
import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories"
import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels"
@@ -109,7 +115,6 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
})
this.featureFlagRouter_ = featureFlagRouter
this.manager_ = manager
this.fileService_ = fileService
this.batchJobService_ = batchJobService
@@ -168,7 +173,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
const variantsUpdate: TParsedProductImportRowData[] = []
for (const row of csvData) {
if ((row["variant.prices"] as Record<string, any>[]).length) {
if ((row["variant.prices"] as Record<string, any>[])?.length) {
await this.prepareVariantPrices(row)
}
@@ -278,6 +283,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
let totalOperationCount = 0
const operationsCounts = {}
Object.keys(ops).forEach((key) => {
operationsCounts[key] = ops[key].length
totalOperationCount += ops[key].length
@@ -417,6 +423,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
return
}
const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled(
MedusaV2Flag.key
)
const transactionManager = this.transactionManager_ ?? this.manager_
const productOps = await this.downloadImportOpsFile(
@@ -468,10 +478,27 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
)
}
// TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData
await productServiceTx.create(
productData as unknown as CreateProductInput
)
if (isMedusaV2Enabled) {
const createProductWorkflow = createProducts(this.__container__)
const input = {
products: [
productData,
] as unknown as ProductWorkflow.CreateProductInputDTO[],
}
await createProductWorkflow.run({
input,
context: {
manager: transactionManager,
},
})
} else {
// TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData
await productServiceTx.create(
productData as unknown as CreateProductInput
)
}
} catch (e) {
ProductImportStrategy.throwDescriptiveError(productOp, e.message)
}
@@ -490,6 +517,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
return
}
const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled(
MedusaV2Flag.key
)
const transactionManager = this.transactionManager_ ?? this.manager_
const productOps = await this.downloadImportOpsFile(
batchJob,
@@ -541,11 +572,31 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
)
}
// TODO: we should only pass the expected data. Here we are passing everything contained in productData
await productServiceTx.update(
productOp["product.id"] as string,
productData
)
if (isMedusaV2Enabled) {
const updateProductWorkflow = updateProducts(this.__container__)
const input = {
products: [
{
id: productOp["product.id"] as string,
...productData,
},
],
}
await updateProductWorkflow.run({
input,
context: {
manager: transactionManager,
},
})
} else {
// TODO: we should only pass the expected data. Here we are passing everything contained in productData
await productServiceTx.update(
productOp["product.id"] as string,
productData
)
}
} catch (e) {
ProductImportStrategy.throwDescriptiveError(productOp, e.message)
}
@@ -565,6 +616,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
return
}
const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled(
MedusaV2Flag.key
)
const transactionManager = this.transactionManager_ ?? this.manager_
const variantOps = await this.downloadImportOpsFile(
@@ -600,9 +655,30 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
delete variant.id
delete variant.product
await this.productVariantService_
.withTransaction(transactionManager)
.create(product!, variant as unknown as CreateProductVariantInput)
if (isMedusaV2Enabled) {
const createProductVariantsWorkflow =
CreateProductVariants.createProductVariants(this.__container__)
const input: ProductWorkflow.CreateProductVariantsWorkflowInputDTO = {
productVariants: [
{
...variant,
product_id: product.id,
},
],
}
await createProductVariantsWorkflow.run({
input,
context: {
manager: transactionManager,
},
})
} else {
await this.productVariantService_
.withTransaction(transactionManager)
.create(product!, variant as unknown as CreateProductVariantInput)
}
await this.updateProgress(batchJob.id)
} catch (e) {
@@ -621,6 +697,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
return
}
const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled(
MedusaV2Flag.key
)
const transactionManager = this.transactionManager_ ?? this.manager_
const variantOps = await this.downloadImportOpsFile(
@@ -637,15 +717,36 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
variantOp["product.handle"] as string
)
await this.prepareVariantOptions(variantOp, product.id)
await this.prepareVariantOptions(variantOp, product.id!)
const updateData = transformVariantData(variantOp)
delete updateData.product
delete updateData["product.handle"]
await this.productVariantService_
.withTransaction(transactionManager)
.update(variantOp["variant.id"] as string, updateData)
if (isMedusaV2Enabled) {
const updateProductVariantsWorkflow =
UpdateProductVariants.updateProductVariants(this.__container__)
const input: ProductWorkflow.UpdateProductVariantsWorkflowInputDTO = {
productVariants: [
{
id: variantOp["variant.id"] as string,
...updateData,
},
],
}
await updateProductVariantsWorkflow.run({
input,
context: {
manager: transactionManager,
},
})
} else {
await this.productVariantService_
.withTransaction(transactionManager)
.update(variantOp["variant.id"] as string, updateData)
}
} catch (e) {
ProductImportStrategy.throwDescriptiveError(variantOp, e.message)
}
@@ -1,3 +1,4 @@
import { RemoteQueryFunction } from "@medusajs/types"
import { FlagRouter } from "@medusajs/utils"
import { FileService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
@@ -5,14 +6,14 @@ import { IFileService } from "../../../../interfaces"
import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser"
import { BatchJob, Product, ProductVariant } from "../../../../models"
import {
BatchJobService,
ProductCategoryService,
ProductCollectionService,
ProductService,
ProductVariantService,
RegionService,
SalesChannelService,
ShippingProfileService,
BatchJobService,
ProductCategoryService,
ProductCollectionService,
ProductService,
ProductVariantService,
RegionService,
SalesChannelService,
ShippingProfileService,
} from "../../../../services"
import { Selector } from "../../../../types/common"
@@ -22,6 +23,7 @@ export type ProductExportInjectedDependencies = {
productService: ProductService
fileService: IFileService
featureFlagRouter: FlagRouter
remoteQuery: RemoteQueryFunction
}
export type ProductExportBatchJobContext = {
+1
View File
@@ -11,6 +11,7 @@ export * from "./is-object"
export * from "./is-string"
export * from "./omit-deep"
export * from "./product-category"
export * from "./queries"
export * from "./remote-query-fetch-data"
export * from "./remove-undefined-properties"
export * from "./set-metadata"
@@ -0,0 +1 @@
export * from "./products"
@@ -0,0 +1,34 @@
import { MedusaContainer, ProductVariantDTO } from "@medusajs/types"
export async function getVariantsFromPriceList(
container: MedusaContainer,
priceListId: string
) {
const remoteQuery = container.resolve("remoteQuery")
const query = {
price_list: {
__args: { id: [priceListId] },
price_set_money_amounts: {
price_set: {
variant_link: { variant: { fields: ["id", "product_id"] } },
},
},
},
}
const priceLists = await remoteQuery(query)
const variants: ProductVariantDTO[] = []
priceLists.forEach((priceList) => {
priceList.price_set_money_amounts?.forEach((psma) => {
const variant = psma.price_set?.variant_link?.variant
if (variant) {
variants.push(variant)
}
})
})
return variants
}
@@ -0,0 +1,3 @@
export * from "./get-variants-from-price-list"
export * from "./list-products"
export * from "./retrieve-product"
@@ -0,0 +1,248 @@
import { MedusaContainer } from "@medusajs/types"
import { MedusaV2Flag, promiseAll } from "@medusajs/utils"
import { PriceListService, SalesChannelService } from "../../../services"
import { getVariantsFromPriceList } from "./get-variants-from-price-list"
export async function listProducts(
container: MedusaContainer,
filterableFields,
listConfig
) {
// TODO: Add support for fields/expands
const remoteQuery = container.resolve("remoteQuery")
const featureFlagRouter = container.resolve("featureFlagRouter")
const productIdsFilter: Set<string> = new Set()
const variantIdsFilter: Set<string> = new Set()
const promises: Promise<void>[] = []
// This is not the best way of handling cross filtering but for now I would say it is fine
const salesChannelIdFilter = filterableFields.sales_channel_id
delete filterableFields.sales_channel_id
if (salesChannelIdFilter) {
const salesChannelService = container.resolve(
"salesChannelService"
) as SalesChannelService
promises.push(
salesChannelService
.listProductIdsBySalesChannelIds(salesChannelIdFilter)
.then((productIdsInSalesChannel) => {
let filteredProductIds =
productIdsInSalesChannel[salesChannelIdFilter]
if (filterableFields.id) {
filterableFields.id = Array.isArray(filterableFields.id)
? filterableFields.id
: [filterableFields.id]
const salesChannelProductIdsSet = new Set(filteredProductIds)
filteredProductIds = filterableFields.id.filter((productId) =>
salesChannelProductIdsSet.has(productId)
)
}
filteredProductIds.map((id) => productIdsFilter.add(id))
})
)
}
const priceListId = filterableFields.price_list_id
delete filterableFields.price_list_id
if (priceListId) {
if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
const variants = await getVariantsFromPriceList(container, priceListId)
variants.forEach((pv) => variantIdsFilter.add(pv.id))
} else {
// TODO: it is working but validate the behaviour.
// e.g pricing context properly set.
// At the moment filtering by price list but not having any customer id or
// include discount forces the query to filter with price list id is null
const priceListService = container.resolve(
"priceListService"
) as PriceListService
promises.push(
priceListService
.listPriceListsVariantIdsMap(priceListId)
.then((priceListVariantIdsMap) => {
priceListVariantIdsMap[priceListId].map((variantId) =>
variantIdsFilter.add(variantId)
)
})
)
}
}
const discountConditionId = filterableFields.discount_condition_id
delete filterableFields.discount_condition_id
if (discountConditionId) {
// TODO implement later
}
await promiseAll(promises)
if (productIdsFilter.size > 0) {
filterableFields.id = Array.from(productIdsFilter)
}
if (variantIdsFilter.size > 0) {
filterableFields.variants = { id: Array.from(variantIdsFilter) }
}
const variables = {
filters: filterableFields,
order: listConfig.order,
skip: listConfig.skip,
take: listConfig.take,
}
const query = {
product: {
__args: variables,
...defaultAdminProductRemoteQueryObject,
},
}
const {
rows: products,
metadata: { count },
} = await remoteQuery(query)
products.forEach((product) => {
product.profile_id = product.profile?.id
})
return [products, count]
}
export const defaultAdminProductRemoteQueryObject = {
fields: [
"id",
"title",
"subtitle",
"status",
"external_id",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"collection_id",
"type_id",
"weight",
"length",
"height",
"width",
"hs_code",
"origin_country",
"mid_code",
"material",
"created_at",
"updated_at",
"deleted_at",
"metadata",
],
images: {
fields: ["id", "created_at", "updated_at", "deleted_at", "url", "metadata"],
},
tags: {
fields: ["id", "created_at", "updated_at", "deleted_at", "value"],
},
type: {
fields: ["id", "created_at", "updated_at", "deleted_at", "value"],
},
collection: {
fields: ["title", "handle", "id", "created_at", "updated_at", "deleted_at"],
},
categories: {
fields: [
"id",
"name",
"description",
"handle",
"is_active",
"is_internal",
"parent_category_id",
],
},
options: {
fields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"title",
"product_id",
"metadata",
],
values: {
fields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"value",
"option_id",
"variant_id",
"metadata",
],
},
},
variants: {
fields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"title",
"product_id",
"sku",
"barcode",
"ean",
"upc",
"variant_rank",
"inventory_quantity",
"allow_backorder",
"manage_inventory",
"hs_code",
"origin_country",
"mid_code",
"material",
"weight",
"length",
"height",
"width",
"metadata",
],
options: {
fields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"value",
"option_id",
"variant_id",
"metadata",
],
},
},
profile: {
fields: ["id", "created_at", "updated_at", "deleted_at", "name", "type"],
},
}
@@ -0,0 +1,28 @@
import { MedusaError } from "@medusajs/utils"
export async function retrieveProduct(container, id, remoteQueryObject = {}) {
// TODO: Add support for fields/expands
const remoteQuery = container.resolve("remoteQuery")
const variables = { id }
const query = {
product: {
__args: variables,
...remoteQueryObject,
},
}
const [product] = await remoteQuery(query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id: ${id} not found`
)
}
product.profile_id = product.profile?.id
return product
}
@@ -1,9 +1,9 @@
import { Context, LoadedModule, MedusaContainer } from "@medusajs/types"
import { WorkflowDefinition, WorkflowManager } from "./workflow-manager"
import { createContainerLike, createMedusaContainer } from "@medusajs/utils"
import { asValue } from "awilix"
import { DistributedTransaction } from "../transaction"
import { asValue } from "awilix"
import { createMedusaContainer } from "@medusajs/utils"
import { WorkflowDefinition, WorkflowManager } from "./workflow-manager"
export class GlobalWorkflow extends WorkflowManager {
protected static workflows: Map<string, WorkflowDefinition> = new Map()
@@ -16,17 +16,17 @@ export class GlobalWorkflow extends WorkflowManager {
) {
super()
const container = createMedusaContainer()
let container
// Medusa container
if (!Array.isArray(modulesLoaded) && modulesLoaded) {
const cradle = modulesLoaded.cradle
for (const key in cradle) {
container.register(key, asValue(cradle[key]))
if (!("cradle" in modulesLoaded)) {
container = createContainerLike(modulesLoaded)
} else {
container = modulesLoaded
}
}
// Array of modules
else if (modulesLoaded?.length) {
} else if (Array.isArray(modulesLoaded) && modulesLoaded.length) {
container = createMedusaContainer()
for (const mod of modulesLoaded) {
const registrationName = mod.__definition.registrationName
container.register(registrationName, asValue(mod))
@@ -1,5 +1,5 @@
import { Context, LoadedModule, MedusaContainer } from "@medusajs/types"
import { createMedusaContainer } from "@medusajs/utils"
import { createContainerLike, createMedusaContainer } from "@medusajs/utils"
import { asValue } from "awilix"
import {
DistributedTransaction,
@@ -27,7 +27,7 @@ export class LocalWorkflow {
constructor(
workflowId: string,
modulesLoaded?: LoadedModule[] | MedusaContainer
modulesLoaded: LoadedModule[] | MedusaContainer
) {
const globalWorkflow = WorkflowManager.getWorkflow(workflowId)
if (!globalWorkflow) {
@@ -39,17 +39,17 @@ export class LocalWorkflow {
this.workflow = globalWorkflow
this.handlers = new Map(globalWorkflow.handlers_)
const container = createMedusaContainer()
let container
// Medusa container
if (!Array.isArray(modulesLoaded) && modulesLoaded) {
const cradle = modulesLoaded.cradle
for (const key of Object.keys(cradle ?? {})) {
container.register(key, asValue(cradle[key]))
if (!("cradle" in modulesLoaded)) {
container = createContainerLike(modulesLoaded)
} else {
container = modulesLoaded
}
}
// Array of modules
else if (modulesLoaded?.length) {
} else if (Array.isArray(modulesLoaded) && modulesLoaded.length) {
container = createMedusaContainer()
for (const mod of modulesLoaded) {
const registrationName = mod.__definition.registrationName
container.register(registrationName, asValue(mod))
@@ -67,15 +67,15 @@ import {
MedusaError,
promiseAll,
} from "@medusajs/utils"
import {
CreateProductOptionValueDTO,
UpdateProductOptionValueDTO,
} from "../types/services/product-option-value"
import {
entityNameToLinkableKeysMap,
joinerConfig,
LinkableKeys,
} from "./../joiner-config"
import {
CreateProductOptionValueDTO,
UpdateProductOptionValueDTO,
} from "../types/services/product-option-value"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
@@ -1063,6 +1063,10 @@ export default class ProductModuleService<
const existingProductVariantsMap = new Map<string, ProductVariant[]>(
data.map((productData) => {
if (productData.variants === undefined) {
return [productData.id, []]
}
const productVariantsForProduct = existingProductVariants.filter(
(variant) => variant.product_id === productData.id
)
@@ -4,3 +4,7 @@ export type MedusaContainer = AwilixContainer & {
registerAdd: <T>(name: string, registration: T) => MedusaContainer
createScope: () => MedusaContainer
}
export type ContainerLike = {
resolve<T = unknown>(key: string): T
}
@@ -0,0 +1,9 @@
import { ContainerLike } from "@medusajs/types"
export function createContainerLike(obj): ContainerLike {
return {
resolve(key: string) {
return obj[key]
},
}
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./array-difference"
export * from "./build-query"
export * from "./camel-to-snake-case"
export * from "./container"
export * from "./create-container-like"
export * from "./deduplicate"
export * from "./deep-equal-obj"
export * from "./errors"