Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix(inventory, stock-location): Remove orphaned location levels and reservations #3460

Merged
6 changes: 6 additions & 0 deletions .changeset/many-papayas-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@medusajs/inventory": patch
"@medusajs/medusa": patch
---

Fix(inventory, medusa): ensure no orphaned reservations and invenotry levels on location removal
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,71 @@ describe("Inventory Items endpoints", () => {
})
})

it("When deleting an inventory item it removes associated levels and reservations", async () => {
const api = useApi()
const inventoryService = appContainer.resolve("inventoryService")

const invItem2 = await inventoryService.createInventoryItem({
sku: "1234567",
})

const stockRes = await api.post(
`/admin/stock-locations`,
{
name: "Fake Warehouse 1",
},
adminHeaders
)

locationId = stockRes.data.stock_location.id

const level = await inventoryService.createInventoryLevel({
inventory_item_id: invItem2.id,
location_id: locationId,
stocked_quantity: 10,
})

const reservation = await inventoryService.createReservationItem({
inventory_item_id: invItem2.id,
location_id: locationId,
quantity: 5,
})

const [, reservationCount] = await inventoryService.listReservationItems({
location_id: locationId,
})

expect(reservationCount).toEqual(1)

const [, inventoryLevelCount] =
await inventoryService.listInventoryLevels({
location_id: locationId,
})

expect(inventoryLevelCount).toEqual(1)

const res = await api.delete(
`/admin/stock-locations/${locationId}`,
adminHeaders
)

expect(res.status).toEqual(200)

const [, reservationCountPostDelete] =
await inventoryService.listReservationItems({
location_id: locationId,
})

expect(reservationCountPostDelete).toEqual(0)

const [, inventoryLevelCountPostDelete] =
await inventoryService.listInventoryLevels({
location_id: locationId,
})

expect(inventoryLevelCountPostDelete).toEqual(0)
})

it("When deleting an inventory item it removes the product variants associated to it", async () => {
const api = useApi()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const LocationCard: React.FC<Props> = ({ location }) => {
const onDelete = async () => {
const shouldDelete = await dialog({
heading: "Delete Location",
text: "Are you sure you want to delete this location",
text: "Are you sure you want to delete this location. This will also delete all inventory levels and reservations associated with this location.",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: great UX addition!

extraConfirmation: true,
entityName: location.name,
})
Expand Down
18 changes: 18 additions & 0 deletions packages/inventory/src/services/inventory-level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,24 @@ export default class InventoryLevelService extends TransactionBaseService {
})
}

/**
* Deletes inventory levels by location ID.
* @param locationId - The ID of the location to delete inventory levels for.
*/
async deleteByLocationId(locationId: string): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const levelRepository = manager.getRepository(InventoryLevel)

await levelRepository.delete({ location_id: locationId })

await this.eventBusService_
.withTransaction(manager)
.emit(InventoryLevelService.Events.DELETED, {
location_id: locationId,
})
})
}

/**
* Gets the total stocked quantity for a specific inventory item at multiple locations.
* @param inventoryItemId - The ID of the inventory item.
Expand Down
12 changes: 12 additions & 0 deletions packages/inventory/src/services/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ export default class InventoryService
.delete(inventoryItemId)
}

async deleteInventoryItemLevelByLocationId(locationId: string): Promise<void> {
return await this.inventoryLevelService_
.withTransaction(this.activeManager_)
.deleteByLocationId(locationId)
}

async deleteReservationItemByLocationId(locationId: string): Promise<void> {
return await this.reservationItemService_
.withTransaction(this.activeManager_)
.deleteByLocationId(locationId)
}

/**
* Deletes an inventory level
* @param inventoryItemId - the id of the inventory item associated with the level
Expand Down
38 changes: 31 additions & 7 deletions packages/inventory/src/services/reservation-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export default class ReservationItemService extends TransactionBaseService {
CREATED: "reservation-item.created",
UPDATED: "reservation-item.updated",
DELETED: "reservation-item.deleted",
DELETED_BY_LINE_ITEM: "reservation-item.deleted-by-line-item",
}

protected readonly eventBusService_: IEventBusService
Expand Down Expand Up @@ -95,7 +94,10 @@ export default class ReservationItemService extends TransactionBaseService {
const manager = this.activeManager_
const reservationItemRepository = manager.getRepository(ReservationItem)

const query = buildQuery({ id: reservationItemId }, config) as FindManyOptions
const query = buildQuery(
{ id: reservationItemId },
config
) as FindManyOptions
const [reservationItem] = await reservationItemRepository.find(query)

if (!reservationItem) {
Expand Down Expand Up @@ -165,8 +167,7 @@ export default class ReservationItemService extends TransactionBaseService {
isDefined(data.quantity) && data.quantity !== item.quantity

const shouldUpdateLocation =
isDefined(data.location_id) &&
data.location_id !== item.location_id
isDefined(data.location_id) && data.location_id !== item.location_id

const ops: Promise<unknown>[] = []

Expand Down Expand Up @@ -243,12 +244,35 @@ export default class ReservationItemService extends TransactionBaseService {

await this.eventBusService_
.withTransaction(manager)
.emit(ReservationItemService.Events.DELETED_BY_LINE_ITEM, {
.emit(ReservationItemService.Events.DELETED, {
line_item_id: lineItemId,
})
})
}

/**
* Deletes reservation items by location ID.
* @param locationId - The ID of the location to delete reservations for.
*/
async deleteByLocationId(locationId: string): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const itemRepository = manager.getRepository(ReservationItem)

await itemRepository
.createQueryBuilder("reservation_item")
.softDelete()
.where("location_id = :locationId", { locationId })
.andWhere("deleted_at IS NULL")
.execute()

await this.eventBusService_
.withTransaction(manager)
.emit(ReservationItemService.Events.DELETED, {
location_id: locationId,
})
})
}

/**
* Deletes a reservation item by id.
* @param reservationItemId - the id of the reservation item to delete.
Expand All @@ -272,8 +296,8 @@ export default class ReservationItemService extends TransactionBaseService {
await this.eventBusService_
.withTransaction(manager)
.emit(ReservationItemService.Events.DELETED, {
id: reservationItemId,
})
id: reservationItemId,
})
})
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { EntityManager } from "typeorm"
import { IStockLocationService } from "../../../../interfaces"
import {
IInventoryService,
IStockLocationService,
} from "../../../../interfaces"
import { SalesChannelLocationService } from "../../../../services"

/**
Expand Down Expand Up @@ -60,6 +63,9 @@ export default async (req, res) => {
"stockLocationService"
)

const inventoryService: IInventoryService =
req.scope.resolve("inventoryService")

const salesChannelLocationService: SalesChannelLocationService =
req.scope.resolve("salesChannelLocationService")

Expand All @@ -70,6 +76,17 @@ export default async (req, res) => {
.removeLocation(id)

await stockLocationService.withTransaction(transactionManager).delete(id)

if (inventoryService) {
await Promise.all([
inventoryService
.withTransaction(transactionManager)
.deleteInventoryItemLevelByLocationId(id),
inventoryService
.withTransaction(transactionManager)
.deleteReservationItemByLocationId(id),
])
}
})

res.status(200).send({
Expand Down
4 changes: 4 additions & 0 deletions packages/medusa/src/interfaces/services/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export interface IInventoryService {

deleteInventoryItem(inventoryItemId: string): Promise<void>

deleteInventoryItemLevelByLocationId(locationId: string): Promise<void>

deleteReservationItemByLocationId(locationId: string): Promise<void>

deleteInventoryLevel(
inventoryLevelId: string,
locationId: string
Expand Down