feat(inventory): check stock to create reservation (#7209)
This commit is contained in:
committed by
GitHub
parent
e97b0125b5
commit
c32a31e139
6
.changeset/shiny-pianos-hammer.md
Normal file
6
.changeset/shiny-pianos-hammer.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/inventory-next": patch
|
||||
"@medusajs/types": patch
|
||||
---
|
||||
|
||||
Check stock to create reservation and support backorder
|
||||
@@ -45,6 +45,10 @@ export interface CreateReservationItemInput {
|
||||
* The reserved quantity.
|
||||
*/
|
||||
quantity: number
|
||||
/**
|
||||
* Allow backorder of the item. If true, it won't check inventory levels before reserving it.
|
||||
*/
|
||||
allow_backorder?: boolean
|
||||
/**
|
||||
* The description of the reservation.
|
||||
*/
|
||||
|
||||
@@ -89,6 +89,41 @@ moduleIntegrationTestRunner({
|
||||
expect(inventoryLevel.reserved_quantity).toEqual(2)
|
||||
})
|
||||
|
||||
it("should check inventory levels before creating reservation", async () => {
|
||||
const reserveMoreThanInStock = service.createReservationItems({
|
||||
inventory_item_id: inventoryItem.id,
|
||||
location_id: "location-1",
|
||||
quantity: 3,
|
||||
})
|
||||
|
||||
expect(reserveMoreThanInStock).rejects.toThrow(
|
||||
`Not enough stock available for item ${inventoryItem.id} at location location-1`
|
||||
)
|
||||
|
||||
let inventoryLevel =
|
||||
await service.retrieveInventoryLevelByItemAndLocation(
|
||||
inventoryItem.id,
|
||||
"location-1"
|
||||
)
|
||||
|
||||
expect(inventoryLevel.reserved_quantity).toEqual(0)
|
||||
|
||||
await service.createReservationItems({
|
||||
inventory_item_id: inventoryItem.id,
|
||||
location_id: "location-1",
|
||||
allow_backorder: true,
|
||||
quantity: 3,
|
||||
})
|
||||
|
||||
inventoryLevel =
|
||||
await service.retrieveInventoryLevelByItemAndLocation(
|
||||
inventoryItem.id,
|
||||
"location-1"
|
||||
)
|
||||
|
||||
expect(inventoryLevel.reserved_quantity).toEqual(3)
|
||||
})
|
||||
|
||||
it("should create reservationItems from array", async () => {
|
||||
const data = [
|
||||
{
|
||||
@@ -99,6 +134,7 @@ moduleIntegrationTestRunner({
|
||||
{
|
||||
inventory_item_id: inventoryItem.id,
|
||||
location_id: "location-2",
|
||||
allow_backorder: true,
|
||||
quantity: 3,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,39 +1,76 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
import { Migration } from "@mikro-orm/migrations"
|
||||
|
||||
export class Migration20240307132720 extends Migration {
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.addSql('create table if not exists "inventory_item" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "sku" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "weight" int null, "length" int null, "height" int null, "width" int null, "requires_shipping" boolean not null default true, "description" text null, "title" text null, "thumbnail" text null, "metadata" jsonb null, constraint "inventory_item_pkey" primary key ("id"));');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_inventory_item_deleted_at" ON "inventory_item" (deleted_at) WHERE deleted_at IS NOT NULL;');
|
||||
this.addSql('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_inventory_item_sku_unique" ON "inventory_item" (sku);');
|
||||
this.addSql(
|
||||
'create table if not exists "inventory_item" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "sku" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "weight" int null, "length" int null, "height" int null, "width" int null, "requires_shipping" boolean not null default true, "description" text null, "title" text null, "thumbnail" text null, "metadata" jsonb null, constraint "inventory_item_pkey" primary key ("id"));'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_inventory_item_deleted_at" ON "inventory_item" (deleted_at) WHERE deleted_at IS NOT NULL;'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_inventory_item_sku_unique" ON "inventory_item" (sku);'
|
||||
)
|
||||
|
||||
this.addSql('create table if not exists "inventory_level" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "inventory_item_id" text not null, "location_id" text not null, "stocked_quantity" int not null default 0, "reserved_quantity" int not null default 0, "incoming_quantity" int not null default 0, "metadata" jsonb null, constraint "inventory_level_pkey" primary key ("id"));');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_inventory_level_deleted_at" ON "inventory_level" (deleted_at) WHERE deleted_at IS NOT NULL;');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_inventory_level_inventory_item_id" ON "inventory_level" (inventory_item_id);');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);');
|
||||
this.addSql(
|
||||
'create table if not exists "inventory_level" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "inventory_item_id" text not null, "location_id" text not null, "stocked_quantity" int not null default 0, "reserved_quantity" int not null default 0, "incoming_quantity" int not null default 0, "metadata" jsonb null, constraint "inventory_level_pkey" primary key ("id"));'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_inventory_level_deleted_at" ON "inventory_level" (deleted_at) WHERE deleted_at IS NOT NULL;'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_inventory_level_inventory_item_id" ON "inventory_level" (inventory_item_id);'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);'
|
||||
)
|
||||
|
||||
this.addSql('create table if not exists "reservation_item" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "line_item_id" text null, "location_id" text not null, "quantity" integer not null, "external_id" text null, "description" text null, "created_by" text null, "metadata" jsonb null, "inventory_item_id" text not null, constraint "reservation_item_pkey" primary key ("id"));');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_reservation_item_deleted_at" ON "reservation_item" (deleted_at) WHERE deleted_at IS NOT NULL;');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_reservation_item_line_item_id" ON "reservation_item" (line_item_id);');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_reservation_item_location_id" ON "reservation_item" (location_id);');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_reservation_item_inventory_item_id" ON "reservation_item" (inventory_item_id);');
|
||||
this.addSql(
|
||||
'create table if not exists "reservation_item" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "line_item_id" text null, "location_id" text not null, "quantity" integer not null, "external_id" text null, "description" text null, "created_by" text null, "metadata" jsonb null, "inventory_item_id" text not null, constraint "reservation_item_pkey" primary key ("id"));'
|
||||
)
|
||||
|
||||
this.addSql('alter table if exists "inventory_level" add constraint "inventory_level_inventory_item_id_foreign" foreign key ("inventory_item_id") references "inventory_item" ("id") on update cascade on delete cascade;');
|
||||
this.addSql(
|
||||
`ALTER TABLE "reservation_item" ADD COLUMN IF NOT EXISTS "allow_backorder" boolean DEFAULT false;`
|
||||
)
|
||||
|
||||
this.addSql('alter table if exists "reservation_item" add constraint "reservation_item_inventory_item_id_foreign" foreign key ("inventory_item_id") references "inventory_item" ("id") on update cascade on delete cascade;');
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_reservation_item_deleted_at" ON "reservation_item" (deleted_at) WHERE deleted_at IS NOT NULL;'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_reservation_item_line_item_id" ON "reservation_item" (line_item_id);'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_reservation_item_location_id" ON "reservation_item" (location_id);'
|
||||
)
|
||||
this.addSql(
|
||||
'CREATE INDEX IF NOT EXISTS "IDX_reservation_item_inventory_item_id" ON "reservation_item" (inventory_item_id);'
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
'alter table if exists "inventory_level" add constraint "inventory_level_inventory_item_id_foreign" foreign key ("inventory_item_id") references "inventory_item" ("id") on update cascade on delete cascade;'
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
'alter table if exists "reservation_item" add constraint "reservation_item_inventory_item_id_foreign" foreign key ("inventory_item_id") references "inventory_item" ("id") on update cascade on delete cascade;'
|
||||
)
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.addSql('alter table if exists "inventory_level" drop constraint if exists "inventory_level_inventory_item_id_foreign";');
|
||||
this.addSql(
|
||||
'alter table if exists "inventory_level" drop constraint if exists "inventory_level_inventory_item_id_foreign";'
|
||||
)
|
||||
|
||||
this.addSql('alter table if exists "reservation_item" drop constraint if exists "reservation_item_inventory_item_id_foreign";');
|
||||
this.addSql(
|
||||
'alter table if exists "reservation_item" drop constraint if exists "reservation_item_inventory_item_id_foreign";'
|
||||
)
|
||||
|
||||
this.addSql('drop table if exists "inventory_item" cascade;');
|
||||
this.addSql('drop table if exists "inventory_item" cascade;')
|
||||
|
||||
this.addSql('drop table if exists "inventory_level" cascade;');
|
||||
this.addSql('drop table if exists "inventory_level" cascade;')
|
||||
|
||||
this.addSql('drop table if exists "reservation_item" cascade;');
|
||||
this.addSql('drop table if exists "reservation_item" cascade;')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
Property,
|
||||
} from "@mikro-orm/core"
|
||||
|
||||
import { DALUtils } from "@medusajs/utils"
|
||||
import {
|
||||
DALUtils,
|
||||
createPsqlIndexStatementHelper,
|
||||
generateEntityId,
|
||||
} from "@medusajs/utils"
|
||||
import { InventoryItem } from "./inventory-item"
|
||||
import { createPsqlIndexStatementHelper } from "@medusajs/utils"
|
||||
import { generateEntityId } from "@medusajs/utils"
|
||||
|
||||
const ReservationItemDeletedAtIndex = createPsqlIndexStatementHelper({
|
||||
tableName: "reservation_item",
|
||||
@@ -62,6 +64,9 @@ export class ReservationItem {
|
||||
@Property({ type: "text", nullable: true })
|
||||
line_item_id: string | null = null
|
||||
|
||||
@Property({ type: "boolean" })
|
||||
allow_backorder: boolean = false
|
||||
|
||||
@ReservationItemLocationIdIndex.MikroORMIndex()
|
||||
@Property({ type: "text" })
|
||||
location_id: string
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
InventoryEvents,
|
||||
isDefined,
|
||||
MedusaContext,
|
||||
MedusaError,
|
||||
ModulesSdkUtils,
|
||||
isDefined,
|
||||
partitionArray,
|
||||
promiseAll,
|
||||
} from "@medusajs/utils"
|
||||
import { InventoryItem, InventoryLevel, ReservationItem } from "@models"
|
||||
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
||||
@@ -170,6 +171,27 @@ export default class InventoryModuleService<
|
||||
> {
|
||||
const toCreate = Array.isArray(input) ? input : [input]
|
||||
|
||||
const checkLevels = toCreate.map(async (item) => {
|
||||
if (!!item.allow_backorder) {
|
||||
return
|
||||
}
|
||||
|
||||
const available = await this.retrieveAvailableQuantity(
|
||||
item.inventory_item_id,
|
||||
[item.location_id],
|
||||
context
|
||||
)
|
||||
|
||||
if (available < item.quantity) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Not enough stock available for item ${item.inventory_item_id} at location ${item.location_id}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await promiseAll(checkLevels)
|
||||
|
||||
const created = await this.createReservationItems_(toCreate, context)
|
||||
|
||||
context.messageAggregator?.saveRawMessageData(
|
||||
|
||||
Reference in New Issue
Block a user