Feat/fulfill swaps and claims with locations (#3518)

* re-add if statements

* initial implementation

* add changeset

* address feedback

* remove context param from create-fulfillment

* fix tests
This commit is contained in:
Philip Korsholm
2023-03-20 13:48:25 +01:00
committed by GitHub
parent ea2633bccf
commit 026bdab05d
13 changed files with 149 additions and 33 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
Feat(medusa): fulfill swaps and claims with locations
@@ -207,7 +207,7 @@ describe("Inventory Items endpoints", () => {
) )
}) })
it.only("fails to update location level to negative quantity", async () => { it("fails to update location level to negative quantity", async () => {
const api = useApi() const api = useApi()
const inventoryItemId = inventoryItems[0].id const inventoryItemId = inventoryItems[0].id
@@ -172,10 +172,6 @@ const CreateFulfillmentModal: React.FC<CreateFulfillmentModalProps> = ({
no_notification: noNotis, no_notification: noNotis,
} as AdminPostOrdersOrderFulfillmentsReq } as AdminPostOrdersOrderFulfillmentsReq
if (isLocationFulfillmentEnabled) {
requestObj.location_id = locationSelectValue.value
}
requestObj.items = Object.entries(quantities) requestObj.items = Object.entries(quantities)
.filter(([, value]) => !!value) .filter(([, value]) => !!value)
.map(([key, value]) => ({ .map(([key, value]) => ({
@@ -185,6 +181,10 @@ const CreateFulfillmentModal: React.FC<CreateFulfillmentModalProps> = ({
break break
} }
if (isLocationFulfillmentEnabled) {
requestObj.location_id = locationSelectValue.value
}
action.mutate(requestObj, { action.mutate(requestObj, {
onSuccess: () => { onSuccess: () => {
notification("Success", successText, "success") notification("Success", successText, "success")
@@ -474,6 +474,10 @@ export default class InventoryService implements IInventoryService {
context context
) )
if (locationIds.length === 0) {
return 0
}
const availableQuantity = const availableQuantity =
await this.inventoryLevelService_.getAvailableQuantity( await this.inventoryLevelService_.getAvailableQuantity(
inventoryItemId, inventoryItemId,
@@ -506,6 +510,10 @@ export default class InventoryService implements IInventoryService {
context context
) )
if (locationIds.length === 0) {
return 0
}
const stockedQuantity = const stockedQuantity =
await this.inventoryLevelService_.getStockedQuantity( await this.inventoryLevelService_.getStockedQuantity(
inventoryItemId, inventoryItemId,
@@ -538,6 +546,10 @@ export default class InventoryService implements IInventoryService {
context context
) )
if (locationIds.length === 0) {
return 0
}
const reservedQuantity = const reservedQuantity =
await this.inventoryLevelService_.getReservedQuantity( await this.inventoryLevelService_.getReservedQuantity(
inventoryItemId, inventoryItemId,
@@ -153,7 +153,7 @@ export default async (req, res) => {
res.json({ order }) res.json({ order })
} }
const updateInventoryAndReservations = async ( export const updateInventoryAndReservations = async (
fulfillments: Fulfillment[], fulfillments: Fulfillment[],
context: { context: {
inventoryService: ProductVariantInventoryService inventoryService: ProductVariantInventoryService
@@ -1,8 +1,13 @@
import { ClaimService, OrderService } from "../../../../services" import {
import { IsBoolean, IsObject, IsOptional } from "class-validator" ClaimService,
OrderService,
ProductVariantInventoryService,
} from "../../../../services"
import { IsBoolean, IsObject, IsOptional, IsString } from "class-validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { FindParams } from "../../../../types/common" import { FindParams } from "../../../../types/common"
import { updateInventoryAndReservations } from "./create-fulfillment"
/** /**
* @oas [post] /admin/orders/{id}/claims/{claim_id}/fulfillments * @oas [post] /admin/orders/{id}/claims/{claim_id}/fulfillments
@@ -72,12 +77,51 @@ export default async (req, res) => {
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
const claimService: ClaimService = req.scope.resolve("claimService") const claimService: ClaimService = req.scope.resolve("claimService")
const entityManager: EntityManager = req.scope.resolve("manager") const entityManager: EntityManager = req.scope.resolve("manager")
const pvInventoryService: ProductVariantInventoryService = req.scope.resolve(
"productVariantInventoryService"
)
await entityManager.transaction(async (manager) => { await entityManager.transaction(async (manager) => {
await claimService.withTransaction(manager).createFulfillment(claim_id, { const claimServiceTx = claimService.withTransaction(manager)
const { fulfillments: existingFulfillments } =
await claimServiceTx.retrieve(claim_id, {
relations: [
"fulfillments",
"fulfillments.items",
"fulfillments.items.item",
],
})
const existingFulfillmentSet = new Set(
existingFulfillments.map((fulfillment) => fulfillment.id)
)
await claimServiceTx.createFulfillment(claim_id, {
metadata: validated.metadata, metadata: validated.metadata,
no_notification: validated.no_notification, no_notification: validated.no_notification,
location_id: validated.location_id,
}) })
if (validated.location_id) {
const { fulfillments } = await claimServiceTx.retrieve(claim_id, {
relations: [
"fulfillments",
"fulfillments.items",
"fulfillments.items.item",
],
})
const pvInventoryServiceTx = pvInventoryService.withTransaction(manager)
await updateInventoryAndReservations(
fulfillments.filter((f) => !existingFulfillmentSet.has(f.id)),
{
inventoryService: pvInventoryServiceTx,
locationId: validated.location_id,
}
)
}
}) })
const order = await orderService.retrieveWithTotals(id, req.retrieveConfig, { const order = await orderService.retrieveWithTotals(id, req.retrieveConfig, {
@@ -106,6 +150,10 @@ export class AdminPostOrdersOrderClaimsClaimFulfillmentsReq {
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
no_notification?: boolean no_notification?: boolean
@IsString()
@IsOptional()
location_id?: string
} }
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
@@ -1,9 +1,14 @@
import { IsBoolean, IsObject, IsOptional } from "class-validator" import { IsBoolean, IsObject, IsOptional, IsString } from "class-validator"
import { OrderService, SwapService } from "../../../../services" import {
OrderService,
ProductVariantInventoryService,
SwapService,
} from "../../../../services"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { FindParams } from "../../../../types/common" import { FindParams } from "../../../../types/common"
import { updateInventoryAndReservations } from "./create-fulfillment"
/** /**
* @oas [post] /admin/orders/{id}/swaps/{swap_id}/fulfillments * @oas [post] /admin/orders/{id}/swaps/{swap_id}/fulfillments
@@ -76,12 +81,53 @@ export default async (req, res) => {
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
const swapService: SwapService = req.scope.resolve("swapService") const swapService: SwapService = req.scope.resolve("swapService")
const entityManager: EntityManager = req.scope.resolve("manager") const entityManager: EntityManager = req.scope.resolve("manager")
const pvInventoryService: ProductVariantInventoryService = req.scope.resolve(
"productVariantInventoryService"
)
await entityManager.transaction(async (manager) => { await entityManager.transaction(async (manager) => {
await swapService.withTransaction(manager).createFulfillment(swap_id, { const swapServiceTx = swapService.withTransaction(manager)
const { fulfillments: existingFulfillments } = await swapServiceTx.retrieve(
swap_id,
{
relations: [
"fulfillments",
"fulfillments.items",
"fulfillments.items.item",
],
}
)
const existingFulfillmentSet = new Set(
existingFulfillments.map((fulfillment) => fulfillment.id)
)
await swapServiceTx.createFulfillment(swap_id, {
metadata: validated.metadata, metadata: validated.metadata,
no_notification: validated.no_notification, no_notification: validated.no_notification,
location_id: validated.location_id,
}) })
if (validated.location_id) {
const { fulfillments } = await swapServiceTx.retrieve(swap_id, {
relations: [
"fulfillments",
"fulfillments.items",
"fulfillments.items.item",
],
})
const pvInventoryServiceTx = pvInventoryService.withTransaction(manager)
await updateInventoryAndReservations(
fulfillments.filter((f) => !existingFulfillmentSet.has(f.id)),
{
inventoryService: pvInventoryServiceTx,
locationId: validated.location_id,
}
)
}
}) })
const order = await orderService.retrieveWithTotals(id, req.retrieveConfig, { const order = await orderService.retrieveWithTotals(id, req.retrieveConfig, {
@@ -110,6 +156,10 @@ export class AdminPostOrdersOrderSwapsSwapFulfillmentsReq {
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
no_notification?: boolean no_notification?: boolean
@IsString()
@IsOptional()
location_id?: string
} }
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
+15 -11
View File
@@ -510,9 +510,12 @@ describe("OrderService", () => {
it("calls order model functions", async () => { it("calls order model functions", async () => {
await orderService.retrieve(IdMap.getId("test-order")) await orderService.retrieve(IdMap.getId("test-order"))
expect(orderRepo.findOneWithRelations).toHaveBeenCalledTimes(1) expect(orderRepo.findOneWithRelations).toHaveBeenCalledTimes(1)
expect(orderRepo.findOneWithRelations).toHaveBeenCalledWith({}, { expect(orderRepo.findOneWithRelations).toHaveBeenCalledWith(
where: { id: IdMap.getId("test-order") }, {},
}) {
where: { id: IdMap.getId("test-order") },
}
)
}) })
}) })
@@ -934,8 +937,7 @@ describe("OrderService", () => {
quantity: 2, quantity: 2,
}, },
], ],
{ metadata: {}, order_id: "test-order" }, { metadata: {}, order_id: "test-order", location_id: undefined }
{ location_id: undefined }
) )
expect(lineItemService.update).toHaveBeenCalledTimes(1) expect(lineItemService.update).toHaveBeenCalledTimes(1)
@@ -967,8 +969,7 @@ describe("OrderService", () => {
quantity: 2, quantity: 2,
}, },
], ],
{ metadata: {}, order_id: "partial" }, { metadata: {}, order_id: "partial", location_id: undefined }
{ location_id: undefined }
) )
expect(lineItemService.update).toHaveBeenCalledTimes(1) expect(lineItemService.update).toHaveBeenCalledTimes(1)
@@ -1000,8 +1001,7 @@ describe("OrderService", () => {
quantity: 1, quantity: 1,
}, },
], ],
{ metadata: {}, order_id: "test" }, { metadata: {}, order_id: "test", location_id: undefined }
{ location_id: undefined }
) )
expect(lineItemService.update).toHaveBeenCalledTimes(1) expect(lineItemService.update).toHaveBeenCalledTimes(1)
@@ -1039,8 +1039,12 @@ describe("OrderService", () => {
quantity: 1, quantity: 1,
}, },
], ],
{ metadata: {}, order_id: "test", no_notification: undefined }, {
{ locationId: "loc_1" } metadata: {},
order_id: "test",
no_notification: undefined,
location_id: "loc_1",
}
) )
}) })
+2 -1
View File
@@ -512,6 +512,7 @@ export default class ClaimService extends TransactionBaseService {
config: { config: {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
no_notification?: boolean no_notification?: boolean
location_id?: string
} = { } = {
metadata: {}, metadata: {},
} }
@@ -595,7 +596,7 @@ export default class ClaimService extends TransactionBaseService {
item_id: i.id, item_id: i.id,
quantity: i.quantity, quantity: i.quantity,
})), })),
{ claim_order_id: id, metadata } { claim_order_id: id, metadata, location_id: config.location_id }
) )
let successfullyFulfilledItems: FulfillmentItem[] = [] let successfullyFulfilledItems: FulfillmentItem[] = []
+1 -4
View File
@@ -205,10 +205,8 @@ class FulfillmentService extends TransactionBaseService {
async createFulfillment( async createFulfillment(
order: CreateFulfillmentOrder, order: CreateFulfillmentOrder,
itemsToFulfill: FulFillmentItemType[], itemsToFulfill: FulFillmentItemType[],
custom: Partial<Fulfillment> = {}, custom: Partial<Fulfillment> = {}
context: { locationId?: string } = {}
): Promise<Fulfillment[]> { ): Promise<Fulfillment[]> {
const { locationId } = context
return await this.atomicPhase_(async (manager) => { return await this.atomicPhase_(async (manager) => {
const fulfillmentRepository = manager.withRepository( const fulfillmentRepository = manager.withRepository(
this.fulfillmentRepository_ this.fulfillmentRepository_
@@ -231,7 +229,6 @@ class FulfillmentService extends TransactionBaseService {
provider_id: shipping_method.shipping_option.provider_id, provider_id: shipping_method.shipping_option.provider_id,
items: items.map((i) => ({ item_id: i.id, quantity: i.quantity })), items: items.map((i) => ({ item_id: i.id, quantity: i.quantity })),
data: {}, data: {},
location_id: locationId,
}) })
const result = await fulfillmentRepository.save(ful) const result = await fulfillmentRepository.save(ful)
+1 -3
View File
@@ -1410,9 +1410,7 @@ class OrderService extends TransactionBaseService {
metadata: metadata ?? {}, metadata: metadata ?? {},
no_notification: no_notification, no_notification: no_notification,
order_id: orderId, order_id: orderId,
}, location_id: location_id,
{
locationId: location_id,
} }
) )
let successfullyFulfilled: FulfillmentItem[] = [] let successfullyFulfilled: FulfillmentItem[] = []
+2 -2
View File
@@ -791,7 +791,7 @@ class SwapService extends TransactionBaseService {
// Is the cascade insert really used? Also, is it really necessary to pass the entire entities when creating or updating? // Is the cascade insert really used? Also, is it really necessary to pass the entire entities when creating or updating?
// We normally should only pass what is needed? // We normally should only pass what is needed?
swap.shipping_methods = cart.shipping_methods.map((method) => { swap.shipping_methods = cart.shipping_methods.map((method) => {
;(method.tax_lines as any) = undefined (method.tax_lines as any) = undefined
return method return method
}) })
swap.confirmed_at = new Date() swap.confirmed_at = new Date()
@@ -975,7 +975,7 @@ class SwapService extends TransactionBaseService {
item_id: i.id, item_id: i.id,
quantity: i.quantity, quantity: i.quantity,
})), })),
{ swap_id: swapId, metadata } { swap_id: swapId, metadata, location_id: config.location_id }
) )
let successfullyFulfilled: FulfillmentItem[] = [] let successfullyFulfilled: FulfillmentItem[] = []
+1
View File
@@ -21,6 +21,7 @@ export type FulfillmentItemPartition = {
export type CreateShipmentConfig = { export type CreateShipmentConfig = {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
no_notification?: boolean no_notification?: boolean
location_id?: string
} }
export type CreateFulfillmentOrder = Omit<ClaimOrder, "beforeInsert"> & { export type CreateFulfillmentOrder = Omit<ClaimOrder, "beforeInsert"> & {