feat(medusa): Support deleting prices from a price list by product or variant (#1555)

This commit is contained in:
Adrien de Peretti
2022-05-30 09:41:57 +02:00
committed by GitHub
parent ad9cfedf04
commit fa031fd28b
16 changed files with 602 additions and 78 deletions

View File

@@ -94,6 +94,24 @@ class AdminPriceListResource extends BaseResource {
const path = `/admin/price-lists/${id}/prices/batch`
return this.client.request("DELETE", path, payload, {}, customHeaders)
}
deleteProductPrices(
priceListId: string,
productId: string,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminPriceListDeleteBatchRes> {
const path = `/admin/price-lists/${priceListId}/products/${productId}/prices`
return this.client.request("DELETE", path, {}, {}, customHeaders)
}
deleteVariantPrices(
priceListId: string,
variantId: string,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminPriceListDeleteBatchRes> {
const path = `/admin/price-lists/${priceListId}/variants/${variantId}/prices`
return this.client.request("DELETE", path, {}, {}, customHeaders)
}
}
export default AdminPriceListResource

View File

@@ -253,6 +253,28 @@ export const adminHandlers = [
)
}),
rest.delete("/admin/price-lists/:id/products/:product_id/prices", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
ids: [],
object: "money-amount",
deleted: true,
})
)
}),
rest.delete("/admin/price-lists/:id/variants/:variant_id/prices", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
ids: [],
object: "money-amount",
deleted: true,
})
)
}),
rest.post("/admin/return-reasons/", (req, res, ctx) => {
const body = req.body as Record<string, any>
return res(

View File

@@ -6,12 +6,16 @@ import {
AdminDeletePriceListPricesPricesReq,
AdminPriceListDeleteRes,
AdminPriceListDeleteBatchRes,
AdminPriceListDeleteProductPricesRes,
AdminPriceListDeleteVariantPricesRes,
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { useMutation, UseMutationOptions, useQueryClient } from "react-query"
import { useMedusa } from "../../../contexts/medusa"
import { buildOptions } from "../../utils/buildOptions"
import { adminPriceListKeys } from "./queries"
import { adminProductKeys } from "../products"
import { adminVariantKeys } from "../variants"
export const useAdminCreatePriceList = (
options?: UseMutationOptions<
@@ -118,3 +122,53 @@ export const useAdminDeletePriceListPrices = (
)
)
}
export const useAdminDeletePriceListProductPrices = (
id: string,
productId: string,
options?: UseMutationOptions<
Response<AdminPriceListDeleteProductPricesRes>,
Error
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
() => client.admin.priceLists.deleteProductPrices(id, productId),
buildOptions(
queryClient,
[
adminPriceListKeys.detail(id),
adminPriceListKeys.lists(),
adminProductKeys.detail(productId)
],
options
)
)
}
export const useAdminDeletePriceListVariantPrices = (
id: string,
variantId: string,
options?: UseMutationOptions<
Response<AdminPriceListDeleteVariantPricesRes>,
Error
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
() => client.admin.priceLists.deleteVariantPrices(id, variantId),
buildOptions(
queryClient,
[
adminPriceListKeys.detail(id),
adminPriceListKeys.lists(),
adminVariantKeys.detail(variantId)
],
options
)
)
}

View File

@@ -21,7 +21,7 @@ export const adminPriceListKeys = {
"products" as const,
{ ...(query || {}) },
] as const
},
}
}
type PriceListQueryKeys = typeof adminPriceListKeys

View File

@@ -3,6 +3,8 @@ import {
useAdminCreatePriceListPrices,
useAdminDeletePriceList,
useAdminDeletePriceListPrices,
useAdminDeletePriceListProductPrices,
useAdminDeletePriceListVariantPrices,
useAdminUpdatePriceList,
} from "../../../../src"
import { renderHook } from "@testing-library/react-hooks"
@@ -137,3 +139,53 @@ describe("useAdminDeletePriceList hook", () => {
)
})
})
describe("useAdminDeletePriceListProductPrices hook", () => {
test("should delete prices from a price list for all the variants related to the specified product", async () => {
const { result, waitFor } = renderHook(
() => useAdminDeletePriceListProductPrices(
fixtures.get("price_list").id,
fixtures.get("product").id
),
{
wrapper: createWrapper(),
}
)
result.current.mutate()
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data).toEqual(expect.objectContaining({
ids: [],
object: "money-amount",
deleted: true
}))
})
})
describe("useAdminDeletePriceListVariantPrices hook", () => {
test("should delete prices from a price list for the specified variant", async () => {
const { result, waitFor } = renderHook(
() => useAdminDeletePriceListVariantPrices(
fixtures.get("price_list").id,
fixtures.get("product_variant").id
),
{
wrapper: createWrapper(),
}
)
result.current.mutate()
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data).toEqual(expect.objectContaining({
ids: [],
object: "money-amount",
deleted: true
}))
})
})

View File

@@ -0,0 +1,48 @@
import PriceListService from "../../../../services/price-list"
/**
* @oas [delete] /price-lists/{id}/products/{product_id}/prices
* operationId: "DeletePriceListsPriceListProductsProductPrices"
* summary: "Delete all the prices related to a specific product in a price list"
* description: "Delete all the prices related to a specific product in a price list"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List that the Money Amounts that will be deleted belongs to.
* - (path) product_id=* {string} The id of the product from which the money amount will be deleted.
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* ids:
* type: number
* description: The price ids that have been deleted.
* count:
* type: number
* description: The number of prices that have been deleted.
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id, product_id } = req.params
const priceListService: PriceListService =
req.scope.resolve("priceListService")
const [deletedPriceIds] = await priceListService.deleteProductPrices(id, [
product_id,
])
return res.json({
ids: deletedPriceIds,
object: "money-amount",
deleted: true,
})
}

View File

@@ -0,0 +1,48 @@
import PriceListService from "../../../../services/price-list"
/**
* @oas [delete] /price-lists/{id}/variants/{variant_id}/prices
* operationId: "DeletePriceListsPriceListVariantsVariantPrices"
* summary: "Delete all the prices related to a specific variant in a price list"
* description: "Delete all the prices related to a specific variant in a price list"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List that the Money Amounts that will be deleted belongs to.
* - (path) variant_id=* {string} The id of the variant from which the money amount will be deleted.
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* ids:
* type: number
* description: The price ids that have been deleted.
* count:
* type: number
* description: The number of prices that have been deleted.
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id, variant_id } = req.params
const priceListService: PriceListService =
req.scope.resolve("priceListService")
const [deletedPriceIds] = await priceListService.deleteVariantPrices(id, [
variant_id,
])
return res.json({
ids: deletedPriceIds,
object: "money-amount",
deleted: true,
})
}

View File

@@ -22,6 +22,15 @@ export default (app) => {
middlewares.wrap(require("./list-price-list-products").default)
)
route.delete(
"/:id/products/:product_id/prices",
middlewares.wrap(require("./delete-product-prices").default)
)
route.delete(
"/:id/variants/:variant_id/prices",
middlewares.wrap(require("./delete-variant-prices").default)
)
route.post("/", middlewares.wrap(require("./create-price-list").default))
route.post("/:id", middlewares.wrap(require("./update-price-list").default))
@@ -68,6 +77,9 @@ export type AdminPriceListDeleteBatchRes = {
object: string
}
export type AdminPriceListDeleteProductPricesRes = AdminPriceListDeleteBatchRes
export type AdminPriceListDeleteVariantPricesRes = AdminPriceListDeleteBatchRes
export type AdminPriceListDeleteRes = DeleteResponse
export type AdminPriceListsListRes = PaginatedResponse & {

View File

@@ -8,7 +8,7 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { Product } from "../../../../models/product"
import { Product } from "../../../../models"
import { DateComparisonOperator } from "../../../../types/common"
import { validator } from "../../../../utils/validator"
import { FilterableProductProps } from "../../../../types/product"
@@ -18,7 +18,6 @@ import {
defaultAdminProductFields,
defaultAdminProductRelations,
} from "../products"
import listAndCount from "../../../../controllers/products/admin-list-products"
import { MedusaError } from "medusa-core-utils"
import { getListConfig } from "../../../../utils/get-query-config"
import PriceListService from "../../../../services/price-list"

View File

@@ -49,7 +49,7 @@ export default async (req, res) => {
expandFields = validated.expand.split(",")
}
const listConfig: FindConfig<PriceList> = {
const listConfig: FindConfig<FilterablePriceListProps> = {
relations: expandFields,
skip: validated.offset,
take: validated.limit,
@@ -65,7 +65,7 @@ export default async (req, res) => {
}
}
const filterableFields = omit(validated, [
const filterableFields: FilterablePriceListProps = omit(validated, [
"limit",
"offset",
"expand",

View File

@@ -119,18 +119,24 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
public async findManyForVariantInPriceList(
variant_id: string,
price_list_id: string
price_list_id: string,
requiresPriceList = false
): Promise<[MoneyAmount[], number]> {
const qb = this.createQueryBuilder("ma")
.leftJoinAndSelect("ma.price_list", "price_list")
.where("ma.variant_id = :variant_id", { variant_id })
.andWhere(
new Brackets((qb) => {
qb.where("ma.price_list_id = :price_list_id", {
price_list_id,
}).orWhere("ma.price_list_id IS NULL")
})
)
const getAndWhere = (subQb) => {
const andWhere = subQb.where("ma.price_list_id = :price_list_id", {
price_list_id,
})
if (!requiresPriceList) {
andWhere.orWhere("ma.price_list_id IS NULL")
}
return andWhere
}
qb.andWhere(new Brackets(getAndWhere))
return await qb.getManyAndCount()
}

View File

@@ -8,16 +8,18 @@ import {
} from "typeorm"
import { PriceList } from "../models/price-list"
import { CustomFindOptions, ExtendedFindConfig } from "../types/common"
import { CustomerGroup } from "../models"
import { FilterablePriceListProps } from "../types/price-list"
type PriceListFindOptions = CustomFindOptions<PriceList, "status" | "type">
export type PriceListFindOptions = CustomFindOptions<PriceList, "status" | "type">
@EntityRepository(PriceList)
export class PriceListRepository extends Repository<PriceList> {
public async getFreeTextSearchResultsAndCount(
q: string,
options: PriceListFindOptions = { where: {} },
groups?: FindOperator<PriceList>,
relations: (keyof PriceList)[] = []
groups: FindOperator<string[]>,
relations: string[] = []
): Promise<[PriceList[], number]> {
options.where = options.where ?? {}
@@ -50,7 +52,7 @@ export class PriceListRepository extends Repository<PriceList> {
}
public async findWithRelations(
relations: (keyof PriceList)[] = [],
relations: string[] = [],
idsOrOptionsWithoutRelations:
| Omit<FindManyOptions<PriceList>, "relations">
| string[] = {}
@@ -97,8 +99,8 @@ export class PriceListRepository extends Repository<PriceList> {
}
async listAndCount(
query: ExtendedFindConfig<PriceList>,
groups?: FindOperator<PriceList>
query: ExtendedFindConfig<FilterablePriceListProps>,
groups: FindOperator<string[]>
): Promise<[PriceList[], number]> {
const qb = this.createQueryBuilder("price_list")
.where(query.where)

View File

@@ -1,13 +1,13 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { EntityManager, FindOperator } from "typeorm"
import { CustomerGroupService } from "."
import { Product } from "../models"
import { CustomerGroup } from "../models/customer-group"
import { PriceList } from "../models/price-list"
import { CustomerGroup, PriceList, Product, ProductVariant } from "../models"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { PriceListRepository } from "../repositories/price-list"
import { FindConfig } from "../types/common"
import {
PriceListFindOptions,
PriceListRepository,
} from "../repositories/price-list"
import { FindConfig, Selector } from "../types/common"
import {
CreatePriceListInput,
FilterablePriceListProps,
@@ -18,12 +18,18 @@ import {
import { formatException } from "../utils/exception-formatter"
import ProductService from "./product"
import RegionService from "./region"
import { TransactionBaseService } from "../interfaces"
import { buildQuery } from "../utils"
import { FilterableProductProps } from "../types/product"
import ProductVariantService from "./product-variant"
import { FilterableProductVariantProps } from "../types/product-variant"
type PriceListConstructorProps = {
manager: EntityManager
customerGroupService: CustomerGroupService
regionService: RegionService
productService: ProductService
productVariantService: ProductVariantService
priceListRepository: typeof PriceListRepository
moneyAmountRepository: typeof MoneyAmountRepository
}
@@ -32,50 +38,38 @@ type PriceListConstructorProps = {
* Provides layer to manipulate product tags.
* @extends BaseService
*/
class PriceListService extends BaseService {
private manager_: EntityManager
private customerGroupService_: CustomerGroupService
private regionService_: RegionService
private productService_: ProductService
private priceListRepo_: typeof PriceListRepository
private moneyAmountRepo_: typeof MoneyAmountRepository
class PriceListService extends TransactionBaseService<PriceListService> {
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
protected readonly customerGroupService_: CustomerGroupService
protected readonly regionService_: RegionService
protected readonly productService_: ProductService
protected readonly variantService_: ProductVariantService
protected readonly priceListRepo_: typeof PriceListRepository
protected readonly moneyAmountRepo_: typeof MoneyAmountRepository
constructor({
manager,
customerGroupService,
regionService,
productService,
productVariantService,
priceListRepository,
moneyAmountRepository,
}: PriceListConstructorProps) {
super()
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
this.customerGroupService_ = customerGroupService
this.productService_ = productService
this.variantService_ = productVariantService
this.regionService_ = regionService
this.priceListRepo_ = priceListRepository
this.moneyAmountRepo_ = moneyAmountRepository
}
withTransaction(transactionManager: EntityManager): PriceListService {
if (!transactionManager) {
return this
}
const cloned = new PriceListService({
manager: transactionManager,
customerGroupService: this.customerGroupService_,
productService: this.productService_,
regionService: this.regionService_,
priceListRepository: this.priceListRepo_,
moneyAmountRepository: this.moneyAmountRepo_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a product tag by id.
* @param {string} priceListId - the id of the product tag to retrieve
@@ -88,7 +82,7 @@ class PriceListService extends BaseService {
): Promise<PriceList> {
const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_)
const query = this.buildQuery_({ id: priceListId }, config)
const query = buildQuery({ id: priceListId }, config)
const priceList = await priceListRepo.findOne(query)
if (!priceList) {
@@ -167,11 +161,9 @@ class PriceListService extends BaseService {
await this.upsertCustomerGroups_(id, customer_groups)
}
const result = await this.retrieve(id, {
return await this.retrieve(id, {
relations: ["prices", "customer_groups"],
})
return result
})
}
@@ -195,11 +187,9 @@ class PriceListService extends BaseService {
const prices_ = await this.addCurrencyFromRegion(prices)
await moneyAmountRepo.addPriceListPrices(priceList.id, prices_, replace)
const result = await this.retrieve(priceList.id, {
return await this.retrieve(priceList.id, {
relations: ["prices"],
})
return result
})
}
@@ -216,8 +206,6 @@ class PriceListService extends BaseService {
const priceList = await this.retrieve(id, { select: ["id"] })
await moneyAmountRepo.deletePriceListPrices(priceList.id, priceIds)
return Promise.resolve()
})
}
@@ -249,14 +237,18 @@ class PriceListService extends BaseService {
*/
async list(
selector: FilterablePriceListProps = {},
config: FindConfig<PriceList> = { skip: 0, take: 20 }
config: FindConfig<FilterablePriceListProps> = { skip: 0, take: 20 }
): Promise<PriceList[]> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const query = this.buildQuery_(selector, config)
const { q, ...priceListSelector } = selector
const query = buildQuery<FilterablePriceListProps>(
priceListSelector,
config
)
const groups = query.where.customer_groups
const groups = query.where.customer_groups as FindOperator<string[]>
query.where.customer_groups = undefined
const [priceLists] = await priceListRepo.listAndCount(query, groups)
@@ -273,21 +265,26 @@ class PriceListService extends BaseService {
*/
async listAndCount(
selector: FilterablePriceListProps = {},
config: FindConfig<PriceList> = { skip: 0, take: 20 }
config: FindConfig<FilterablePriceListProps> = {
skip: 0,
take: 20,
}
): Promise<[PriceList[], number]> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const q = selector.q
const { relations, ...query } = this.buildQuery_(selector, config)
const { q, ...priceListSelector } = selector
const { relations, ...query } = buildQuery<FilterablePriceListProps>(
priceListSelector,
config
)
const groups = query.where.customer_groups
const groups = query.where.customer_groups as FindOperator<string[]>
delete query.where.customer_groups
if (q) {
delete query.where.q
return await priceListRepo.getFreeTextSearchResultsAndCount(
q,
query,
query as PriceListFindOptions,
groups,
relations
)
@@ -296,7 +293,7 @@ class PriceListService extends BaseService {
})
}
async upsertCustomerGroups_(
protected async upsertCustomerGroups_(
priceListId: string,
customerGroups: { id: string }[]
): Promise<void> {
@@ -317,12 +314,13 @@ class PriceListService extends BaseService {
async listProducts(
priceListId: string,
selector = {},
selector: FilterableProductProps | Selector<Product> = {},
config: FindConfig<Product> = {
relations: [],
skip: 0,
take: 20,
}
},
requiresPriceList = false
): Promise<[Product[], number]> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const [products, count] = await this.productService_.listAndCount(
@@ -340,7 +338,8 @@ class PriceListService extends BaseService {
const [prices] =
await moneyAmountRepo.findManyForVariantInPriceList(
v.id,
priceListId
priceListId,
requiresPriceList
)
return {
@@ -359,6 +358,109 @@ class PriceListService extends BaseService {
})
}
async listVariants(
priceListId: string,
selector: FilterableProductVariantProps = {},
config: FindConfig<ProductVariant> = {
relations: [],
skip: 0,
take: 20,
},
requiresPriceList = false
): Promise<[ProductVariant[], number]> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const [variants, count] = await this.variantService_.listAndCount(
selector,
config
)
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
const variantsWithPrices = await Promise.all(
variants.map(async (variant) => {
const [prices] = await moneyAmountRepo.findManyForVariantInPriceList(
variant.id,
priceListId,
requiresPriceList
)
variant.prices = prices
return variant
})
)
return [variantsWithPrices, count]
})
}
public async deleteProductPrices(
priceListId: string,
productIds: string[]
): Promise<[string[], number]> {
return await this.atomicPhase_(async () => {
const [products, count] = await this.listProducts(
priceListId,
{
id: productIds,
},
{
relations: ["variants"],
},
true
)
if (count === 0) {
return [[], count]
}
const priceIds = products
.map(({ variants }) =>
variants
.map((variant) => variant.prices.map((price) => price.id))
.flat()
)
.flat()
if (!priceIds.length) {
return [[], 0]
}
await this.deletePrices(priceListId, priceIds)
return [priceIds, priceIds.length]
})
}
public async deleteVariantPrices(
priceListId: string,
variantIds: string[]
): Promise<[string[], number]> {
return await this.atomicPhase_(async () => {
const [variants, count] = await this.listVariants(
priceListId,
{
id: variantIds,
},
{},
true
)
if (count === 0) {
return [[], count]
}
const priceIds = variants
.map((variant) => variant.prices.map((price) => price.id))
.flat()
if (!priceIds.length) {
return [[], 0]
}
await this.deletePrices(priceListId, priceIds)
return [priceIds, priceIds.length]
})
}
/**
* Add `currency_code` to an MA record if `region_id`is passed.
* @param prices - a list of PriceListPrice(Create/Update)Input records

View File

@@ -7,18 +7,24 @@ import {
IsString,
} from "class-validator"
import "reflect-metadata"
import { FindManyOptions, OrderByCondition } from "typeorm"
import { FindManyOptions, FindOperator, OrderByCondition } from "typeorm"
import { transformDate } from "../utils/validators/date-transform"
export type PartialPick<T, K extends keyof T> = {
[P in K]?: T[P]
}
export type Writable<T> = { -readonly [key in keyof T]: T[key] }
export type Writable<T> = {
-readonly [key in keyof T]:
| T[key]
| FindOperator<T[key][]>
| FindOperator<string[]>
}
export type ExtendedFindConfig<TEntity> = FindConfig<TEntity> & {
where: Partial<Writable<TEntity>>
withDeleted?: boolean
relations?: string[]
}
export type Selector<TEntity> = {
@@ -28,6 +34,7 @@ export type Selector<TEntity> = {
| DateComparisonOperator
| StringComparisonOperator
| NumericalComparisonOperator
| FindOperator<TEntity[key][] | string[]>
}
export type TotalField =