feat(medusa, inventory): Inventory Management module (#2956)
* feat: inventory module
This commit is contained in:
committed by
GitHub
parent
a2df11fc10
commit
93ee248493
6
packages/inventory/.gitignore
vendored
Normal file
6
packages/inventory/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/dist
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
.env
|
||||
*.sql
|
||||
10
packages/inventory/.npmignore
Normal file
10
packages/inventory/.npmignore
Normal file
@@ -0,0 +1,10 @@
|
||||
src
|
||||
.turbo
|
||||
.prettierrc
|
||||
.env
|
||||
.babelrc.js
|
||||
.eslintrc
|
||||
.gitignore
|
||||
ormconfig.json
|
||||
tsconfig.json
|
||||
jest.config.md
|
||||
13
packages/inventory/jest.config.js
Normal file
13
packages/inventory/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
globals: {
|
||||
"ts-jest": {
|
||||
tsConfig: "tsconfig.json",
|
||||
isolatedModules: false,
|
||||
},
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.[jt]s?$": "ts-jest",
|
||||
},
|
||||
testEnvironment: `node`,
|
||||
moduleFileExtensions: [`js`, `ts`],
|
||||
}
|
||||
37
packages/inventory/package.json
Normal file
37
packages/inventory/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@medusajs/inventory",
|
||||
"version": "1.0.0",
|
||||
"description": "Inventory Module for Medusa",
|
||||
"main": "dist/index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/inventory"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@medusajs/medusa": "*",
|
||||
"cross-env": "^5.2.1",
|
||||
"jest": "^25.5.2",
|
||||
"ts-jest": "^25.5.1",
|
||||
"typescript": "^4.4.4"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "tsc --build --watch",
|
||||
"prepare": "cross-env NODE_ENV=production yarn run build",
|
||||
"build": "tsc --build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"test:unit": "jest --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/medusa": "1.x.x",
|
||||
"medusa-interfaces": "1.x.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"typeorm": "^0.2.31"
|
||||
}
|
||||
}
|
||||
1
packages/inventory/src/config.ts
Normal file
1
packages/inventory/src/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CONNECTION_NAME = "inventory_connection"
|
||||
7
packages/inventory/src/index.js
Normal file
7
packages/inventory/src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import ConnectionLoader from "./loaders/connection"
|
||||
import InventoryService from "./services/inventory"
|
||||
import * as SchemaMigration from "./migrations/schema-migrations/1665748086258-inventory_setup"
|
||||
|
||||
export const service = InventoryService
|
||||
export const migrations = [SchemaMigration]
|
||||
export const loaders = [ConnectionLoader]
|
||||
22
packages/inventory/src/loaders/connection.ts
Normal file
22
packages/inventory/src/loaders/connection.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ConfigModule } from "@medusajs/medusa"
|
||||
import { ConnectionOptions, createConnection } from "typeorm"
|
||||
import { CONNECTION_NAME } from "../config"
|
||||
|
||||
import { ReservationItem, InventoryItem, InventoryLevel } from "../models"
|
||||
|
||||
export default async ({
|
||||
configModule,
|
||||
}: {
|
||||
configModule: ConfigModule
|
||||
}): Promise<void> => {
|
||||
await createConnection({
|
||||
name: CONNECTION_NAME,
|
||||
type: configModule.projectConfig.database_type,
|
||||
url: configModule.projectConfig.database_url,
|
||||
database: configModule.projectConfig.database_database,
|
||||
schema: configModule.projectConfig.database_schema,
|
||||
extra: configModule.projectConfig.database_extra || {},
|
||||
entities: [ReservationItem, InventoryLevel, InventoryItem],
|
||||
logging: configModule.projectConfig.database_logging || false,
|
||||
} as ConnectionOptions)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ConfigModule } from "@medusajs/medusa"
|
||||
import {
|
||||
createConnection,
|
||||
ConnectionOptions,
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
} from "typeorm"
|
||||
|
||||
import { CONNECTION_NAME } from "../../config"
|
||||
|
||||
export const up = async ({ configModule }: { configModule: ConfigModule }) => {
|
||||
const connection = await createConnection({
|
||||
name: CONNECTION_NAME,
|
||||
type: configModule.projectConfig.database_type,
|
||||
url: configModule.projectConfig.database_url,
|
||||
extra: configModule.projectConfig.database_extra || {},
|
||||
schema: configModule.projectConfig.database_schema,
|
||||
migrations: [inventorySetup1665748086258],
|
||||
logging: true,
|
||||
} as ConnectionOptions)
|
||||
|
||||
await connection.runMigrations()
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
export const down = async ({
|
||||
configModule,
|
||||
}: {
|
||||
configModule: ConfigModule
|
||||
}) => {
|
||||
const connection = await createConnection({
|
||||
name: CONNECTION_NAME,
|
||||
type: configModule.projectConfig.database_type,
|
||||
url: configModule.projectConfig.database_url,
|
||||
extra: configModule.projectConfig.database_extra || {},
|
||||
schema: configModule.projectConfig.database_schema,
|
||||
migrations: [inventorySetup1665748086258],
|
||||
logging: true,
|
||||
} as ConnectionOptions)
|
||||
|
||||
await connection.undoLastMigration({ transaction: "all" })
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
export class inventorySetup1665748086258 implements MigrationInterface {
|
||||
name = "inventorySetup1665748086258"
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "inventory_item" (
|
||||
"id" character varying NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"deleted_at" TIMESTAMP WITH TIME ZONE,
|
||||
"sku" text,
|
||||
"origin_country" text,
|
||||
"hs_code" text,
|
||||
"mid_code" text,
|
||||
"material" text,
|
||||
"weight" integer,
|
||||
"length" integer,
|
||||
"height" integer,
|
||||
"width" integer,
|
||||
"requires_shipping" boolean NOT NULL DEFAULT true,
|
||||
"metadata" jsonb,
|
||||
CONSTRAINT "PK_inventory_item_id" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "IDX_inventory_item_sku" ON "inventory_item" ("sku");
|
||||
|
||||
|
||||
CREATE TABLE "reservation_item" (
|
||||
"id" character varying NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"deleted_at" TIMESTAMP WITH TIME ZONE,
|
||||
"line_item_id" text,
|
||||
"inventory_item_id" text NOT NULL,
|
||||
"location_id" text NOT NULL,
|
||||
"quantity" integer NOT NULL,
|
||||
"metadata" jsonb,
|
||||
CONSTRAINT "PK_reservation_item_id" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_reservation_item_line_item_id" ON "reservation_item" ("line_item_id") WHERE deleted_at IS NULL;
|
||||
CREATE INDEX "IDX_reservation_item_inventory_item_id" ON "reservation_item" ("inventory_item_id") WHERE deleted_at IS NULL;
|
||||
CREATE INDEX "IDX_reservation_item_location_id" ON "reservation_item" ("location_id") WHERE deleted_at IS NULL;
|
||||
|
||||
|
||||
CREATE TABLE "inventory_level" (
|
||||
"id" character varying NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"deleted_at" TIMESTAMP WITH TIME ZONE,
|
||||
"inventory_item_id" text NOT NULL,
|
||||
"location_id" text NOT NULL,
|
||||
"stocked_quantity" integer NOT NULL DEFAULT 0,
|
||||
"reserved_quantity" integer NOT NULL DEFAULT 0,
|
||||
"incoming_quantity" integer NOT NULL DEFAULT 0,
|
||||
"metadata" jsonb,
|
||||
CONSTRAINT "PK_inventory_level_id" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "UQ_inventory_level_inventory_item_id_location_id" ON "inventory_level" ("inventory_item_id", "location_id");
|
||||
CREATE INDEX "IDX_inventory_level_inventory_item_id" ON "inventory_level" ("inventory_item_id");
|
||||
CREATE INDEX "IDX_inventory_level_location_id" ON "inventory_level" ("location_id");
|
||||
`)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX "IDX_inventory_level_location_id";
|
||||
DROP INDEX "IDX_inventory_level_inventory_item_id";
|
||||
DROP INDEX "UQ_inventory_level_inventory_item_id_location_id";
|
||||
DROP TABLE "inventory_level";
|
||||
|
||||
DROP INDEX "IDX_reservation_item_location_id";
|
||||
DROP INDEX "IDX_reservation_item_inventory_item_id";
|
||||
DROP INDEX "IDX_reservation_item_line_item_id";
|
||||
DROP TABLE "reservation_item";
|
||||
|
||||
DROP INDEX "IDX_inventory_item_sku";
|
||||
DROP TABLE "inventory_item";
|
||||
`)
|
||||
}
|
||||
}
|
||||
3
packages/inventory/src/models/index.ts
Normal file
3
packages/inventory/src/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./reservation-item"
|
||||
export * from "./inventory-item"
|
||||
export * from "./inventory-level"
|
||||
44
packages/inventory/src/models/inventory-item.ts
Normal file
44
packages/inventory/src/models/inventory-item.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Index, BeforeInsert, Column, Entity } from "typeorm"
|
||||
import { SoftDeletableEntity, generateEntityId } from "@medusajs/medusa"
|
||||
|
||||
@Entity()
|
||||
export class InventoryItem extends SoftDeletableEntity {
|
||||
@Index({ unique: true })
|
||||
@Column({ type: "text", nullable: true })
|
||||
sku: string | null
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
origin_country: string | null
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
hs_code: string | null
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
mid_code: string | null
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
material: string | null
|
||||
|
||||
@Column({ type: "int", nullable: true })
|
||||
weight: number | null
|
||||
|
||||
@Column({ type: "int", nullable: true })
|
||||
length: number | null
|
||||
|
||||
@Column({ type: "int", nullable: true })
|
||||
height: number | null
|
||||
|
||||
@Column({ type: "int", nullable: true })
|
||||
width: number | null
|
||||
|
||||
@Column({ default: true })
|
||||
requires_shipping: boolean
|
||||
|
||||
@Column({ type: "jsonb", nullable: true })
|
||||
metadata: Record<string, unknown> | null
|
||||
|
||||
@BeforeInsert()
|
||||
private beforeInsert(): void {
|
||||
this.id = generateEntityId(this.id, "iitem")
|
||||
}
|
||||
}
|
||||
31
packages/inventory/src/models/inventory-level.ts
Normal file
31
packages/inventory/src/models/inventory-level.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Index, Unique, BeforeInsert, Column, Entity } from "typeorm"
|
||||
import { SoftDeletableEntity, generateEntityId } from "@medusajs/medusa"
|
||||
|
||||
@Entity()
|
||||
@Index(["inventory_item_id", "location_id"], { unique: true })
|
||||
export class InventoryLevel extends SoftDeletableEntity {
|
||||
@Index()
|
||||
@Column({ type: "text" })
|
||||
inventory_item_id: string
|
||||
|
||||
@Index()
|
||||
@Column({ type: "text" })
|
||||
location_id: string
|
||||
|
||||
@Column({ default: 0 })
|
||||
stocked_quantity: number
|
||||
|
||||
@Column({ default: 0 })
|
||||
reserved_quantity: number
|
||||
|
||||
@Column({ default: 0 })
|
||||
incoming_quantity: number
|
||||
|
||||
@Column({ type: "jsonb", nullable: true })
|
||||
metadata: Record<string, unknown> | null
|
||||
|
||||
@BeforeInsert()
|
||||
private beforeInsert(): void {
|
||||
this.id = generateEntityId(this.id, "ilev")
|
||||
}
|
||||
}
|
||||
28
packages/inventory/src/models/reservation-item.ts
Normal file
28
packages/inventory/src/models/reservation-item.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Index, Unique, BeforeInsert, Column, Entity } from "typeorm"
|
||||
import { SoftDeletableEntity, generateEntityId } from "@medusajs/medusa"
|
||||
|
||||
@Entity()
|
||||
export class ReservationItem extends SoftDeletableEntity {
|
||||
@Index()
|
||||
@Column({ type: "text", nullable: true })
|
||||
line_item_id: string | null
|
||||
|
||||
@Index()
|
||||
@Column({ type: "text" })
|
||||
inventory_item_id: string
|
||||
|
||||
@Index()
|
||||
@Column({ type: "text" })
|
||||
location_id: string
|
||||
|
||||
@Column()
|
||||
quantity: number
|
||||
|
||||
@Column({ type: "jsonb", nullable: true })
|
||||
metadata: Record<string, unknown> | null
|
||||
|
||||
@BeforeInsert()
|
||||
private beforeInsert(): void {
|
||||
this.id = generateEntityId(this.id, "resitem")
|
||||
}
|
||||
}
|
||||
4
packages/inventory/src/services/index.ts
Normal file
4
packages/inventory/src/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as InventoryLevelService } from "./inventory-level"
|
||||
export { default as InventoryItemService } from "./inventory-item"
|
||||
export { default as ReservationItemService } from "./reservation-item"
|
||||
export { default as InventoryService } from "./inventory"
|
||||
235
packages/inventory/src/services/inventory-item.ts
Normal file
235
packages/inventory/src/services/inventory-item.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { ILike, In, getConnection, DeepPartial, EntityManager } from "typeorm"
|
||||
import { isDefined, MedusaError } from "medusa-core-utils"
|
||||
import {
|
||||
FindConfig,
|
||||
buildQuery,
|
||||
IEventBusService,
|
||||
FilterableInventoryItemProps,
|
||||
CreateInventoryItemInput,
|
||||
} from "@medusajs/medusa"
|
||||
|
||||
import { InventoryItem } from "../models"
|
||||
import { CONNECTION_NAME } from "../config"
|
||||
|
||||
type InjectedDependencies = {
|
||||
eventBusService: IEventBusService
|
||||
}
|
||||
|
||||
export default class InventoryItemService {
|
||||
static Events = {
|
||||
CREATED: "inventory-item.created",
|
||||
UPDATED: "inventory-item.updated",
|
||||
DELETED: "inventory-item.deleted",
|
||||
}
|
||||
|
||||
protected readonly eventBusService_: IEventBusService
|
||||
|
||||
constructor({ eventBusService }: InjectedDependencies) {
|
||||
this.eventBusService_ = eventBusService
|
||||
}
|
||||
|
||||
private getManager(): EntityManager {
|
||||
const connection = getConnection(CONNECTION_NAME)
|
||||
return connection.manager
|
||||
}
|
||||
|
||||
/**
|
||||
* @param selector - Filter options for inventory items.
|
||||
* @param config - Configuration for query.
|
||||
* @return Resolves to the list of inventory items that match the filter.
|
||||
*/
|
||||
async list(
|
||||
selector: FilterableInventoryItemProps = {},
|
||||
config: FindConfig<InventoryItem> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<InventoryItem[]> {
|
||||
const queryBuilder = this.getListQuery(selector, config)
|
||||
return await queryBuilder.getMany()
|
||||
}
|
||||
|
||||
private getListQuery(
|
||||
selector: FilterableInventoryItemProps = {},
|
||||
config: FindConfig<InventoryItem> = { relations: [], skip: 0, take: 10 }
|
||||
) {
|
||||
const manager = this.getManager()
|
||||
const inventoryItemRepository = manager.getRepository(InventoryItem)
|
||||
const query = buildQuery(selector, config)
|
||||
|
||||
const queryBuilder = inventoryItemRepository.createQueryBuilder("inv_item")
|
||||
|
||||
if (query.where.q) {
|
||||
query.where.sku = ILike(`%${query.where.q as string}%`)
|
||||
|
||||
delete query.where.q
|
||||
}
|
||||
|
||||
if ("location_id" in query.where) {
|
||||
const locationIds = Array.isArray(selector.location_id)
|
||||
? selector.location_id
|
||||
: [selector.location_id]
|
||||
|
||||
queryBuilder.innerJoin(
|
||||
"inventory_level",
|
||||
"level",
|
||||
"level.inventory_item_id = inv_item.id AND level.location_id IN (:...locationIds)",
|
||||
{ locationIds }
|
||||
)
|
||||
|
||||
delete query.where.location_id
|
||||
}
|
||||
|
||||
if (query.take) {
|
||||
queryBuilder.take(query.take)
|
||||
}
|
||||
|
||||
if (query.skip) {
|
||||
queryBuilder.skip(query.skip)
|
||||
}
|
||||
|
||||
if (query.where) {
|
||||
queryBuilder.where(query.where)
|
||||
}
|
||||
|
||||
if (query.select) {
|
||||
queryBuilder.select(query.select.map((s) => "inv_item." + s))
|
||||
}
|
||||
|
||||
if (query.order) {
|
||||
const toSelect: string[] = []
|
||||
const parsed = Object.entries(query.order).reduce((acc, [k, v]) => {
|
||||
const key = `inv_item.${k}`
|
||||
toSelect.push(key)
|
||||
acc[key] = v
|
||||
return acc
|
||||
}, {})
|
||||
queryBuilder.addSelect(toSelect)
|
||||
queryBuilder.orderBy(parsed)
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* @param selector - Filter options for inventory items.
|
||||
* @param config - Configuration for query.
|
||||
* @return - Resolves to the list of inventory items that match the filter and the count of all matching items.
|
||||
*/
|
||||
async listAndCount(
|
||||
selector: FilterableInventoryItemProps = {},
|
||||
config: FindConfig<InventoryItem> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<[InventoryItem[], number]> {
|
||||
const queryBuilder = this.getListQuery(selector, config)
|
||||
return await queryBuilder.getManyAndCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an inventory item by its id.
|
||||
* @param inventoryItemId - the id of the inventory item to retrieve.
|
||||
* @param config - the configuration options for the find operation.
|
||||
* @return The retrieved inventory item.
|
||||
* @throws If the inventory item id is not defined or if the inventory item is not found.
|
||||
*/
|
||||
async retrieve(
|
||||
inventoryItemId: string,
|
||||
config: FindConfig<InventoryItem> = {}
|
||||
): Promise<InventoryItem> {
|
||||
if (!isDefined(inventoryItemId)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`"inventoryItemId" must be defined`
|
||||
)
|
||||
}
|
||||
|
||||
const manager = this.getManager()
|
||||
const itemRepository = manager.getRepository(InventoryItem)
|
||||
|
||||
const query = buildQuery({ id: inventoryItemId }, config)
|
||||
const [inventoryItem] = await itemRepository.find(query)
|
||||
|
||||
if (!inventoryItem) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`InventoryItem with id ${inventoryItemId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return inventoryItem
|
||||
}
|
||||
|
||||
/**
|
||||
* @param input - Input for creating a new inventory item.
|
||||
* @return The newly created inventory item.
|
||||
*/
|
||||
async create(data: CreateInventoryItemInput): Promise<InventoryItem> {
|
||||
const manager = this.getManager()
|
||||
const itemRepository = manager.getRepository(InventoryItem)
|
||||
|
||||
const inventoryItem = itemRepository.create({
|
||||
sku: data.sku,
|
||||
origin_country: data.origin_country,
|
||||
metadata: data.metadata,
|
||||
hs_code: data.hs_code,
|
||||
mid_code: data.mid_code,
|
||||
material: data.material,
|
||||
weight: data.weight,
|
||||
length: data.length,
|
||||
height: data.height,
|
||||
width: data.width,
|
||||
requires_shipping: data.requires_shipping,
|
||||
})
|
||||
|
||||
const result = await itemRepository.save(inventoryItem)
|
||||
|
||||
await this.eventBusService_.emit(InventoryItemService.Events.CREATED, {
|
||||
id: result.id,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param inventoryItemId - The id of the inventory item to update.
|
||||
* @param update - The updates to apply to the inventory item.
|
||||
* @return The updated inventory item.
|
||||
*/
|
||||
async update(
|
||||
inventoryItemId: string,
|
||||
data: Omit<
|
||||
DeepPartial<InventoryItem>,
|
||||
"id" | "created_at" | "metadata" | "deleted_at"
|
||||
>
|
||||
): Promise<InventoryItem> {
|
||||
const manager = this.getManager()
|
||||
const itemRepository = manager.getRepository(InventoryItem)
|
||||
|
||||
const item = await this.retrieve(inventoryItemId)
|
||||
|
||||
const shouldUpdate = Object.keys(data).some((key) => {
|
||||
return item[key] !== data[key]
|
||||
})
|
||||
|
||||
if (shouldUpdate) {
|
||||
itemRepository.merge(item, data)
|
||||
await itemRepository.save(item)
|
||||
|
||||
await this.eventBusService_.emit(InventoryItemService.Events.UPDATED, {
|
||||
id: item.id,
|
||||
})
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* @param inventoryItemId - The id of the inventory item to delete.
|
||||
*/
|
||||
async delete(inventoryItemId: string): Promise<void> {
|
||||
const manager = this.getManager()
|
||||
const itemRepository = manager.getRepository(InventoryItem)
|
||||
|
||||
await itemRepository.softRemove({ id: inventoryItemId })
|
||||
|
||||
await this.eventBusService_.emit(InventoryItemService.Events.DELETED, {
|
||||
id: inventoryItemId,
|
||||
})
|
||||
}
|
||||
}
|
||||
287
packages/inventory/src/services/inventory-level.ts
Normal file
287
packages/inventory/src/services/inventory-level.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { getConnection, DeepPartial, EntityManager } from "typeorm"
|
||||
import { isDefined, MedusaError } from "medusa-core-utils"
|
||||
import {
|
||||
FindConfig,
|
||||
buildQuery,
|
||||
FilterableInventoryLevelProps,
|
||||
CreateInventoryLevelInput,
|
||||
IEventBusService,
|
||||
TransactionBaseService,
|
||||
} from "@medusajs/medusa"
|
||||
|
||||
import { InventoryLevel } from "../models"
|
||||
import { CONNECTION_NAME } from "../config"
|
||||
|
||||
type InjectedDependencies = {
|
||||
eventBusService: IEventBusService
|
||||
}
|
||||
|
||||
export default class InventoryLevelService extends TransactionBaseService {
|
||||
static Events = {
|
||||
CREATED: "inventory-level.created",
|
||||
UPDATED: "inventory-level.updated",
|
||||
DELETED: "inventory-level.deleted",
|
||||
}
|
||||
|
||||
protected manager_: EntityManager
|
||||
protected transactionManager_: EntityManager | undefined
|
||||
|
||||
protected readonly eventBusService_: IEventBusService
|
||||
|
||||
constructor({ eventBusService }: InjectedDependencies) {
|
||||
super(arguments[0])
|
||||
|
||||
this.eventBusService_ = eventBusService
|
||||
this.manager_ = this.getManager()
|
||||
}
|
||||
|
||||
private getManager(): EntityManager {
|
||||
if (this.manager_) {
|
||||
return this.transactionManager_ ?? this.manager_
|
||||
}
|
||||
|
||||
const connection = getConnection(CONNECTION_NAME)
|
||||
return connection.manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of inventory levels based on the provided selector and configuration.
|
||||
* @param selector - An object containing filterable properties for inventory levels.
|
||||
* @param config - An object containing configuration options for the query.
|
||||
* @return Array of inventory levels.
|
||||
*/
|
||||
async list(
|
||||
selector: FilterableInventoryLevelProps = {},
|
||||
config: FindConfig<InventoryLevel> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<InventoryLevel[]> {
|
||||
const manager = this.getManager()
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const query = buildQuery(selector, config)
|
||||
return await levelRepository.find(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of inventory levels and a count based on the provided selector and configuration.
|
||||
* @param selector - An object containing filterable properties for inventory levels.
|
||||
* @param config - An object containing configuration options for the query.
|
||||
* @return An array of inventory levels and a count.
|
||||
*/
|
||||
async listAndCount(
|
||||
selector: FilterableInventoryLevelProps = {},
|
||||
config: FindConfig<InventoryLevel> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<[InventoryLevel[], number]> {
|
||||
const manager = this.getManager()
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const query = buildQuery(selector, config)
|
||||
return await levelRepository.findAndCount(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single inventory level by its ID.
|
||||
* @param inventoryLevelId - The ID of the inventory level to retrieve.
|
||||
* @param config - An object containing configuration options for the query.
|
||||
* @return A inventory level.
|
||||
* @throws If the inventory level ID is not defined or the given ID was not found.
|
||||
*/
|
||||
async retrieve(
|
||||
inventoryLevelId: string,
|
||||
config: FindConfig<InventoryLevel> = {}
|
||||
): Promise<InventoryLevel> {
|
||||
if (!isDefined(inventoryLevelId)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`"inventoryLevelId" must be defined`
|
||||
)
|
||||
}
|
||||
|
||||
const manager = this.getManager()
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const query = buildQuery({ id: inventoryLevelId }, config)
|
||||
const [inventoryLevel] = await levelRepository.find(query)
|
||||
|
||||
if (!inventoryLevel) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`InventoryLevel with id ${inventoryLevelId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return inventoryLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new inventory level.
|
||||
* @param data - An object containing the properties for the new inventory level.
|
||||
* @return The created inventory level.
|
||||
*/
|
||||
async create(data: CreateInventoryLevelInput): Promise<InventoryLevel> {
|
||||
const result = await this.atomicPhase_(async (manager) => {
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const inventoryLevel = levelRepository.create({
|
||||
location_id: data.location_id,
|
||||
inventory_item_id: data.inventory_item_id,
|
||||
stocked_quantity: data.stocked_quantity,
|
||||
reserved_quantity: data.reserved_quantity,
|
||||
incoming_quantity: data.incoming_quantity,
|
||||
})
|
||||
|
||||
return await levelRepository.save(inventoryLevel)
|
||||
})
|
||||
|
||||
await this.eventBusService_.emit(InventoryLevelService.Events.CREATED, {
|
||||
id: result.id,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing inventory level.
|
||||
* @param inventoryLevelId - The ID of the inventory level to update.
|
||||
* @param data - An object containing the properties to update on the inventory level.
|
||||
* @param autoSave - A flag indicating whether to save the changes automatically.
|
||||
* @return The updated inventory level.
|
||||
* @throws If the inventory level ID is not defined or the given ID was not found.
|
||||
*/
|
||||
async update(
|
||||
inventoryLevelId: string,
|
||||
data: Omit<
|
||||
DeepPartial<InventoryLevel>,
|
||||
"id" | "created_at" | "metadata" | "deleted_at"
|
||||
>
|
||||
): Promise<InventoryLevel> {
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const item = await this.retrieve(inventoryLevelId)
|
||||
|
||||
const shouldUpdate = Object.keys(data).some((key) => {
|
||||
return item[key] !== data[key]
|
||||
})
|
||||
|
||||
if (shouldUpdate) {
|
||||
levelRepository.merge(item, data)
|
||||
await levelRepository.save(item)
|
||||
|
||||
await this.eventBusService_.emit(InventoryLevelService.Events.UPDATED, {
|
||||
id: item.id,
|
||||
})
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the reserved quantity for an inventory item at a specific location.
|
||||
* @param inventoryItemId - The ID of the inventory item.
|
||||
* @param locationId - The ID of the location.
|
||||
* @param quantity - The quantity to adjust from the reserved quantity.
|
||||
*/
|
||||
async adjustReservedQuantity(
|
||||
inventoryItemId: string,
|
||||
locationId: string,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
await this.atomicPhase_(async (manager) => {
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(InventoryLevel)
|
||||
.set({ reserved_quantity: () => `reserved_quantity + ${quantity}` })
|
||||
.where(
|
||||
"inventory_item_id = :inventoryItemId AND location_id = :locationId",
|
||||
{ inventoryItemId, locationId }
|
||||
)
|
||||
.execute()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an inventory level by ID.
|
||||
* @param inventoryLevelId - The ID of the inventory level to delete.
|
||||
*/
|
||||
async delete(inventoryLevelId: string): Promise<void> {
|
||||
await this.atomicPhase_(async (manager) => {
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
await levelRepository.delete({ id: inventoryLevelId })
|
||||
})
|
||||
|
||||
await this.eventBusService_.emit(InventoryLevelService.Events.DELETED, {
|
||||
id: inventoryLevelId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total stocked quantity for a specific inventory item at multiple locations.
|
||||
* @param inventoryItemId - The ID of the inventory item.
|
||||
* @param locationIds - The IDs of the locations.
|
||||
* @return The total stocked quantity.
|
||||
*/
|
||||
async getStockedQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number> {
|
||||
const manager = this.getManager()
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const result = await levelRepository
|
||||
.createQueryBuilder()
|
||||
.select("SUM(stocked_quantity)", "quantity")
|
||||
.where("inventory_item_id = :inventoryItemId", { inventoryItemId })
|
||||
.andWhere("location_id IN (:...locationIds)", { locationIds })
|
||||
.getRawOne()
|
||||
|
||||
return result.quantity
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total available quantity for a specific inventory item at multiple locations.
|
||||
* @param inventoryItemId - The ID of the inventory item.
|
||||
* @param locationIds - The IDs of the locations.
|
||||
* @return The total available quantity.
|
||||
*/
|
||||
async getAvailableQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number> {
|
||||
const manager = this.getManager()
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const result = await levelRepository
|
||||
.createQueryBuilder()
|
||||
.select("SUM(stocked_quantity - reserved_quantity)", "quantity")
|
||||
.where("inventory_item_id = :inventoryItemId", { inventoryItemId })
|
||||
.andWhere("location_id IN (:...locationIds)", { locationIds })
|
||||
.getRawOne()
|
||||
|
||||
return result.quantity
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total reserved quantity for a specific inventory item at multiple locations.
|
||||
* @param inventoryItemId - The ID of the inventory item.
|
||||
* @param locationIds - The IDs of the locations.
|
||||
* @return The total reserved quantity.
|
||||
*/
|
||||
async getReservedQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number> {
|
||||
const manager = this.getManager()
|
||||
const levelRepository = manager.getRepository(InventoryLevel)
|
||||
|
||||
const result = await levelRepository
|
||||
.createQueryBuilder()
|
||||
.select("SUM(reserved_quantity)", "quantity")
|
||||
.where("inventory_item_id = :inventoryItemId", { inventoryItemId })
|
||||
.andWhere("location_id IN (:...locationIds)", { locationIds })
|
||||
.getRawOne()
|
||||
|
||||
return result.quantity
|
||||
}
|
||||
}
|
||||
412
packages/inventory/src/services/inventory.ts
Normal file
412
packages/inventory/src/services/inventory.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import {
|
||||
FindConfig,
|
||||
IInventoryService,
|
||||
FilterableInventoryItemProps,
|
||||
FilterableReservationItemProps,
|
||||
CreateInventoryItemInput,
|
||||
CreateReservationItemInput,
|
||||
FilterableInventoryLevelProps,
|
||||
CreateInventoryLevelInput,
|
||||
UpdateInventoryLevelInput,
|
||||
UpdateReservationItemInput,
|
||||
IEventBusService,
|
||||
InventoryItemDTO,
|
||||
ReservationItemDTO,
|
||||
InventoryLevelDTO,
|
||||
} from "@medusajs/medusa"
|
||||
|
||||
import {
|
||||
InventoryItemService,
|
||||
ReservationItemService,
|
||||
InventoryLevelService,
|
||||
} from "./"
|
||||
|
||||
type InjectedDependencies = {
|
||||
eventBusService: IEventBusService
|
||||
}
|
||||
|
||||
export default class InventoryService implements IInventoryService {
|
||||
protected readonly eventBusService_: IEventBusService
|
||||
protected readonly inventoryItemService_: InventoryItemService
|
||||
protected readonly reservationItemService_: ReservationItemService
|
||||
protected readonly inventoryLevelService_: InventoryLevelService
|
||||
|
||||
constructor({ eventBusService }: InjectedDependencies) {
|
||||
this.eventBusService_ = eventBusService
|
||||
|
||||
this.inventoryItemService_ = new InventoryItemService({ eventBusService })
|
||||
this.inventoryLevelService_ = new InventoryLevelService({
|
||||
eventBusService,
|
||||
})
|
||||
this.reservationItemService_ = new ReservationItemService({
|
||||
eventBusService,
|
||||
inventoryLevelService: this.inventoryLevelService_,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists inventory items that match the given selector
|
||||
* @param selector - the selector to filter inventory items by
|
||||
* @param config - the find configuration to use
|
||||
* @return A tuple of inventory items and their total count
|
||||
*/
|
||||
async listInventoryItems(
|
||||
selector: FilterableInventoryItemProps,
|
||||
config: FindConfig<InventoryItemDTO> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<[InventoryItemDTO[], number]> {
|
||||
return await this.inventoryItemService_.listAndCount(selector, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists inventory levels that match the given selector
|
||||
* @param selector - the selector to filter inventory levels by
|
||||
* @param config - the find configuration to use
|
||||
* @return A tuple of inventory levels and their total count
|
||||
*/
|
||||
async listInventoryLevels(
|
||||
selector: FilterableInventoryLevelProps,
|
||||
config: FindConfig<InventoryLevelDTO> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<[InventoryLevelDTO[], number]> {
|
||||
return await this.inventoryLevelService_.listAndCount(selector, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists reservation items that match the given selector
|
||||
* @param selector - the selector to filter reservation items by
|
||||
* @param config - the find configuration to use
|
||||
* @return A tuple of reservation items and their total count
|
||||
*/
|
||||
async listReservationItems(
|
||||
selector: FilterableReservationItemProps,
|
||||
config: FindConfig<ReservationItemDTO> = {
|
||||
relations: [],
|
||||
skip: 0,
|
||||
take: 10,
|
||||
}
|
||||
): Promise<[ReservationItemDTO[], number]> {
|
||||
return await this.reservationItemService_.listAndCount(selector, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an inventory item with the given id
|
||||
* @param inventoryItemId - the id of the inventory item to retrieve
|
||||
* @param config - the find configuration to use
|
||||
* @return The retrieved inventory item
|
||||
*/
|
||||
async retrieveInventoryItem(
|
||||
inventoryItemId: string,
|
||||
config?: FindConfig<InventoryItemDTO>
|
||||
): Promise<InventoryItemDTO> {
|
||||
const inventoryItem = await this.inventoryItemService_.retrieve(
|
||||
inventoryItemId,
|
||||
config
|
||||
)
|
||||
return { ...inventoryItem }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an inventory level for a given inventory item and location
|
||||
* @param inventoryItemId - the id of the inventory item
|
||||
* @param locationId - the id of the location
|
||||
* @return the retrieved inventory level
|
||||
*/
|
||||
async retrieveInventoryLevel(
|
||||
inventoryItemId: string,
|
||||
locationId: string
|
||||
): Promise<InventoryLevelDTO> {
|
||||
const [inventoryLevel] = await this.inventoryLevelService_.list(
|
||||
{ inventory_item_id: inventoryItemId, location_id: locationId },
|
||||
{ take: 1 }
|
||||
)
|
||||
if (!inventoryLevel) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Inventory level for item ${inventoryItemId} and location ${locationId} not found`
|
||||
)
|
||||
}
|
||||
return inventoryLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reservation item
|
||||
* @param input - the input object
|
||||
* @return The created reservation item
|
||||
*/
|
||||
async createReservationItem(
|
||||
input: CreateReservationItemInput
|
||||
): Promise<ReservationItemDTO> {
|
||||
// Verify that the item is stocked at the location
|
||||
const [inventoryLevel] = await this.inventoryLevelService_.list(
|
||||
{
|
||||
inventory_item_id: input.inventory_item_id,
|
||||
location_id: input.location_id,
|
||||
},
|
||||
{ take: 1 }
|
||||
)
|
||||
|
||||
if (!inventoryLevel) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Item ${input.inventory_item_id} is not stocked at location ${input.location_id}`
|
||||
)
|
||||
}
|
||||
|
||||
const reservationItem = await this.reservationItemService_.create(input)
|
||||
|
||||
return { ...reservationItem }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an inventory item
|
||||
* @param input - the input object
|
||||
* @return The created inventory item
|
||||
*/
|
||||
async createInventoryItem(
|
||||
input: CreateInventoryItemInput
|
||||
): Promise<InventoryItemDTO> {
|
||||
const inventoryItem = await this.inventoryItemService_.create(input)
|
||||
return { ...inventoryItem }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an inventory item
|
||||
* @param input - the input object
|
||||
* @return The created inventory level
|
||||
*/
|
||||
async createInventoryLevel(
|
||||
input: CreateInventoryLevelInput
|
||||
): Promise<InventoryLevelDTO> {
|
||||
return await this.inventoryLevelService_.create(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an inventory item
|
||||
* @param inventoryItemId - the id of the inventory item to update
|
||||
* @param input - the input object
|
||||
* @return The updated inventory item
|
||||
*/
|
||||
async updateInventoryItem(
|
||||
inventoryItemId: string,
|
||||
input: Partial<CreateInventoryItemInput>
|
||||
): Promise<InventoryItemDTO> {
|
||||
const inventoryItem = await this.inventoryItemService_.update(
|
||||
inventoryItemId,
|
||||
input
|
||||
)
|
||||
return { ...inventoryItem }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an inventory item
|
||||
* @param inventoryItemId - the id of the inventory item to delete
|
||||
*/
|
||||
async deleteInventoryItem(inventoryItemId: string): Promise<void> {
|
||||
return await this.inventoryItemService_.delete(inventoryItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an inventory level
|
||||
* @param inventoryItemId - the id of the inventory item associated with the level
|
||||
* @param locationId - the id of the location associated with the level
|
||||
*/
|
||||
async deleteInventoryLevel(
|
||||
inventoryItemId: string,
|
||||
locationId: string
|
||||
): Promise<void> {
|
||||
const [inventoryLevel] = await this.inventoryLevelService_.list(
|
||||
{ inventory_item_id: inventoryItemId, location_id: locationId },
|
||||
{ take: 1 }
|
||||
)
|
||||
|
||||
if (!inventoryLevel) {
|
||||
return
|
||||
}
|
||||
|
||||
return await this.inventoryLevelService_.delete(inventoryLevel.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an inventory level
|
||||
* @param inventoryItemId - the id of the inventory item associated with the level
|
||||
* @param locationId - the id of the location associated with the level
|
||||
* @param input - the input object
|
||||
* @return The updated inventory level
|
||||
*/
|
||||
async updateInventoryLevel(
|
||||
inventoryItemId: string,
|
||||
locationId: string,
|
||||
input: UpdateInventoryLevelInput
|
||||
): Promise<InventoryLevelDTO> {
|
||||
const [inventoryLevel] = await this.inventoryLevelService_.list(
|
||||
{ inventory_item_id: inventoryItemId, location_id: locationId },
|
||||
{ take: 1 }
|
||||
)
|
||||
|
||||
if (!inventoryLevel) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Inventory level for item ${inventoryItemId} and location ${locationId} not found`
|
||||
)
|
||||
}
|
||||
|
||||
return await this.inventoryLevelService_.update(inventoryLevel.id, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a reservation item
|
||||
* @param inventoryItemId - the id of the inventory item associated with the level
|
||||
* @param input - the input object
|
||||
* @return The updated inventory level
|
||||
*/
|
||||
async updateReservationItem(
|
||||
reservationItemId: string,
|
||||
input: UpdateReservationItemInput
|
||||
): Promise<ReservationItemDTO> {
|
||||
return await this.reservationItemService_.update(reservationItemId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes reservation items by line item
|
||||
* @param lineItemId - the id of the line item associated with the reservation item
|
||||
*/
|
||||
async deleteReservationItemsByLineItem(lineItemId: string): Promise<void> {
|
||||
return await this.reservationItemService_.deleteByLineItem(lineItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a reservation item
|
||||
* @param reservationItemId - the id of the reservation item to delete
|
||||
*/
|
||||
async deleteReservationItem(reservationItemId: string): Promise<void> {
|
||||
return await this.reservationItemService_.delete(reservationItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the inventory level for a given inventory item and location.
|
||||
* @param inventoryItemId - the id of the inventory item
|
||||
* @param locationId - the id of the location
|
||||
* @param adjustment - the number to adjust the inventory by (can be positive or negative)
|
||||
* @return The updated inventory level
|
||||
* @throws when the inventory level is not found
|
||||
*/
|
||||
async adjustInventory(
|
||||
inventoryItemId: string,
|
||||
locationId: string,
|
||||
adjustment: number
|
||||
): Promise<InventoryLevelDTO> {
|
||||
const [inventoryLevel] = await this.inventoryLevelService_.list(
|
||||
{ inventory_item_id: inventoryItemId, location_id: locationId },
|
||||
{ take: 1 }
|
||||
)
|
||||
if (!inventoryLevel) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Inventory level for inventory item ${inventoryItemId} and location ${locationId} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const updatedInventoryLevel = await this.inventoryLevelService_.update(
|
||||
inventoryLevel.id,
|
||||
{
|
||||
stocked_quantity: inventoryLevel.stocked_quantity + adjustment,
|
||||
}
|
||||
)
|
||||
|
||||
return { ...updatedInventoryLevel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the available quantity of a given inventory item in a given location.
|
||||
* @param inventoryItemId - the id of the inventory item
|
||||
* @param locationIds - the ids of the locations to check
|
||||
* @return The available quantity
|
||||
* @throws when the inventory item is not found
|
||||
*/
|
||||
async retrieveAvailableQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number> {
|
||||
// Throws if item does not exist
|
||||
await this.inventoryItemService_.retrieve(inventoryItemId, {
|
||||
select: ["id"],
|
||||
})
|
||||
|
||||
const availableQuantity =
|
||||
await this.inventoryLevelService_.getAvailableQuantity(
|
||||
inventoryItemId,
|
||||
locationIds
|
||||
)
|
||||
|
||||
return availableQuantity
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the stocked quantity of a given inventory item in a given location.
|
||||
* @param inventoryItemId - the id of the inventory item
|
||||
* @param locationIds - the ids of the locations to check
|
||||
* @return The stocked quantity
|
||||
* @throws when the inventory item is not found
|
||||
*/
|
||||
async retrieveStockedQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number> {
|
||||
// Throws if item does not exist
|
||||
await this.inventoryItemService_.retrieve(inventoryItemId, {
|
||||
select: ["id"],
|
||||
})
|
||||
|
||||
const stockedQuantity =
|
||||
await this.inventoryLevelService_.getStockedQuantity(
|
||||
inventoryItemId,
|
||||
locationIds
|
||||
)
|
||||
|
||||
return stockedQuantity
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the reserved quantity of a given inventory item in a given location.
|
||||
* @param inventoryItemId - the id of the inventory item
|
||||
* @param locationIds - the ids of the locations to check
|
||||
* @return The reserved quantity
|
||||
* @throws when the inventory item is not found
|
||||
*/
|
||||
async retrieveReservedQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number> {
|
||||
// Throws if item does not exist
|
||||
await this.inventoryItemService_.retrieve(inventoryItemId, {
|
||||
select: ["id"],
|
||||
})
|
||||
|
||||
const reservedQuantity =
|
||||
await this.inventoryLevelService_.getReservedQuantity(
|
||||
inventoryItemId,
|
||||
locationIds
|
||||
)
|
||||
|
||||
return reservedQuantity
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms whether there is sufficient inventory for a given quantity of a given inventory item in a given location.
|
||||
* @param inventoryItemId - the id of the inventory item
|
||||
* @param locationIds - the ids of the locations to check
|
||||
* @param quantity - the quantity to check
|
||||
* @return Whether there is sufficient inventory
|
||||
*/
|
||||
async confirmInventory(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[],
|
||||
quantity: number
|
||||
): Promise<boolean> {
|
||||
const availableQuantity = await this.retrieveAvailableQuantity(
|
||||
inventoryItemId,
|
||||
locationIds
|
||||
)
|
||||
return availableQuantity >= quantity
|
||||
}
|
||||
}
|
||||
268
packages/inventory/src/services/reservation-item.ts
Normal file
268
packages/inventory/src/services/reservation-item.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { getConnection, DeepPartial, EntityManager } from "typeorm"
|
||||
import { isDefined, MedusaError } from "medusa-core-utils"
|
||||
import {
|
||||
FindConfig,
|
||||
buildQuery,
|
||||
IEventBusService,
|
||||
FilterableReservationItemProps,
|
||||
CreateReservationItemInput,
|
||||
TransactionBaseService,
|
||||
UpdateReservationItemInput,
|
||||
} from "@medusajs/medusa"
|
||||
|
||||
import { ReservationItem } from "../models"
|
||||
import { CONNECTION_NAME } from "../config"
|
||||
import { InventoryLevelService } from "."
|
||||
|
||||
type InjectedDependencies = {
|
||||
eventBusService: IEventBusService
|
||||
inventoryLevelService: InventoryLevelService
|
||||
}
|
||||
|
||||
export default class ReservationItemService extends TransactionBaseService {
|
||||
static Events = {
|
||||
CREATED: "reservation-item.created",
|
||||
UPDATED: "reservation-item.updated",
|
||||
DELETED: "reservation-item.deleted",
|
||||
DELETED_BY_LINE_ITEM: "reservation-item.deleted-by-line-item",
|
||||
}
|
||||
|
||||
protected manager_: EntityManager
|
||||
protected transactionManager_: EntityManager | undefined
|
||||
protected readonly eventBusService_: IEventBusService
|
||||
protected readonly inventoryLevelService_: InventoryLevelService
|
||||
|
||||
constructor({
|
||||
eventBusService,
|
||||
inventoryLevelService,
|
||||
}: InjectedDependencies) {
|
||||
super(arguments[0])
|
||||
|
||||
this.manager_ = this.getManager()
|
||||
this.eventBusService_ = eventBusService
|
||||
this.inventoryLevelService_ = inventoryLevelService
|
||||
}
|
||||
|
||||
private getManager(): EntityManager {
|
||||
if (this.manager_) {
|
||||
return this.transactionManager_ ?? this.manager_
|
||||
}
|
||||
|
||||
const connection = getConnection(CONNECTION_NAME)
|
||||
return connection.manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists reservation items that match the provided filter.
|
||||
* @param selector - Filters to apply to the reservation items.
|
||||
* @param config - Configuration for the query.
|
||||
* @return Array of reservation items that match the selector.
|
||||
*/
|
||||
async list(
|
||||
selector: FilterableReservationItemProps = {},
|
||||
config: FindConfig<ReservationItem> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<ReservationItem[]> {
|
||||
const manager = this.getManager()
|
||||
const itemRepository = manager.getRepository(ReservationItem)
|
||||
|
||||
const query = buildQuery(selector, config)
|
||||
return await itemRepository.find(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists reservation items that match the provided filter and returns the total count.
|
||||
* @param selector - Filters to apply to the reservation items.
|
||||
* @param config - Configuration for the query.
|
||||
* @return Array of reservation items that match the selector and the total count.
|
||||
*/
|
||||
async listAndCount(
|
||||
selector: FilterableReservationItemProps = {},
|
||||
config: FindConfig<ReservationItem> = { relations: [], skip: 0, take: 10 }
|
||||
): Promise<[ReservationItem[], number]> {
|
||||
const manager = this.getManager()
|
||||
const itemRepository = manager.getRepository(ReservationItem)
|
||||
|
||||
const query = buildQuery(selector, config)
|
||||
return await itemRepository.findAndCount(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a reservation item by its id.
|
||||
* @param reservationItemId - The id of the reservation item to retrieve.
|
||||
* @param config - Configuration for the query.
|
||||
* @return The reservation item with the provided id.
|
||||
* @throws If reservationItemId is not defined or if the reservation item was not found.
|
||||
*/
|
||||
async retrieve(
|
||||
reservationItemId: string,
|
||||
config: FindConfig<ReservationItem> = {}
|
||||
): Promise<ReservationItem> {
|
||||
if (!isDefined(reservationItemId)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`"reservationItemId" must be defined`
|
||||
)
|
||||
}
|
||||
|
||||
const manager = this.getManager()
|
||||
const reservationItemRepository = manager.getRepository(ReservationItem)
|
||||
|
||||
const query = buildQuery({ id: reservationItemId }, config)
|
||||
const [reservationItem] = await reservationItemRepository.find(query)
|
||||
|
||||
if (!reservationItem) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`ReservationItem with id ${reservationItemId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return reservationItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new reservation item.
|
||||
* @param data - The reservation item data.
|
||||
* @return The created reservation item.
|
||||
*/
|
||||
async create(data: CreateReservationItemInput): Promise<ReservationItem> {
|
||||
const result = await this.atomicPhase_(async (manager) => {
|
||||
const itemRepository = manager.getRepository(ReservationItem)
|
||||
|
||||
const inventoryItem = itemRepository.create({
|
||||
inventory_item_id: data.inventory_item_id,
|
||||
line_item_id: data.line_item_id,
|
||||
location_id: data.location_id,
|
||||
quantity: data.quantity,
|
||||
metadata: data.metadata,
|
||||
})
|
||||
|
||||
const [newInventoryItem] = await Promise.all([
|
||||
itemRepository.save(inventoryItem),
|
||||
this.inventoryLevelService_
|
||||
.withTransaction(manager)
|
||||
.adjustReservedQuantity(
|
||||
data.inventory_item_id,
|
||||
data.location_id,
|
||||
data.quantity
|
||||
),
|
||||
])
|
||||
|
||||
return newInventoryItem
|
||||
})
|
||||
|
||||
await this.eventBusService_.emit(ReservationItemService.Events.CREATED, {
|
||||
id: result.id,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a reservation item.
|
||||
* @param reservationItemId - The reservation item's id.
|
||||
* @param data - The reservation item data to update.
|
||||
* @return The updated reservation item.
|
||||
*/
|
||||
async update(
|
||||
reservationItemId: string,
|
||||
data: UpdateReservationItemInput
|
||||
): Promise<ReservationItem> {
|
||||
const updatedItem = await this.atomicPhase_(async (manager) => {
|
||||
const itemRepository = manager.getRepository(ReservationItem)
|
||||
|
||||
const item = await this.retrieve(reservationItemId)
|
||||
|
||||
const shouldUpdateQuantity =
|
||||
isDefined(data.quantity) && data.quantity !== item.quantity
|
||||
|
||||
const ops: Promise<unknown>[] = []
|
||||
if (shouldUpdateQuantity) {
|
||||
const quantityDiff = data.quantity! - item.quantity
|
||||
ops.push(
|
||||
this.inventoryLevelService_
|
||||
.withTransaction(manager)
|
||||
.adjustReservedQuantity(
|
||||
item.inventory_item_id,
|
||||
item.location_id,
|
||||
quantityDiff
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const mergedItem = itemRepository.merge(item, data)
|
||||
|
||||
ops.push(itemRepository.save(item))
|
||||
|
||||
await Promise.all(ops)
|
||||
|
||||
return mergedItem
|
||||
})
|
||||
|
||||
await this.eventBusService_.emit(ReservationItemService.Events.UPDATED, {
|
||||
id: updatedItem.id,
|
||||
})
|
||||
|
||||
return updatedItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a reservation item by line item id.
|
||||
* @param lineItemId - the id of the line item to delete.
|
||||
*/
|
||||
async deleteByLineItem(lineItemId: string): Promise<void> {
|
||||
await this.atomicPhase_(async (manager) => {
|
||||
const itemRepository = manager.getRepository(ReservationItem)
|
||||
|
||||
const items = await this.list({ line_item_id: lineItemId })
|
||||
|
||||
const ops: Promise<unknown>[] = []
|
||||
for (const item of items) {
|
||||
ops.push(itemRepository.softRemove({ line_item_id: lineItemId }))
|
||||
ops.push(
|
||||
this.inventoryLevelService_
|
||||
.withTransaction(manager)
|
||||
.adjustReservedQuantity(
|
||||
item.inventory_item_id,
|
||||
item.location_id,
|
||||
item.quantity * -1
|
||||
)
|
||||
)
|
||||
}
|
||||
await Promise.all(ops)
|
||||
})
|
||||
|
||||
await this.eventBusService_.emit(
|
||||
ReservationItemService.Events.DELETED_BY_LINE_ITEM,
|
||||
{
|
||||
line_item_id: lineItemId,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a reservation item by id.
|
||||
* @param reservationItemId - the id of the reservation item to delete.
|
||||
*/
|
||||
async delete(reservationItemId: string): Promise<void> {
|
||||
await this.atomicPhase_(async (manager) => {
|
||||
const itemRepository = manager.getRepository(ReservationItem)
|
||||
const item = await this.retrieve(reservationItemId)
|
||||
|
||||
await Promise.all([
|
||||
itemRepository.softRemove({ id: reservationItemId }),
|
||||
this.inventoryLevelService_
|
||||
.withTransaction(manager)
|
||||
.adjustReservedQuantity(
|
||||
item.inventory_item_id,
|
||||
item.location_id,
|
||||
item.quantity * -1
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
await this.eventBusService_.emit(ReservationItemService.Events.DELETED, {
|
||||
id: reservationItemId,
|
||||
})
|
||||
}
|
||||
}
|
||||
32
packages/inventory/tsconfig.json
Normal file
32
packages/inventory/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es5",
|
||||
"es6",
|
||||
"es2019"
|
||||
],
|
||||
"target": "es5",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitReturns": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitThis": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"downlevelIteration": true // to use ES5 specific tooling
|
||||
},
|
||||
"include": ["./src/**/*", "index.d.ts"],
|
||||
"exclude": [
|
||||
"./dist/**/*",
|
||||
"./src/**/__tests__",
|
||||
"./src/**/__mocks__",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -31,12 +31,12 @@ export interface IInventoryService {
|
||||
): Promise<[InventoryLevelDTO[], number]>
|
||||
|
||||
retrieveInventoryItem(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
config?: FindConfig<InventoryItemDTO>
|
||||
): Promise<InventoryItemDTO>
|
||||
|
||||
retrieveInventoryLevel(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
locationId: string
|
||||
): Promise<InventoryLevelDTO>
|
||||
|
||||
@@ -53,48 +53,56 @@ export interface IInventoryService {
|
||||
): Promise<InventoryLevelDTO>
|
||||
|
||||
updateInventoryLevel(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
locationId: string,
|
||||
update: UpdateInventoryLevelInput
|
||||
): Promise<InventoryLevelDTO>
|
||||
|
||||
updateInventoryItem(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
input: CreateInventoryItemInput
|
||||
): Promise<InventoryItemDTO>
|
||||
|
||||
updateReservationItem(
|
||||
reservationId: string,
|
||||
update: UpdateReservationItemInput
|
||||
reservationItemId: string,
|
||||
input: UpdateReservationItemInput
|
||||
): Promise<ReservationItemDTO>
|
||||
|
||||
deleteReservationItemsByLineItem(lineItemId: string): Promise<void>
|
||||
|
||||
deleteReservationItem(id: string): Promise<void>
|
||||
deleteReservationItem(reservationItemId: string): Promise<void>
|
||||
|
||||
deleteInventoryItem(itemId: string): Promise<void>
|
||||
deleteInventoryItem(inventoryItemId: string): Promise<void>
|
||||
|
||||
deleteInventoryLevel(itemId: string, locationId: string): Promise<void>
|
||||
deleteInventoryLevel(
|
||||
inventoryLevelId: string,
|
||||
locationId: string
|
||||
): Promise<void>
|
||||
|
||||
adjustInventory(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
locationId: string,
|
||||
adjustment: number
|
||||
): Promise<InventoryLevelDTO>
|
||||
|
||||
confirmInventory(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
locationIds: string[],
|
||||
quantity: number
|
||||
): Promise<boolean>
|
||||
|
||||
retrieveAvailableQuantity(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number>
|
||||
|
||||
retrieveStockedQuantity(
|
||||
itemId: string,
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number>
|
||||
|
||||
retrieveReservedQuantity(
|
||||
inventoryItemId: string,
|
||||
locationIds: string[]
|
||||
): Promise<number>
|
||||
}
|
||||
|
||||
128
packages/medusa/src/scripts/migrate-inventory-items.ts
Normal file
128
packages/medusa/src/scripts/migrate-inventory-items.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import dotenv from "dotenv"
|
||||
import { AwilixContainer } from "awilix"
|
||||
import express from "express"
|
||||
|
||||
import {
|
||||
ProductVariantService,
|
||||
ProductVariantInventoryService,
|
||||
} from "../services"
|
||||
import { ProductVariant } from "../models"
|
||||
import { IInventoryService, IStockLocationService } from "../interfaces"
|
||||
import loaders from "../loaders"
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
const migrateProductVariant = async (
|
||||
variant: ProductVariant,
|
||||
locationId: string,
|
||||
{ container }: { container: AwilixContainer }
|
||||
) => {
|
||||
const productVariantInventoryService: ProductVariantInventoryService =
|
||||
container.resolve("productVariantInventoryService")
|
||||
const inventoryService: IInventoryService =
|
||||
container.resolve("inventoryService")
|
||||
|
||||
if (!variant.manage_inventory) {
|
||||
return
|
||||
}
|
||||
|
||||
const inventoryItem = await inventoryService.createInventoryItem({
|
||||
sku: variant.sku,
|
||||
material: variant.material,
|
||||
width: variant.width,
|
||||
length: variant.length,
|
||||
height: variant.height,
|
||||
weight: variant.weight,
|
||||
origin_country: variant.origin_country,
|
||||
hs_code: variant.hs_code,
|
||||
mid_code: variant.mid_code,
|
||||
requires_shipping: true,
|
||||
})
|
||||
|
||||
await productVariantInventoryService.attachInventoryItem(
|
||||
variant.id,
|
||||
inventoryItem.id,
|
||||
1
|
||||
)
|
||||
|
||||
await inventoryService.createInventoryLevel({
|
||||
location_id: locationId,
|
||||
inventory_item_id: inventoryItem.id,
|
||||
stocked_quantity: variant.inventory_quantity,
|
||||
incoming_quantity: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const migrateStockLocation = async (container: AwilixContainer) => {
|
||||
const stockLocationService: IStockLocationService = container.resolve(
|
||||
"stockLocationService"
|
||||
)
|
||||
const existing = await stockLocationService.list({}, { take: 1 })
|
||||
|
||||
if (existing.length) {
|
||||
return existing[0].id
|
||||
}
|
||||
|
||||
const stockLocation = await stockLocationService.create({ name: "Default" })
|
||||
|
||||
return stockLocation.id
|
||||
}
|
||||
|
||||
const processBatch = async (
|
||||
variants: ProductVariant[],
|
||||
locationId: string,
|
||||
container: AwilixContainer
|
||||
) => {
|
||||
await Promise.all(
|
||||
variants.map(async (variant) => {
|
||||
await migrateProductVariant(variant, locationId, { container })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const migrate = async function ({ directory }) {
|
||||
const app = express()
|
||||
const { container } = await loaders({
|
||||
directory,
|
||||
expressApp: app,
|
||||
isTest: false,
|
||||
})
|
||||
|
||||
const variantService: ProductVariantService = await container.resolve(
|
||||
"productVariantService"
|
||||
)
|
||||
|
||||
const defaultLocationId = await migrateStockLocation(container)
|
||||
|
||||
const [variants, totalCount] = await variantService.listAndCount(
|
||||
{},
|
||||
{ take: BATCH_SIZE, order: { id: "ASC" } }
|
||||
)
|
||||
|
||||
await processBatch(variants, defaultLocationId, container)
|
||||
|
||||
let processedCount = variants.length
|
||||
console.log(`Processed ${processedCount} of ${totalCount}`)
|
||||
while (processedCount < totalCount) {
|
||||
const nextBatch = await variantService.list(
|
||||
{},
|
||||
{
|
||||
skip: processedCount,
|
||||
take: BATCH_SIZE,
|
||||
order: { id: "ASC" },
|
||||
}
|
||||
)
|
||||
|
||||
await processBatch(nextBatch, defaultLocationId, container)
|
||||
|
||||
processedCount += nextBatch.length
|
||||
console.log(`Processed ${processedCount} of ${totalCount}`)
|
||||
}
|
||||
|
||||
console.log("Done")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
migrate({ directory: process.cwd() })
|
||||
@@ -34,6 +34,7 @@ export type InventoryLevelDTO = {
|
||||
inventory_item_id: string
|
||||
location_id: string
|
||||
stocked_quantity: number
|
||||
reserved_quantity: number
|
||||
incoming_quantity: number
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string | Date
|
||||
@@ -87,6 +88,7 @@ export type FilterableInventoryLevelProps = {
|
||||
inventory_item_id?: string | string[]
|
||||
location_id?: string | string[]
|
||||
stocked_quantity?: number | NumericalComparisonOperator
|
||||
reserved_quantity?: number | NumericalComparisonOperator
|
||||
incoming_quantity?: number | NumericalComparisonOperator
|
||||
}
|
||||
|
||||
@@ -94,7 +96,8 @@ export type CreateInventoryLevelInput = {
|
||||
inventory_item_id: string
|
||||
location_id: string
|
||||
stocked_quantity: number
|
||||
incoming_quantity: number
|
||||
reserved_quantity?: number
|
||||
incoming_quantity?: number
|
||||
}
|
||||
|
||||
export type UpdateInventoryLevelInput = {
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -4332,6 +4332,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@medusajs/inventory@workspace:packages/inventory":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@medusajs/inventory@workspace:packages/inventory"
|
||||
dependencies:
|
||||
"@medusajs/medusa": "*"
|
||||
cross-env: ^5.2.1
|
||||
jest: ^25.5.2
|
||||
ts-jest: ^25.5.1
|
||||
typeorm: ^0.2.31
|
||||
typescript: ^4.4.4
|
||||
peerDependencies:
|
||||
"@medusajs/medusa": 1.x.x
|
||||
medusa-interfaces: 1.x.x
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@medusajs/medusa-cli@^1.3.5, @medusajs/medusa-cli@workspace:packages/medusa-cli":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@medusajs/medusa-cli@workspace:packages/medusa-cli"
|
||||
|
||||
Reference in New Issue
Block a user