From 93ee248493b448b734383784d88e2ca29bfa8e59 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Tue, 10 Jan 2023 14:38:30 -0300 Subject: [PATCH] feat(medusa, inventory): Inventory Management module (#2956) * feat: inventory module --- packages/inventory/.gitignore | 6 + packages/inventory/.npmignore | 10 + packages/inventory/jest.config.js | 13 + packages/inventory/package.json | 37 ++ packages/inventory/src/config.ts | 1 + packages/inventory/src/index.js | 7 + packages/inventory/src/loaders/connection.ts | 22 + .../1665748086258-inventory_setup.ts | 126 ++++++ packages/inventory/src/models/index.ts | 3 + .../inventory/src/models/inventory-item.ts | 44 ++ .../inventory/src/models/inventory-level.ts | 31 ++ .../inventory/src/models/reservation-item.ts | 28 ++ packages/inventory/src/services/index.ts | 4 + .../inventory/src/services/inventory-item.ts | 235 ++++++++++ .../inventory/src/services/inventory-level.ts | 287 ++++++++++++ packages/inventory/src/services/inventory.ts | 412 ++++++++++++++++++ .../src/services/reservation-item.ts | 268 ++++++++++++ packages/inventory/tsconfig.json | 32 ++ .../src/interfaces/services/inventory.ts | 34 +- .../src/scripts/migrate-inventory-items.ts | 128 ++++++ packages/medusa/src/types/inventory.ts | 5 +- yarn.lock | 16 + 22 files changed, 1735 insertions(+), 14 deletions(-) create mode 100644 packages/inventory/.gitignore create mode 100644 packages/inventory/.npmignore create mode 100644 packages/inventory/jest.config.js create mode 100644 packages/inventory/package.json create mode 100644 packages/inventory/src/config.ts create mode 100644 packages/inventory/src/index.js create mode 100644 packages/inventory/src/loaders/connection.ts create mode 100644 packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts create mode 100644 packages/inventory/src/models/index.ts create mode 100644 packages/inventory/src/models/inventory-item.ts create mode 100644 packages/inventory/src/models/inventory-level.ts create mode 100644 packages/inventory/src/models/reservation-item.ts create mode 100644 packages/inventory/src/services/index.ts create mode 100644 packages/inventory/src/services/inventory-item.ts create mode 100644 packages/inventory/src/services/inventory-level.ts create mode 100644 packages/inventory/src/services/inventory.ts create mode 100644 packages/inventory/src/services/reservation-item.ts create mode 100644 packages/inventory/tsconfig.json create mode 100644 packages/medusa/src/scripts/migrate-inventory-items.ts diff --git a/packages/inventory/.gitignore b/packages/inventory/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/inventory/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/inventory/.npmignore b/packages/inventory/.npmignore new file mode 100644 index 0000000000..4a2bc7f77c --- /dev/null +++ b/packages/inventory/.npmignore @@ -0,0 +1,10 @@ +src +.turbo +.prettierrc +.env +.babelrc.js +.eslintrc +.gitignore +ormconfig.json +tsconfig.json +jest.config.md diff --git a/packages/inventory/jest.config.js b/packages/inventory/jest.config.js new file mode 100644 index 0000000000..7de5bf104a --- /dev/null +++ b/packages/inventory/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + globals: { + "ts-jest": { + tsConfig: "tsconfig.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], +} diff --git a/packages/inventory/package.json b/packages/inventory/package.json new file mode 100644 index 0000000000..7ccfa6ab44 --- /dev/null +++ b/packages/inventory/package.json @@ -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" + } +} diff --git a/packages/inventory/src/config.ts b/packages/inventory/src/config.ts new file mode 100644 index 0000000000..0ff17abbc7 --- /dev/null +++ b/packages/inventory/src/config.ts @@ -0,0 +1 @@ +export const CONNECTION_NAME = "inventory_connection" diff --git a/packages/inventory/src/index.js b/packages/inventory/src/index.js new file mode 100644 index 0000000000..4086f2f220 --- /dev/null +++ b/packages/inventory/src/index.js @@ -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] diff --git a/packages/inventory/src/loaders/connection.ts b/packages/inventory/src/loaders/connection.ts new file mode 100644 index 0000000000..60fffca097 --- /dev/null +++ b/packages/inventory/src/loaders/connection.ts @@ -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 => { + 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) +} diff --git a/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts b/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts new file mode 100644 index 0000000000..0608bbcf9d --- /dev/null +++ b/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts @@ -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 { + 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 { + 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"; + `) + } +} diff --git a/packages/inventory/src/models/index.ts b/packages/inventory/src/models/index.ts new file mode 100644 index 0000000000..8a77e08089 --- /dev/null +++ b/packages/inventory/src/models/index.ts @@ -0,0 +1,3 @@ +export * from "./reservation-item" +export * from "./inventory-item" +export * from "./inventory-level" diff --git a/packages/inventory/src/models/inventory-item.ts b/packages/inventory/src/models/inventory-item.ts new file mode 100644 index 0000000000..ebb5401c33 --- /dev/null +++ b/packages/inventory/src/models/inventory-item.ts @@ -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 | null + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "iitem") + } +} diff --git a/packages/inventory/src/models/inventory-level.ts b/packages/inventory/src/models/inventory-level.ts new file mode 100644 index 0000000000..88c46bba36 --- /dev/null +++ b/packages/inventory/src/models/inventory-level.ts @@ -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 | null + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "ilev") + } +} diff --git a/packages/inventory/src/models/reservation-item.ts b/packages/inventory/src/models/reservation-item.ts new file mode 100644 index 0000000000..d2d46ced08 --- /dev/null +++ b/packages/inventory/src/models/reservation-item.ts @@ -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 | null + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "resitem") + } +} diff --git a/packages/inventory/src/services/index.ts b/packages/inventory/src/services/index.ts new file mode 100644 index 0000000000..cad75521c3 --- /dev/null +++ b/packages/inventory/src/services/index.ts @@ -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" diff --git a/packages/inventory/src/services/inventory-item.ts b/packages/inventory/src/services/inventory-item.ts new file mode 100644 index 0000000000..c8e4c9306f --- /dev/null +++ b/packages/inventory/src/services/inventory-item.ts @@ -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 = { relations: [], skip: 0, take: 10 } + ): Promise { + const queryBuilder = this.getListQuery(selector, config) + return await queryBuilder.getMany() + } + + private getListQuery( + selector: FilterableInventoryItemProps = {}, + config: FindConfig = { 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 = { 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 = {} + ): Promise { + 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 { + 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, + "id" | "created_at" | "metadata" | "deleted_at" + > + ): Promise { + 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 { + const manager = this.getManager() + const itemRepository = manager.getRepository(InventoryItem) + + await itemRepository.softRemove({ id: inventoryItemId }) + + await this.eventBusService_.emit(InventoryItemService.Events.DELETED, { + id: inventoryItemId, + }) + } +} diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts new file mode 100644 index 0000000000..8c48b08aaa --- /dev/null +++ b/packages/inventory/src/services/inventory-level.ts @@ -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 = { relations: [], skip: 0, take: 10 } + ): Promise { + 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 = { 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 = {} + ): Promise { + 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 { + 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, + "id" | "created_at" | "metadata" | "deleted_at" + > + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts new file mode 100644 index 0000000000..fc4e4aeb8b --- /dev/null +++ b/packages/inventory/src/services/inventory.ts @@ -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 = { 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 = { 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 = { + 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 + ): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + // 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 { + const availableQuantity = await this.retrieveAvailableQuantity( + inventoryItemId, + locationIds + ) + return availableQuantity >= quantity + } +} diff --git a/packages/inventory/src/services/reservation-item.ts b/packages/inventory/src/services/reservation-item.ts new file mode 100644 index 0000000000..6599277d42 --- /dev/null +++ b/packages/inventory/src/services/reservation-item.ts @@ -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 = { relations: [], skip: 0, take: 10 } + ): Promise { + 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 = { 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 = {} + ): Promise { + 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 { + 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 { + 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[] = [] + 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 { + await this.atomicPhase_(async (manager) => { + const itemRepository = manager.getRepository(ReservationItem) + + const items = await this.list({ line_item_id: lineItemId }) + + const ops: Promise[] = [] + 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 { + 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, + }) + } +} diff --git a/packages/inventory/tsconfig.json b/packages/inventory/tsconfig.json new file mode 100644 index 0000000000..0fc6130e78 --- /dev/null +++ b/packages/inventory/tsconfig.json @@ -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" + ] +} \ No newline at end of file diff --git a/packages/medusa/src/interfaces/services/inventory.ts b/packages/medusa/src/interfaces/services/inventory.ts index 5344390c7c..cfef370338 100644 --- a/packages/medusa/src/interfaces/services/inventory.ts +++ b/packages/medusa/src/interfaces/services/inventory.ts @@ -31,12 +31,12 @@ export interface IInventoryService { ): Promise<[InventoryLevelDTO[], number]> retrieveInventoryItem( - itemId: string, + inventoryItemId: string, config?: FindConfig ): Promise retrieveInventoryLevel( - itemId: string, + inventoryItemId: string, locationId: string ): Promise @@ -53,48 +53,56 @@ export interface IInventoryService { ): Promise updateInventoryLevel( - itemId: string, + inventoryItemId: string, locationId: string, update: UpdateInventoryLevelInput ): Promise updateInventoryItem( - itemId: string, + inventoryItemId: string, input: CreateInventoryItemInput ): Promise updateReservationItem( - reservationId: string, - update: UpdateReservationItemInput + reservationItemId: string, + input: UpdateReservationItemInput ): Promise deleteReservationItemsByLineItem(lineItemId: string): Promise - deleteReservationItem(id: string): Promise + deleteReservationItem(reservationItemId: string): Promise - deleteInventoryItem(itemId: string): Promise + deleteInventoryItem(inventoryItemId: string): Promise - deleteInventoryLevel(itemId: string, locationId: string): Promise + deleteInventoryLevel( + inventoryLevelId: string, + locationId: string + ): Promise adjustInventory( - itemId: string, + inventoryItemId: string, locationId: string, adjustment: number ): Promise confirmInventory( - itemId: string, + inventoryItemId: string, locationIds: string[], quantity: number ): Promise retrieveAvailableQuantity( - itemId: string, + inventoryItemId: string, locationIds: string[] ): Promise retrieveStockedQuantity( - itemId: string, + inventoryItemId: string, + locationIds: string[] + ): Promise + + retrieveReservedQuantity( + inventoryItemId: string, locationIds: string[] ): Promise } diff --git a/packages/medusa/src/scripts/migrate-inventory-items.ts b/packages/medusa/src/scripts/migrate-inventory-items.ts new file mode 100644 index 0000000000..2fec5b8e84 --- /dev/null +++ b/packages/medusa/src/scripts/migrate-inventory-items.ts @@ -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() }) diff --git a/packages/medusa/src/types/inventory.ts b/packages/medusa/src/types/inventory.ts index 87ea4e61ad..80950ce636 100644 --- a/packages/medusa/src/types/inventory.ts +++ b/packages/medusa/src/types/inventory.ts @@ -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 | 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 = { diff --git a/yarn.lock b/yarn.lock index e037de2214..de82dd4838 100644 --- a/yarn.lock +++ b/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"