feat(core-flows, dashboard, fulfillment, fulfillment-manual, utils, types): create shipping options with calculated prices (#10495)

**What**
- support creating SO with calculated price
- support updating SO for both types of pricing
- update `validateShippingOptionPricesStep` to handle both SO price_types
- add the `validateShippingOptionsForPriceCalculation` method to `FulfillementModule`
- add `canCalculate` and `calculatePrice` to fulfillment provider service service / interface / manual provider
- disable SO pricing edit on Admin if SO price type is calculated

---

CLOSES CMRC-776
This commit is contained in:
Frane Polić
2024-12-11 09:38:44 +01:00
committed by GitHub
parent fad85a9d29
commit d8a92dbb2d
13 changed files with 253 additions and 53 deletions

View File

@@ -47,7 +47,10 @@ import {
isOptionEnabledInStore, isOptionEnabledInStore,
isReturnOption, isReturnOption,
} from "../../../../../lib/shipping-options" } from "../../../../../lib/shipping-options"
import { FulfillmentSetType } from "../../../common/constants" import {
FulfillmentSetType,
ShippingOptionPriceType,
} from "../../../common/constants"
type LocationGeneralSectionProps = { type LocationGeneralSectionProps = {
location: HttpTypes.AdminStockLocation location: HttpTypes.AdminStockLocation
@@ -167,6 +170,8 @@ function ShippingOption({
{ {
label: t("stockLocations.shippingOptions.pricing.action"), label: t("stockLocations.shippingOptions.pricing.action"),
icon: <CurrencyDollar />, icon: <CurrencyDollar />,
disabled:
option.price_type === ShippingOptionPriceType.Calculated,
to: `/settings/locations/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${option.service_zone_id}/shipping-option/${option.id}/pricing`, to: `/settings/locations/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${option.service_zone_id}/shipping-option/${option.id}/pricing`,
}, },
], ],

View File

@@ -25,7 +25,7 @@ interface PriceRegionId {
export type SetShippingOptionsPricesStepInput = { export type SetShippingOptionsPricesStepInput = {
id: string id: string
prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"] prices?: FulfillmentWorkflow.UpdateShippingOptionPriceRecord[]
}[] }[]
async function getCurrentShippingOptionPrices( async function getCurrentShippingOptionPrices(

View File

@@ -1,26 +1,85 @@
import { FulfillmentWorkflow } from "@medusajs/framework/types" import { FulfillmentWorkflow } from "@medusajs/framework/types"
import { MedusaError, Modules } from "@medusajs/framework/utils" import {
MedusaError,
Modules,
ShippingOptionPriceType,
} from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
type OptionsInput = (
| FulfillmentWorkflow.CreateShippingOptionsWorkflowInput
| FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput
)[]
export const validateShippingOptionPricesStepId = export const validateShippingOptionPricesStepId =
"validate-shipping-option-prices" "validate-shipping-option-prices"
/** /**
* Validate that regions exist for the shipping option prices. * Validate that shipping options can be crated based on provided price configuration.
*
* For flat rate prices, it validates that regions exist for the shipping option prices.
* For calculated prices, it validates with the fulfillment provider if the price can be calculated.
*/ */
export const validateShippingOptionPricesStep = createStep( export const validateShippingOptionPricesStep = createStep(
validateShippingOptionPricesStepId, validateShippingOptionPricesStepId,
async ( async (options: OptionsInput, { container }) => {
options: { const fulfillmentModuleService = container.resolve(Modules.FULFILLMENT)
prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"]
}[], const optionIds = options.map(
{ container } (option) =>
) => { (option as FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput).id
const allPrices = options.flatMap((option) => option.prices ?? []) )
if (optionIds.length) {
/**
* This means we are validating an update of shipping options.
* We need to ensure that all shipping options have price_type set
* to correctly determine price updates.
*
* (On create, price_type must be defined already.)
*/
const shippingOptions =
await fulfillmentModuleService.listShippingOptions(
{
id: optionIds,
},
{ select: ["id", "price_type", "provider_id"] }
)
const optionsMap = new Map(
shippingOptions.map((option) => [option.id, option])
)
;(
options as FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput[]
).forEach((option) => {
option.price_type =
option.price_type ?? optionsMap.get(option.id)?.price_type
option.provider_id =
option.provider_id ?? optionsMap.get(option.id)?.provider_id
})
}
const flatRatePrices: FulfillmentWorkflow.UpdateShippingOptionPriceRecord[] =
[]
const calculatedOptions: OptionsInput = []
options.forEach((option) => {
if (option.price_type === ShippingOptionPriceType.FLAT) {
flatRatePrices.push(...(option.prices ?? []))
}
if (option.price_type === ShippingOptionPriceType.CALCULATED) {
calculatedOptions.push(option)
}
})
await fulfillmentModuleService.validateShippingOptionsForPriceCalculation(
calculatedOptions as FulfillmentWorkflow.CreateShippingOptionsWorkflowInput[]
)
const regionIdSet = new Set<string>() const regionIdSet = new Set<string>()
allPrices.forEach((price) => { flatRatePrices.forEach((price) => {
if ("region_id" in price && price.region_id) { if ("region_id" in price && price.region_id) {
regionIdSet.add(price.region_id) regionIdSet.add(price.region_id)
} }

View File

@@ -33,7 +33,13 @@ export const createShippingOptionsWorkflow = createWorkflow(
const data = transform(input, (data) => { const data = transform(input, (data) => {
const shippingOptionsIndexToPrices = data.map((option, index) => { const shippingOptionsIndexToPrices = data.map((option, index) => {
const prices = option.prices /**
* Flat rate ShippingOptions always needs to provide a price array.
*
* For calculated pricing we create an "empty" price set
* so we can have simpler update flow for both cases and allow updating price_type.
*/
const prices = (option as any).prices ?? []
return { return {
shipping_option_index: index, shipping_option_index: index,
prices, prices,

View File

@@ -12,6 +12,7 @@ import {
} from "../steps" } from "../steps"
import { validateFulfillmentProvidersStep } from "../steps/validate-fulfillment-providers" import { validateFulfillmentProvidersStep } from "../steps/validate-fulfillment-providers"
import { validateShippingOptionPricesStep } from "../steps/validate-shipping-option-prices" import { validateShippingOptionPricesStep } from "../steps/validate-shipping-option-prices"
import { ShippingOptionPriceType } from "@medusajs/framework/utils"
export const updateShippingOptionsWorkflowId = export const updateShippingOptionsWorkflowId =
"update-shipping-options-workflow" "update-shipping-options-workflow"
@@ -32,11 +33,22 @@ export const updateShippingOptionsWorkflow = createWorkflow(
const data = transform(input, (data) => { const data = transform(input, (data) => {
const shippingOptionsIndexToPrices = data.map((option, index) => { const shippingOptionsIndexToPrices = data.map((option, index) => {
const prices = option.prices const prices = (
delete option.prices option as FulfillmentWorkflow.UpdateFlatRateShippingOptionInput
).prices
delete (option as FulfillmentWorkflow.UpdateFlatRateShippingOptionInput)
.prices
/**
* When we are updating an option to be calculated, remove the prices.
*/
const isCalculatedOption =
option.price_type === ShippingOptionPriceType.CALCULATED
return { return {
shipping_option_index: index, shipping_option_index: index,
prices, prices: isCalculatedOption ? [] : prices,
} }
}) })
@@ -58,8 +70,10 @@ export const updateShippingOptionsWorkflow = createWorkflow(
(data) => { (data) => {
const shippingOptionsPrices = data.shippingOptionsIndexToPrices.map( const shippingOptionsPrices = data.shippingOptionsIndexToPrices.map(
({ shipping_option_index, prices }) => { ({ shipping_option_index, prices }) => {
const option = data.shippingOptions[shipping_option_index]
return { return {
id: data.shippingOptions[shipping_option_index].id, id: option.id,
prices, prices,
} }
} }

View File

@@ -12,6 +12,11 @@ export type FulfillmentOption = {
[k: string]: unknown [k: string]: unknown
} }
export type CalculatedShippingOptionPrice = {
calculated_amount: number
is_calculated_price_tax_inclusive: boolean
}
export interface IFulfillmentProvider { export interface IFulfillmentProvider {
/** /**
* *
@@ -41,7 +46,7 @@ export interface IFulfillmentProvider {
* *
* Check if the provider can calculate the fulfillment price. * Check if the provider can calculate the fulfillment price.
*/ */
canCalculate(data: Record<string, unknown>): Promise<any> canCalculate(data: Record<string, unknown>): Promise<boolean>
/** /**
* *
* Calculate the price for the given fulfillment option. * Calculate the price for the given fulfillment option.
@@ -50,7 +55,7 @@ export interface IFulfillmentProvider {
optionData: Record<string, unknown>, optionData: Record<string, unknown>,
data: Record<string, unknown>, data: Record<string, unknown>,
context: Record<string, unknown> context: Record<string, unknown>
): Promise<any> ): Promise<CalculatedShippingOptionPrice>
/** /**
* *
* Create a fulfillment for the given data. * Create a fulfillment for the given data.

View File

@@ -2625,6 +2625,28 @@ export interface IFulfillmentModuleService extends IModuleService {
context: Record<string, unknown> context: Record<string, unknown>
): Promise<boolean> ): Promise<boolean>
/**
* This method checks whether a shipping option can have calculated price.
*
* @param {FulfillmentTypes.CreateShippingOptionDTO[]} shippingOptionsData - The shipping options data to check.
* @returns {Promise<boolean[]>} Whether the shipping options can have calculated price.
*
* @example
* const isValid =
* await fulfillmentModuleService.validateShippingOptionsForPriceCalculation(
* [
* {
* provider_id: "webshipper",
* price_type: "calculated",
* },
* ]
* )
*/
validateShippingOptionsForPriceCalculation(
shippingOptionsData: CreateShippingOptionDTO[],
sharedContext?: Context
): Promise<boolean[]>
/** /**
* This method retrieves a paginated list of fulfillment providers based on optional filters and configuration. * This method retrieves a paginated list of fulfillment providers based on optional filters and configuration.
* *

View File

@@ -1,28 +1,27 @@
import { ShippingOptionDTO, ShippingOptionPriceType } from "../../fulfillment" import { ShippingOptionDTO } from "../../fulfillment"
import { RuleOperatorType } from "../../common" import { RuleOperatorType } from "../../common"
export interface CreateShippingOptionsWorkflowInput { type CreateFlatRateShippingOptionPriceRecord =
| {
currency_code: string
amount: number
}
| {
region_id: string
amount: number
}
type CreateFlatShippingOptionInputBase = {
name: string name: string
service_zone_id: string service_zone_id: string
shipping_profile_id: string shipping_profile_id: string
data?: Record<string, unknown> data?: Record<string, unknown>
price_type: ShippingOptionPriceType
provider_id: string provider_id: string
type: { type: {
label: string label: string
description: string description: string
code: string code: string
} }
prices: (
| {
currency_code: string
amount: number
}
| {
region_id: string
amount: number
}
)[]
rules?: { rules?: {
attribute: string attribute: string
operator: RuleOperatorType operator: RuleOperatorType
@@ -30,4 +29,17 @@ export interface CreateShippingOptionsWorkflowInput {
}[] }[]
} }
type CreateFlatRateShippingOptionInput = CreateFlatShippingOptionInputBase & {
price_type: "flat"
prices: CreateFlatRateShippingOptionPriceRecord[]
}
type CreateCalculatedShippingOptionInput = CreateFlatShippingOptionInputBase & {
price_type: "calculated"
}
export type CreateShippingOptionsWorkflowInput =
| CreateFlatRateShippingOptionInput
| CreateCalculatedShippingOptionInput
export type CreateShippingOptionsWorkflowOutput = ShippingOptionDTO[] export type CreateShippingOptionsWorkflowOutput = ShippingOptionDTO[]

View File

@@ -1,34 +1,18 @@
import { RuleOperatorType } from "../../common" import { RuleOperatorType } from "../../common"
import { ShippingOptionPriceType } from "../../fulfillment"
import { PriceRule } from "../../pricing" import { PriceRule } from "../../pricing"
export interface UpdateShippingOptionsWorkflowInput { type UpdateFlatShippingOptionInputBase = {
id: string id: string
name?: string name?: string
service_zone_id?: string service_zone_id?: string
shipping_profile_id?: string shipping_profile_id?: string
data?: Record<string, unknown> data?: Record<string, unknown>
price_type?: ShippingOptionPriceType
provider_id?: string provider_id?: string
type?: { type?: {
label: string label: string
description: string description: string
code: string code: string
} }
prices?: (
| {
id?: string
currency_code?: string
amount?: number
rules?: PriceRule[]
}
| {
id?: string
region_id?: string
amount?: number
rules?: PriceRule[]
}
)[]
rules?: { rules?: {
attribute: string attribute: string
operator: RuleOperatorType operator: RuleOperatorType
@@ -36,6 +20,35 @@ export interface UpdateShippingOptionsWorkflowInput {
}[] }[]
} }
export type UpdateShippingOptionPriceRecord =
| {
id?: string
currency_code?: string
amount?: number
rules?: PriceRule[]
}
| {
id?: string
region_id?: string
amount?: number
rules?: PriceRule[]
}
export type UpdateCalculatedShippingOptionInput =
UpdateFlatShippingOptionInputBase & {
price_type?: "calculated"
}
export type UpdateFlatRateShippingOptionInput =
UpdateFlatShippingOptionInputBase & {
price_type?: "flat"
prices?: UpdateShippingOptionPriceRecord[]
}
export type UpdateShippingOptionsWorkflowInput =
| UpdateFlatRateShippingOptionInput
| UpdateCalculatedShippingOptionInput
export type UpdateShippingOptionsWorkflowOutput = { export type UpdateShippingOptionsWorkflowOutput = {
id: string id: string
}[] }[]

View File

@@ -1,4 +1,8 @@
import { FulfillmentOption, IFulfillmentProvider } from "@medusajs/types" import {
CalculatedShippingOptionPrice,
FulfillmentOption,
IFulfillmentProvider,
} from "@medusajs/types"
/** /**
* ### constructor * ### constructor
@@ -221,7 +225,7 @@ export class AbstractFulfillmentProviderService
optionData: Record<string, unknown>, optionData: Record<string, unknown>,
data: Record<string, unknown>, data: Record<string, unknown>,
context: Record<string, unknown> context: Record<string, unknown>
): Promise<number> { ): Promise<CalculatedShippingOptionPrice> {
throw Error("calculatePrice must be overridden by the child class") throw Error("calculatePrice must be overridden by the child class")
} }
@@ -345,7 +349,9 @@ export class AbstractFulfillmentProviderService
* } * }
* } * }
*/ */
async createReturnFulfillment(fulfillment: Record<string, unknown>): Promise<any> { async createReturnFulfillment(
fulfillment: Record<string, unknown>
): Promise<any> {
throw Error("createReturn must be overridden by the child class") throw Error("createReturn must be overridden by the child class")
} }

View File

@@ -1970,6 +1970,34 @@ export default class FulfillmentModuleService
return !!shippingOptions.length return !!shippingOptions.length
} }
@InjectManager()
async validateShippingOptionsForPriceCalculation(
shippingOptionsData: FulfillmentTypes.CreateShippingOptionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<boolean[]> {
const nonCalculatedOptions = shippingOptionsData.filter(
(option) => option.price_type !== "calculated"
)
if (nonCalculatedOptions.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot calculate price for non-calculated shipping options: ${nonCalculatedOptions
.map((o) => o.name)
.join(", ")}`
)
}
const promises = shippingOptionsData.map((option) =>
this.fulfillmentProviderService_.canCalculate(
option.provider_id,
option as unknown as Record<string, unknown>
)
)
return await promiseAll(promises)
}
@InjectTransactionManager() @InjectTransactionManager()
// @ts-expect-error // @ts-expect-error
async deleteShippingProfiles( async deleteShippingProfiles(

View File

@@ -100,6 +100,21 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.MedusaIn
return await provider.validateOption(data) return await provider.validateOption(data)
} }
async canCalculate(providerId: string, data: Record<string, unknown>) {
const provider = this.retrieveProviderRegistration(providerId)
return await provider.canCalculate(data)
}
async calculatePrice(
providerId: string,
optionData: Record<string, unknown>,
data: Record<string, unknown>,
context: Record<string, unknown>
) {
const provider = this.retrieveProviderRegistration(providerId)
return await provider.calculatePrice(optionData, data, context)
}
async createFulfillment( async createFulfillment(
providerId: string, providerId: string,
data: object, data: object,

View File

@@ -1,5 +1,8 @@
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils" import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"
import { FulfillmentOption } from "@medusajs/types" import {
CalculatedShippingOptionPrice,
FulfillmentOption,
} from "@medusajs/types"
// TODO rework type and DTO's // TODO rework type and DTO's
@@ -30,6 +33,18 @@ export class ManualFulfillmentService extends AbstractFulfillmentProviderService
return data return data
} }
async calculatePrice(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
context: Record<string, unknown>
): Promise<CalculatedShippingOptionPrice> {
throw new Error("Manual fulfillment does not support price calculation")
}
async canCalculate(): Promise<boolean> {
return false
}
async validateOption(data: Record<string, any>): Promise<boolean> { async validateOption(data: Record<string, any>): Promise<boolean> {
return true return true
} }