feat: Add support for shipping options prices update (#7028)

This commit is contained in:
Adrien de Peretti
2024-04-11 18:34:55 +02:00
committed by GitHub
parent 51acd1da5b
commit c78915c7c5
13 changed files with 450 additions and 77 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@medusajs/medusa": patch
"@medusajs/core-flows": patch
"@medusajs/pricing": patch
"@medusajs/types": patch
---
feat(): Add support for shipping options prices update
@@ -856,8 +856,14 @@ medusaIntegrationTestRunner({
expect(response.data.values.length).toEqual(2)
expect(response.data.values).toEqual(
expect.arrayContaining([
{ label: "Afghanistan", value: "af" },
{ label: "Albania", value: "al" },
{
label: "Andorra",
value: "ad",
},
{
label: "United Arab Emirates",
value: "ae",
},
])
)
@@ -264,7 +264,7 @@ medusaIntegrationTestRunner({
})
})
it("should create a shipping option successfully", async () => {
it("should update a shipping option successfully", async () => {
const shippingOptionPayload = {
name: "Test shipping option",
service_zone_id: fulfillmentSet.service_zones[0].id,
@@ -295,15 +295,44 @@ medusaIntegrationTestRunner({
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.shipping_option).toEqual(
const shippingOptionId = response.data.shipping_option.id
const eurPrice = response.data.shipping_option.prices.find(
(p) => p.currency_code === "eur"
)
const updateShippingOptionPayload = {
id: shippingOptionId,
name: "Updated shipping option",
provider_id: "manual_test-provider",
price_type: "flat",
prices: [
{
currency_code: "dkk",
amount: 10,
},
{
id: eurPrice.id,
amount: 10000,
},
],
}
const updateResponse = await api.post(
`/admin/shipping-options/${shippingOptionId}`,
updateShippingOptionPayload,
adminHeaders
)
expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.shipping_option.prices).toHaveLength(2)
expect(updateResponse.data.shipping_option).toEqual(
expect.objectContaining({
id: expect.any(String),
name: shippingOptionPayload.name,
name: updateShippingOptionPayload.name,
provider: expect.objectContaining({
id: shippingOptionPayload.provider_id,
}),
price_type: shippingOptionPayload.price_type,
price_type: updateShippingOptionPayload.price_type,
type: expect.objectContaining({
id: expect.any(String),
label: shippingOptionPayload.type.label,
@@ -315,13 +344,15 @@ medusaIntegrationTestRunner({
prices: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
amount: 1000,
currency_code: "dkk",
rules_count: 0,
amount: 10,
}),
expect.objectContaining({
id: expect.any(String),
currency_code: "eur",
amount: 1000,
rules_count: 1,
amount: 10000,
}),
]),
rules: expect.arrayContaining([
@@ -97,6 +97,10 @@ medusaIntegrationTestRunner({
region_id: region.id,
amount: 100,
},
{
currency_code: "dkk",
amount: 1000,
},
],
rules: [
{
@@ -111,26 +115,11 @@ medusaIntegrationTestRunner({
input: [shippingOptionData],
})
const updateData: UpdateShippingOptionsWorkflowInput = {
id: result[0].id,
name: "Test shipping option",
price_type: "flat",
type: {
code: "manual-type",
label: "Manual Type",
description: "Manual Type Description",
},
}
await updateShippingOptionsWorkflow(container).run({
input: [updateData],
})
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
let remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "shipping_option",
variables: {
id: result[0].id,
@@ -155,10 +144,50 @@ medusaIntegrationTestRunner({
const [createdShippingOption] = await remoteQuery(remoteQueryObject)
const prices = createdShippingOption.prices
delete createdShippingOption.prices
const usdPrice = createdShippingOption.prices.find((price) => {
return price.currency_code === "usd"
})
expect(createdShippingOption).toEqual(
const dkkPrice = createdShippingOption.prices.find((price) => {
return price.currency_code === "dkk"
})
const updateData: UpdateShippingOptionsWorkflowInput = {
id: createdShippingOption.id,
name: "Test shipping option",
price_type: "flat",
type: {
code: "manual-type",
label: "Manual Type",
description: "Manual Type Description",
},
prices: [
// We keep the usd price as is
// update the dkk price to 100
// delete the third price eur
// create a new eur one instead
usdPrice,
{
...dkkPrice,
amount: 100,
},
{
region_id: region.id,
amount: 1000,
},
],
}
await updateShippingOptionsWorkflow(container).run({
input: [updateData],
})
const [updatedShippingOption] = await remoteQuery(remoteQueryObject)
const prices = updatedShippingOption.prices
delete updatedShippingOption.prices
expect(updatedShippingOption).toEqual(
expect.objectContaining({
id: result[0].id,
name: updateData.name,
@@ -178,7 +207,7 @@ medusaIntegrationTestRunner({
})
)
expect(prices).toHaveLength(2)
expect(prices).toHaveLength(3)
expect(prices).toContainEqual(
expect.objectContaining({
currency_code: "usd",
@@ -188,8 +217,13 @@ medusaIntegrationTestRunner({
expect(prices).toContainEqual(
expect.objectContaining({
currency_code: "eur",
amount: 1000,
})
)
expect(prices).toContainEqual(
expect.objectContaining({
currency_code: "dkk",
amount: 100,
rules_count: 1,
})
)
})
@@ -7,3 +7,4 @@ export * from "./delete-service-zones"
export * from "./delete-shipping-options"
export * from "./create-shipping-profiles"
export * from "./remove-rules-from-fulfillment-shipping-option"
export * from "./set-shipping-options-prices"
@@ -0,0 +1,193 @@
import {
CreatePriceDTO,
CreatePricesDTO,
FulfillmentWorkflow,
IPricingModuleService,
IRegionModuleService,
PriceDTO,
PriceSetDTO,
RemoteQueryFunction,
} from "@medusajs/types"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import {
ContainerRegistrationKeys,
isDefined,
LINKS,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
interface PriceRegionId {
region_id: string
amount: number
}
type SetShippingOptionsPricesStepInput = {
id: string
prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"]
}[]
async function getCurrentShippingOptionPrices(
shippingOptionIds: string[],
{ remoteQuery }: { remoteQuery: RemoteQueryFunction }
): Promise<
{ shipping_option_id: string; price_set_id: string; prices: PriceDTO[] }[]
> {
const query = remoteQueryObjectFromString({
service: LINKS.ShippingOptionPriceSet,
variables: {
filters: { shipping_option_id: shippingOptionIds },
take: null,
},
fields: ["shipping_option_id", "price_set_id", "price_set.prices.*"],
})
const shippingOptionPrices = (await remoteQuery(query)) as {
shipping_option_id: string
price_set_id: string
price_set: PriceSetDTO
}[]
return shippingOptionPrices.map((shippingOption) => {
const prices = shippingOption.price_set?.prices ?? []
const price_set_id = shippingOption.price_set_id
return {
shipping_option_id: shippingOption.shipping_option_id,
price_set_id,
prices,
}
})
}
function buildPrices(
prices: SetShippingOptionsPricesStepInput[0]["prices"],
regionToCurrencyMap: Map<string, string>
): CreatePriceDTO[] {
if (!prices) {
return []
}
const shippingOptionPrices = prices.map((price) => {
if ("region_id" in price) {
const currency_code = regionToCurrencyMap.get(price.region_id!)!
const regionId = price.region_id
delete price.region_id
return {
...price,
currency_code: currency_code,
amount: price.amount,
rules: {
region_id: regionId,
},
}
}
return price
})
return shippingOptionPrices as CreatePriceDTO[]
}
export const setShippingOptionsPricesStepId = "set-shipping-options-prices-step"
export const setShippingOptionsPricesStep = createStep(
setShippingOptionsPricesStepId,
async (data: SetShippingOptionsPricesStepInput, { container }) => {
if (!data.length) {
return
}
const regionIds = data
.map((input) => input.prices)
.flat()
.filter((price): price is PriceRegionId => "region_id" in (price ?? {}))
.map((price) => price.region_id)
let regionToCurrencyMap: Map<string, string> = new Map()
if (regionIds.length) {
const regionService = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
const regions = await regionService.list(
{
id: [...new Set(regionIds)],
},
{
select: ["id", "currency_code"],
}
)
regionToCurrencyMap = new Map(
regions.map((region) => [region.id, region.currency_code])
)
}
const remoteQuery = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.REMOTE_QUERY
)
const currentShippingOptionPricesData =
await getCurrentShippingOptionPrices(
data.map((d) => d.id),
{ remoteQuery }
)
const shippingOptionPricesMap = new Map(
currentShippingOptionPricesData.map((currentShippingOptionDataItem) => {
const shippingOptionData = data.find(
(d) => d.id === currentShippingOptionDataItem.shipping_option_id
)!
const pricesData = shippingOptionData?.prices?.map((priceData) => {
return {
...priceData,
price_set_id: currentShippingOptionDataItem.price_set_id,
}
})
const buildPricesData =
pricesData && buildPrices(pricesData, regionToCurrencyMap)
return [
currentShippingOptionDataItem.shipping_option_id,
{
price_set_id: currentShippingOptionDataItem.price_set_id,
prices: buildPricesData,
},
]
})
)
const pricingService = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
for (const data_ of data) {
const shippingOptionData = shippingOptionPricesMap.get(data_.id)!
if (!isDefined(shippingOptionData.prices)) {
continue
}
await pricingService.update(shippingOptionData.price_set_id, {
prices: shippingOptionData.prices,
})
}
return new StepResponse(void 0, currentShippingOptionPricesData)
},
async (rollbackData, { container }) => {
if (!rollbackData?.length) {
return
}
const pricingService = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
for (const data_ of rollbackData) {
const prices = data_.prices as CreatePricesDTO[]
if (!isDefined(prices)) {
continue
}
await pricingService.update(data_.price_set_id, { prices })
}
}
)
@@ -22,9 +22,10 @@ export const createShippingOptionsWorkflow = createWorkflow(
): WorkflowData<FulfillmentWorkflow.CreateShippingOptionsWorkflowOutput> => {
const data = transform(input, (data) => {
const shippingOptionsIndexToPrices = data.map((option, index) => {
const prices = option.prices
return {
shipping_option_index: index,
prices: option.prices,
prices,
}
})
@@ -1,10 +1,18 @@
import { FulfillmentWorkflow } from "@medusajs/types"
import {
CreateRuleTypeDTO,
FulfillmentWorkflow,
RuleTypeDTO,
} from "@medusajs/types"
import {
createWorkflow,
transform,
WorkflowData,
} from "@medusajs/workflows-sdk"
import { upsertShippingOptionsStep } from "../steps"
import {
setShippingOptionsPricesStep,
upsertShippingOptionsStep,
} from "../steps"
import { createPricingRuleTypesStep } from "../../pricing"
export const updateShippingOptionsWorkflowId =
"update-shipping-options-workflow"
@@ -17,9 +25,11 @@ export const updateShippingOptionsWorkflow = createWorkflow(
): WorkflowData<FulfillmentWorkflow.UpdateShippingOptionsWorkflowOutput> => {
const data = transform(input, (data) => {
const shippingOptionsIndexToPrices = data.map((option, index) => {
const prices = option.prices
delete option.prices
return {
shipping_option_index: index,
prices: option.prices,
prices,
}
})
@@ -33,7 +43,7 @@ export const updateShippingOptionsWorkflow = createWorkflow(
data.shippingOptions
)
/*const normalizedShippingOptionsPrices = transform(
const normalizedShippingOptionsPrices = transform(
{
shippingOptions: updatedShippingOptions,
shippingOptionsIndexToPrices: data.shippingOptionsIndexToPrices,
@@ -60,32 +70,16 @@ export const updateShippingOptionsWorkflow = createWorkflow(
return {
shippingOptionsPrices,
ruleTypes: Array.from(ruleTypes) as UpdateRuleTypeDTO[],
ruleTypes: Array.from(ruleTypes) as CreateRuleTypeDTO[],
}
}
)*/
/*updatePricingRuleTypesStep(normalizedShippingOptionsPrices.ruleTypes)*/
/*const shippingOptionsPriceSetsLinkData = updateShippingOptionsPriceSetsStep(
normalizedShippingOptionsPrices.shippingOptionsPrices
)
const normalizedLinkData = transform(
{
shippingOptionsPriceSetsLinkData,
},
(data) => {
return data.shippingOptionsPriceSetsLinkData.map((item) => {
return {
id: item.id,
price_sets: [item.priceSetId],
}
})
}
)*/
createPricingRuleTypesStep(normalizedShippingOptionsPrices.ruleTypes)
/*setShippingOptionsPriceSetsStep(normalizedLinkData)*/
setShippingOptionsPricesStep(
normalizedShippingOptionsPrices.shippingOptionsPrices
)
return updatedShippingOptions
}
@@ -70,6 +70,23 @@ export const AdminCreateShippingOptionPriceWithRegion = z
})
.strict()
export const AdminUpdateShippingOptionPriceWithCurrency =z
.object({
id: z.string().optional(),
currency_code: z.string().optional(),
amount: z.number().optional(),
})
.strict()
export const AdminUpdateShippingOptionPriceWithRegion =
z
.object({
id: z.string().optional(),
region_id: z.string().optional(),
amount: z.number().optional(),
})
.strict()
export const AdminCreateShippingOption = z
.object({
name: z.string(),
@@ -98,6 +115,11 @@ export const AdminUpdateShippingOption = z
price_type: z.nativeEnum(ShippingOptionPriceTypeEnum).optional(),
provider_id: z.string().optional(),
type: AdminCreateShippingOptionTypeObject.optional(),
prices: AdminUpdateShippingOptionPriceWithCurrency.or(
AdminUpdateShippingOptionPriceWithRegion
)
.array()
.optional(),
})
.strict()
+12 -5
View File
@@ -1,8 +1,14 @@
import { getSetDifference, stringToSelectRelationObject } from "@medusajs/utils"
import {
getSetDifference,
isPresent,
stringToSelectRelationObject,
} from "@medusajs/utils"
import { pick } from "lodash"
import { MedusaError, isDefined } from "medusa-core-utils"
import { isDefined, MedusaError } from "medusa-core-utils"
import { BaseEntity } from "../interfaces"
import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common"
import { featureFlagRouter } from "../loaders/feature-flags"
import MedusaV2 from "../loaders/feature-flags/medusa-v2"
export function pickByConfig<TModel extends BaseEntity>(
obj: TModel | TModel[],
@@ -24,7 +30,7 @@ export function prepareListQuery<
T extends RequestQueryFields,
TEntity extends BaseEntity
>(validated: T, queryConfig: QueryConfig<TEntity> = {}) {
const isMedusaV2 = process.env.MEDUSA_FF_MEDUSA_V2 == "true"
const isMedusaV2 = featureFlagRouter.isFeatureEnabled(MedusaV2.key)
// TODO: this function will be simplified a lot once we drop support for the old api
const { order, fields, limit = 50, expand, offset = 0 } = validated
@@ -189,13 +195,14 @@ export function prepareListQuery<
}
}
const finalOrder = isPresent(orderBy) ? orderBy : undefined
return {
listConfig: {
select: select.length ? select : undefined,
relations: Array.from(allRelations),
skip: offset,
take: limit ?? defaultLimit,
order: orderBy,
order: finalOrder,
},
remoteQueryConfig: {
// Add starFields that are relations only on which we want all properties with a dedicated format to the remote query
@@ -207,7 +214,7 @@ export function prepareListQuery<
? {
skip: offset,
take: limit ?? defaultLimit,
order: orderBy,
order: finalOrder,
}
: {},
},
+83 -10
View File
@@ -2,8 +2,8 @@ import {
AddPricesDTO,
Context,
CreatePriceListRuleDTO,
CreatePriceSetDTO,
CreatePricesDTO,
CreatePriceSetDTO,
DAL,
InternalModuleDeclaration,
ModuleJoinerConfig,
@@ -23,7 +23,7 @@ import {
groupBy,
InjectManager,
InjectTransactionManager,
isDefined,
isDefined, isPresent,
isString,
MedusaContext,
MedusaError,
@@ -44,12 +44,12 @@ import {
RuleType,
} from "@models"
import { PriceListService, RuleTypeService } from "@services"
import { validatePriceListDates } from "@utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { PriceSetIdPrefix } from "../models/price-set"
import { PriceListIdPrefix } from "../models/price-list"
import { UpdatePriceSetInput } from "src/types/services"
import {PriceListService, RuleTypeService} from "@services"
import {validatePriceListDates} from "@utils"
import {entityNameToLinkableKeysMap, joinerConfig} from "../joiner-config"
import {PriceSetIdPrefix} from "../models/price-set"
import {PriceListIdPrefix} from "../models/price-list"
import {UpdatePriceSetInput} from "src/types/services"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
@@ -336,6 +336,54 @@ export default class PricingModuleService<
return isString(idOrSelector) ? priceSets[0] : priceSets
}
private async normalizeUpdateData(
data: UpdatePriceSetInput[],
sharedContext
) {
const ruleAttributes = data
.map((d) => d.prices?.map((p) => Object.keys(p.rules ?? [])) ?? [])
.flat(Infinity)
.filter(Boolean)
const ruleTypes = await this.ruleTypeService_.list(
{ rule_attribute: ruleAttributes },
{ take: null },
sharedContext
)
const ruleTypeMap = ruleTypes.reduce((acc, curr) => {
acc.set(curr.rule_attribute, curr)
return acc
}, new Map())
return data.map((priceSet) => {
const prices = priceSet.prices?.map((price) => {
const rules = Object.entries(price.rules ?? {}).map(
([attribute, value]) => {
return {
price_set_id: priceSet.id,
rule_type_id: ruleTypeMap.get(attribute)!.id,
value,
}
}
)
const hasRulesInput = isPresent(price.rules)
delete price.rules
return {
...price,
price_set_id: priceSet.id,
price_rules: hasRulesInput ? rules : undefined,
rules_count: hasRulesInput ? rules.length : undefined
}
})
return {
...priceSet,
prices,
}
})
}
@InjectTransactionManager("baseRepository_")
protected async update_(
data: UpdatePriceSetInput[],
@@ -344,8 +392,33 @@ export default class PricingModuleService<
// TODO: We are not handling rule types, rules, etc. here, add support after data models are finalized
// TODO: Since money IDs are rarely passed, this will delete all previous data and insert new entries.
// We can make the `insert` inside upsertWithReplace do an `upsert` instead to avoid this
return this.priceSetService_.upsertWithReplace(
data,
const normalizedData = await this.normalizeUpdateData(data, sharedContext)
const prices = normalizedData.flatMap((priceSet) => priceSet.prices || [])
const upsertedPrices = await this.priceService_.upsertWithReplace(
prices,
{
relations: ["price_rules"],
},
sharedContext
)
const priceSetsToUpsert = normalizedData.map((priceSet) => {
const { prices, ...rest } = priceSet
return {
...rest,
prices: upsertedPrices
.filter((p) => p.price_set_id === priceSet.id)
.map((price) => {
// @ts-ignore
delete price.price_rules
return price
}),
}
})
return await this.priceSetService_.upsertWithReplace(
priceSetsToUpsert,
{ relations: ["prices"] },
sharedContext
)
@@ -16,12 +16,14 @@ export interface UpdateShippingOptionsWorkflowInput {
}
prices?: (
| {
currency_code: string
amount: number
id?: string
currency_code?: string
amount?: number
}
| {
region_id: string
amount: number
id?: string
region_id?: string
amount?: number
}
)[]
rules?: {
@@ -13,6 +13,7 @@ import {
doNotForceTransaction,
isDefined,
isObject,
isPresent,
isString,
lowerCaseFirst,
MedusaError,
@@ -85,7 +86,7 @@ export function internalModuleServiceFactory<
* @param config
*/
static applyDefaultOrdering(config: FindConfig<any>) {
if (config.order) {
if (isPresent(config.order)) {
return
}