From c32a31e1394609845a5f202b1684d70fea019dc9 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Sun, 5 May 2024 05:24:52 -0300 Subject: [PATCH] feat(inventory): check stock to create reservation (#7209) --- .changeset/shiny-pianos-hammer.md | 6 ++ .../inventory/mutations/reservation-item.ts | 4 + .../inventory-module-service.spec.ts | 36 ++++++++ .../src/migrations/Migration20240307132720.ts | 83 ++++++++++++++----- .../src/models/reservation-item.ts | 11 ++- .../inventory-next/src/services/inventory.ts | 24 +++++- 6 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 .changeset/shiny-pianos-hammer.md diff --git a/.changeset/shiny-pianos-hammer.md b/.changeset/shiny-pianos-hammer.md new file mode 100644 index 0000000000..e3c182f339 --- /dev/null +++ b/.changeset/shiny-pianos-hammer.md @@ -0,0 +1,6 @@ +--- +"@medusajs/inventory-next": patch +"@medusajs/types": patch +--- + +Check stock to create reservation and support backorder diff --git a/packages/core/types/src/inventory/mutations/reservation-item.ts b/packages/core/types/src/inventory/mutations/reservation-item.ts index e5a712fadf..85be2fedcd 100644 --- a/packages/core/types/src/inventory/mutations/reservation-item.ts +++ b/packages/core/types/src/inventory/mutations/reservation-item.ts @@ -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. */ diff --git a/packages/modules/inventory-next/integration-tests/__tests__/inventory-module-service.spec.ts b/packages/modules/inventory-next/integration-tests/__tests__/inventory-module-service.spec.ts index fa3bc5e696..974d3a74a9 100644 --- a/packages/modules/inventory-next/integration-tests/__tests__/inventory-module-service.spec.ts +++ b/packages/modules/inventory-next/integration-tests/__tests__/inventory-module-service.spec.ts @@ -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, }, ] diff --git a/packages/modules/inventory-next/src/migrations/Migration20240307132720.ts b/packages/modules/inventory-next/src/migrations/Migration20240307132720.ts index 35f4c68550..3779cdb793 100644 --- a/packages/modules/inventory-next/src/migrations/Migration20240307132720.ts +++ b/packages/modules/inventory-next/src/migrations/Migration20240307132720.ts @@ -1,39 +1,76 @@ -import { Migration } from '@mikro-orm/migrations'; +import { Migration } from "@mikro-orm/migrations" export class Migration20240307132720 extends Migration { - async up(): Promise { - 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 { - 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;') } - } diff --git a/packages/modules/inventory-next/src/models/reservation-item.ts b/packages/modules/inventory-next/src/models/reservation-item.ts index 8964b13426..e715cfc7e3 100644 --- a/packages/modules/inventory-next/src/models/reservation-item.ts +++ b/packages/modules/inventory-next/src/models/reservation-item.ts @@ -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 diff --git a/packages/modules/inventory-next/src/services/inventory.ts b/packages/modules/inventory-next/src/services/inventory.ts index 0a5ca491d8..c3bb6e2855 100644 --- a/packages/modules/inventory-next/src/services/inventory.ts +++ b/packages/modules/inventory-next/src/services/inventory.ts @@ -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(