feat(orchestration,core-flows,medusa,product,types,utils): product import/export uses workflows (#5811)
This commit is contained in:
@@ -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
|
||||
|
@@ -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 = {
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user