From d859ccf55134a78cae09325eff5daec872fcc252 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Wed, 8 Feb 2023 17:23:47 -0300 Subject: [PATCH] Feat(medusa) - delete cascade modules associations (#3190) * delete cascade sales channel x locations, variant x inventory item --- .../inventory/inventory-items/index.js | 78 ++++++++++++ .../inventory/products/delete-variant.js | 111 ++++++++++++++++++ .../stock-location/delete-sales-channels.js | 85 ++++++++++++++ .../stock-location/delete-stock-location.js | 93 +++++++++++++++ .../stock-location/sales-channels.js | 94 +++++++++++++++ .../inventory-items/delete-inventory-item.ts | 8 ++ .../stock-locations/delete-stock-location.ts | 8 ++ .../routes/admin/variants/get-inventory.ts | 24 ++-- ...675689306130-multi_location_soft_delete.ts | 51 ++++++++ .../models/product-variant-inventory-item.ts | 25 ++-- packages/medusa/src/models/product-variant.ts | 15 +++ .../src/models/sales-channel-location.ts | 41 ++++++- packages/medusa/src/models/sales-channel.ts | 17 ++- .../src/services/product-variant-inventory.ts | 24 ++-- .../medusa/src/services/product-variant.ts | 2 +- .../src/services/sales-channel-inventory.ts | 6 +- .../src/services/sales-channel-location.ts | 47 +++++--- packages/medusa/src/services/sales-channel.ts | 6 +- 18 files changed, 672 insertions(+), 63 deletions(-) create mode 100644 integration-tests/plugins/__tests__/inventory/products/delete-variant.js create mode 100644 integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js create mode 100644 integration-tests/plugins/__tests__/stock-location/delete-stock-location.js create mode 100644 integration-tests/plugins/__tests__/stock-location/sales-channels.js create mode 100644 packages/medusa/src/migrations/1675689306130-multi_location_soft_delete.ts diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js index d5275a2d06..17fad1751a 100644 --- a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -362,5 +362,83 @@ describe("Inventory Items endpoints", () => { }), ]) }) + + it("When deleting an inventory item it removes the product variants associated to it", async () => { + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product-new", + variants: [], + }, + 5 + ) + + const response = await api.post( + `/admin/products/test-product-new/variants`, + { + title: "Test2", + sku: "MY_SKU2", + manage_inventory: true, + options: [ + { + option_id: "test-product-new-option", + value: "Blue", + }, + ], + prices: [{ currency_code: "usd", amount: 100 }], + }, + { headers: { Authorization: "Bearer test_token" } } + ) + + const secondVariantId = response.data.product.variants.find( + (v) => v.sku === "MY_SKU2" + ).id + + const inventoryService = appContainer.resolve("inventoryService") + const variantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const invItem2 = await inventoryService.createInventoryItem({ + sku: "123456", + }) + + await variantInventoryService.attachInventoryItem( + variantId, + invItem2.id, + 2 + ) + await variantInventoryService.attachInventoryItem( + secondVariantId, + invItem2.id, + 2 + ) + + expect( + await variantInventoryService.listInventoryItemsByVariant(variantId) + ).toHaveLength(2) + + expect( + await variantInventoryService.listInventoryItemsByVariant( + secondVariantId + ) + ).toHaveLength(2) + + await api.delete(`/admin/inventory-items/${invItem2.id}`, { + headers: { Authorization: "Bearer test_token" }, + }) + + expect( + await variantInventoryService.listInventoryItemsByVariant(variantId) + ).toHaveLength(1) + + expect( + await variantInventoryService.listInventoryItemsByVariant( + secondVariantId + ) + ).toHaveLength(1) + }) }) }) diff --git a/integration-tests/plugins/__tests__/inventory/products/delete-variant.js b/integration-tests/plugins/__tests__/inventory/products/delete-variant.js new file mode 100644 index 0000000000..e9d256328b --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/products/delete-variant.js @@ -0,0 +1,111 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") + +describe("Delete Variant", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Items", () => { + it("When deleting a product variant it removes the inventory items associated to it", async () => { + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [{ id: "test-variant" }], + }, + 100 + ) + + const response = await api.post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + { headers: { Authorization: "Bearer test_token" } } + ) + + const variantId = response.data.product.variants.find( + (v) => v.sku === "MY_SKU" + ).id + + const inventoryService = appContainer.resolve("inventoryService") + const variantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + const variantService = appContainer.resolve("productVariantService") + + const invItem2 = await inventoryService.createInventoryItem({ + sku: "123456", + }) + + await variantInventoryService.attachInventoryItem( + variantId, + invItem2.id, + 2 + ) + + expect( + await variantInventoryService.listInventoryItemsByVariant(variantId) + ).toHaveLength(2) + + await api.delete(`/admin/products/test-product/variants/${variantId}`, { + headers: { Authorization: "Bearer test_token" }, + }) + + await expect(variantService.retrieve(variantId)).rejects.toThrow( + `Variant with id: ${variantId} was not found` + ) + + expect( + await variantInventoryService.listInventoryItemsByVariant(variantId) + ).toHaveLength(0) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js b/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js new file mode 100644 index 0000000000..6b315434ee --- /dev/null +++ b/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js @@ -0,0 +1,85 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") +const { setPort, useApi } = require("../../../helpers/use-api") + +const adminSeeder = require("../../helpers/admin-seeder") + +jest.setTimeout(30000) + +describe("Sales channels", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Stock Locations", () => { + it("When deleting a sales channel, removes all associated locations with it", async () => { + await adminSeeder(dbConnection) + const api = useApi() + + const stockLocationService = appContainer.resolve("stockLocationService") + const salesChannelService = appContainer.resolve("salesChannelService") + const salesChannelLocationService = appContainer.resolve( + "salesChannelLocationService" + ) + + const loc = await stockLocationService.create({ + name: "warehouse", + }) + const loc2 = await stockLocationService.create({ + name: "other place", + }) + + const sc = await salesChannelService.create({ name: "channel test" }) + + await salesChannelLocationService.associateLocation(sc.id, loc.id) + await salesChannelLocationService.associateLocation(sc.id, loc2.id) + + expect(await salesChannelService.retrieve(sc.id)).toEqual( + expect.objectContaining({ + id: sc.id, + name: "channel test", + }) + ) + + expect( + await salesChannelLocationService.listLocations(sc.id) + ).toHaveLength(2) + + await api.delete(`/admin/sales-channels/${sc.id}`, { + headers: { Authorization: "Bearer test_token" }, + }) + + await expect(salesChannelService.retrieve(sc.id)).rejects.toThrowError() + + await expect( + salesChannelLocationService.listLocations(sc.id) + ).rejects.toThrowError() + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js b/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js new file mode 100644 index 0000000000..4ee1e9a164 --- /dev/null +++ b/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js @@ -0,0 +1,93 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") +const { setPort, useApi } = require("../../../helpers/use-api") + +const adminSeeder = require("../../helpers/admin-seeder") + +jest.setTimeout(30000) + +describe("Sales channels", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Stock Locations", () => { + it("When deleting a stock location, removes all associated sales channels with it", async () => { + await adminSeeder(dbConnection) + const api = useApi() + + const stockLocationService = appContainer.resolve("stockLocationService") + const salesChannelService = appContainer.resolve("salesChannelService") + const salesChannelLocationService = appContainer.resolve( + "salesChannelLocationService" + ) + + const loc = await stockLocationService.create({ + name: "warehouse", + }) + + const saleChannel = await salesChannelService.create({ + name: "channel test", + }) + + const otherChannel = await salesChannelService.create({ + name: "yet another channel", + }) + + await salesChannelLocationService.associateLocation( + saleChannel.id, + loc.id + ) + await salesChannelLocationService.associateLocation( + otherChannel.id, + loc.id + ) + + expect( + await salesChannelLocationService.listLocations(saleChannel.id) + ).toHaveLength(1) + + expect( + await salesChannelLocationService.listLocations(otherChannel.id) + ).toHaveLength(1) + + await api.delete(`/admin/stock-locations/${loc.id}`, { + headers: { Authorization: "Bearer test_token" }, + }) + + expect( + await salesChannelLocationService.listLocations(saleChannel.id) + ).toHaveLength(0) + + expect( + await salesChannelLocationService.listLocations(otherChannel.id) + ).toHaveLength(0) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/stock-location/sales-channels.js b/integration-tests/plugins/__tests__/stock-location/sales-channels.js new file mode 100644 index 0000000000..153664c256 --- /dev/null +++ b/integration-tests/plugins/__tests__/stock-location/sales-channels.js @@ -0,0 +1,94 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") +const { setPort, useApi } = require("../../../helpers/use-api") + +const adminSeeder = require("../../helpers/admin-seeder") + +jest.setTimeout(30000) + +describe("Sales channels", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Stock Locations", () => { + it("When listing a sales channel, it brings all associated locations with it", async () => { + await adminSeeder(dbConnection) + + const stockLocationService = appContainer.resolve("stockLocationService") + const salesChannelService = appContainer.resolve("salesChannelService") + const salesChannelLocationService = appContainer.resolve( + "salesChannelLocationService" + ) + + const loc = await stockLocationService.create({ + name: "warehouse", + }) + const loc2 = await stockLocationService.create({ + name: "other place", + }) + + const sc = await salesChannelService.create({ name: "channel test" }) + + await salesChannelLocationService.associateLocation(sc.id, loc.id) + await salesChannelLocationService.associateLocation(sc.id, loc2.id) + + expect( + await salesChannelLocationService.listLocations(sc.id) + ).toHaveLength(2) + + const [channels] = await salesChannelService.listAndCount( + {}, + { + relations: ["locations"], + } + ) + const createdSC = channels.find((c) => c.id === sc.id) + + expect(channels).toHaveLength(2) + expect(createdSC.locations).toHaveLength(2) + expect(createdSC).toEqual( + expect.objectContaining({ + id: sc.id, + name: "channel test", + locations: expect.arrayContaining([ + expect.objectContaining({ + sales_channel_id: sc.id, + location_id: loc.id, + }), + expect.objectContaining({ + sales_channel_id: sc.id, + location_id: loc2.id, + }), + ]), + }) + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts b/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts index 9d6e807235..a8371d526b 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express" import { EntityManager } from "typeorm" import { IInventoryService } from "../../../../interfaces" +import { ProductVariantInventoryService } from "../../../../services" /** * @oas [delete] /inventory-items/{id} @@ -47,8 +48,15 @@ export default async (req: Request, res: Response) => { const inventoryService: IInventoryService = req.scope.resolve("inventoryService") + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") + const manager: EntityManager = req.scope.resolve("manager") await manager.transaction(async (transactionManager) => { + await productVariantInventoryService + .withTransaction(transactionManager) + .detachInventoryItem(id) + await inventoryService .withTransaction(transactionManager) .deleteInventoryItem(id) diff --git a/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts b/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts index 662b7c05e4..8a9bad21a1 100644 --- a/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts +++ b/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts @@ -1,5 +1,6 @@ import { EntityManager } from "typeorm" import { IStockLocationService } from "../../../../interfaces" +import { SalesChannelLocationService } from "../../../../services" /** * @oas [delete] /stock-locations/{id} @@ -59,8 +60,15 @@ export default async (req, res) => { "stockLocationService" ) + const salesChannelLocationService: SalesChannelLocationService = + req.scope.resolve("salesChannelLocationService") + const manager: EntityManager = req.scope.resolve("manager") await manager.transaction(async (transactionManager) => { + await salesChannelLocationService + .withTransaction(transactionManager) + .removeLocation(id) + await stockLocationService.withTransaction(transactionManager).delete(id) }) diff --git a/packages/medusa/src/api/routes/admin/variants/get-inventory.ts b/packages/medusa/src/api/routes/admin/variants/get-inventory.ts index 0f842b9282..440be75ca1 100644 --- a/packages/medusa/src/api/routes/admin/variants/get-inventory.ts +++ b/packages/medusa/src/api/routes/admin/variants/get-inventory.ts @@ -72,9 +72,7 @@ export default async (req, res) => { const inventoryService: IInventoryService = req.scope.resolve("inventoryService") - const channelLocationService: SalesChannelLocationService = req.scope.resolve( - "salesChannelLocationService" - ) + const channelService: SalesChannelService = req.scope.resolve( "salesChannelService" ) @@ -93,15 +91,11 @@ export default async (req, res) => { sales_channel_availability: [], } - const [rawChannels] = await channelService.listAndCount({}) - const channels: SalesChannelDTO[] = await Promise.all( - rawChannels.map(async (channel) => { - const locations = await channelLocationService.listLocations(channel.id) - return { - ...channel, - locations, - } - }) + const [channels] = await channelService.listAndCount( + {}, + { + relations: ["locations"], + } ) const inventory = @@ -122,7 +116,7 @@ export default async (req, res) => { const quantity = await inventoryService.retrieveAvailableQuantity( inventory[0].id, - channel.locations + channel.locations.map((loc) => loc.id) ) return { @@ -139,10 +133,6 @@ export default async (req, res) => { }) } -type SalesChannelDTO = Omit & { - locations: string[] -} - type ResponseInventoryItem = Partial & { location_levels?: InventoryLevelDTO[] } diff --git a/packages/medusa/src/migrations/1675689306130-multi_location_soft_delete.ts b/packages/medusa/src/migrations/1675689306130-multi_location_soft_delete.ts new file mode 100644 index 0000000000..7f1643cf77 --- /dev/null +++ b/packages/medusa/src/migrations/1675689306130-multi_location_soft_delete.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class multiLocationSoftDelete1675689306130 + implements MigrationInterface +{ + name = "multiLocationSoftDelete1675689306130" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE sales_channel_location + ADD COLUMN "deleted_at" TIMESTAMP WITH TIME ZONE; + + DROP INDEX "IDX_6caaa358f12ed0b846f00e2dcd"; + DROP INDEX "IDX_c2203162ca946a71aeb98390b0"; + + CREATE INDEX "IDX_sales_channel_location_sales_channel_id" ON "sales_channel_location" ("sales_channel_id") WHERE deleted_at IS NULL; + CREATE INDEX "IDX_sales_channel_location_location_id" ON "sales_channel_location" ("location_id") WHERE deleted_at IS NULL; + + ALTER TABLE product_variant_inventory_item + ADD COLUMN "deleted_at" TIMESTAMP WITH TIME ZONE; + + DROP INDEX "IDX_c74e8c2835094a37dead376a3b"; + DROP INDEX "IDX_bf5386e7f2acc460adbf96d6f3"; + + CREATE INDEX "IDX_product_variant_inventory_item_inventory_item_id" ON "product_variant_inventory_item" ("inventory_item_id") WHERE deleted_at IS NULL; + CREATE INDEX "IDX_product_variant_inventory_item_variant_id" ON "product_variant_inventory_item" ("variant_id") WHERE deleted_at IS NULL; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "IDX_sales_channel_location_sales_channel_id"; + DROP INDEX "IDX_sales_channel_location_location_id"; + + DROP INDEX "IDX_product_variant_inventory_item_inventory_item_id"; + DROP INDEX "IDX_product_variant_inventory_item_variant_id"; + + CREATE INDEX "IDX_6caaa358f12ed0b846f00e2dcd" ON "sales_channel_location" ("sales_channel_id"); + CREATE INDEX "IDX_c2203162ca946a71aeb98390b0" ON "sales_channel_location" ("location_id"); + + CREATE INDEX "IDX_c74e8c2835094a37dead376a3b" ON "product_variant_inventory_item" ("inventory_item_id"); + CREATE INDEX "IDX_bf5386e7f2acc460adbf96d6f3" ON "product_variant_inventory_item" ("variant_id"); + + ALTER TABLE sales_channel_location + DROP COLUMN "deleted_at"; + + ALTER TABLE product_variant_inventory_item + DROP COLUMN "deleted_at"; + `) + } +} diff --git a/packages/medusa/src/models/product-variant-inventory-item.ts b/packages/medusa/src/models/product-variant-inventory-item.ts index e351e34067..5b0343df7e 100644 --- a/packages/medusa/src/models/product-variant-inventory-item.ts +++ b/packages/medusa/src/models/product-variant-inventory-item.ts @@ -1,18 +1,29 @@ -import { Index, Unique, BeforeInsert, Column, Entity } from "typeorm" -import { BaseEntity } from "../interfaces/models/base-entity" -import { DbAwareColumn, generateEntityId } from "../utils" +import { + Index, + BeforeInsert, + Column, + Entity, + ManyToOne, + JoinColumn, +} from "typeorm" +import { SoftDeletableEntity } from "../interfaces" +import { generateEntityId } from "../utils" +import { ProductVariant } from "./product-variant" @Entity() -@Unique(["variant_id", "inventory_item_id"]) -export class ProductVariantInventoryItem extends BaseEntity { +export class ProductVariantInventoryItem extends SoftDeletableEntity { @Index() - @DbAwareColumn({ type: "text" }) + @Column({ type: "text" }) inventory_item_id: string @Index() - @DbAwareColumn({ type: "text" }) + @Column({ type: "text" }) variant_id: string + @ManyToOne(() => ProductVariant, (variant) => variant.inventory_items) + @JoinColumn({ name: "variant_id" }) + variant: ProductVariant + @Column({ type: "int", default: 1 }) required_quantity: number diff --git a/packages/medusa/src/models/product-variant.ts b/packages/medusa/src/models/product-variant.ts index c8a2221dec..6adda04782 100644 --- a/packages/medusa/src/models/product-variant.ts +++ b/packages/medusa/src/models/product-variant.ts @@ -14,6 +14,7 @@ import { Product } from "./product" import { ProductOptionValue } from "./product-option-value" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" import { generateEntityId } from "../utils/generate-entity-id" +import { ProductVariantInventoryItem } from "./product-variant-inventory-item" @Entity() export class ProductVariant extends SoftDeletableEntity { @@ -91,6 +92,15 @@ export class ProductVariant extends SoftDeletableEntity { }) options: ProductOptionValue[] + @OneToMany( + () => ProductVariantInventoryItem, + (inventoryItem) => inventoryItem.variant, + { + cascade: ["soft-remove", "remove"], + } + ) + inventory_items: ProductVariantInventoryItem[] + @DbAwareColumn({ type: "jsonb", nullable: true }) metadata: Record @@ -199,6 +209,11 @@ export class ProductVariant extends SoftDeletableEntity { * type: array * items: * $ref: "#/components/schemas/ProductOptionValue" + * inventory_items: + * description: The Inventory Items related to the product variant. Available if the relation `inventory_items` is expanded. + * type: array + * items: + * $ref: "#/components/schemas/ProductVariantInventoryItem" * created_at: * type: string * description: "The date with timezone at which the resource was created." diff --git a/packages/medusa/src/models/sales-channel-location.ts b/packages/medusa/src/models/sales-channel-location.ts index 7c127e4165..b866b9e044 100644 --- a/packages/medusa/src/models/sales-channel-location.ts +++ b/packages/medusa/src/models/sales-channel-location.ts @@ -1,11 +1,12 @@ -import { BeforeInsert, Index, Column } from "typeorm" +import { BeforeInsert, Index, Column, ManyToOne, JoinColumn } from "typeorm" import { FeatureFlagEntity } from "../utils/feature-flag-decorators" -import { BaseEntity } from "../interfaces" +import { SoftDeletableEntity } from "../interfaces" import { generateEntityId } from "../utils" +import { SalesChannel } from "./sales-channel" @FeatureFlagEntity("sales_channels") -export class SalesChannelLocation extends BaseEntity { +export class SalesChannelLocation extends SoftDeletableEntity { @Index() @Column({ type: "text" }) sales_channel_id: string @@ -14,8 +15,42 @@ export class SalesChannelLocation extends BaseEntity { @Column({ type: "text" }) location_id: string + @ManyToOne(() => SalesChannel, (sc) => sc.locations) + @JoinColumn({ name: "sales_channel_id" }) + sales_channel: SalesChannel + @BeforeInsert() private beforeInsert(): void { this.id = generateEntityId(this.id, "scloc") } } + +/** + * @schema SalesChannelLocation + * title: "Sales Channel Stock Location" + * description: "Sales Channel Stock Location link sales channels with stock locations." + * type: object + * properties: + * id: + * type: string + * description: The Sales Channel Stock Location's ID + * example: scloc_01G8X9A7ESKAJXG2H0E6F1MW7A + * sales_channel_id: + * description: "The id of the Sales Channel" + * type: string + * location_id: + * description: "The id of the Location Stock." + * type: string + * created_at: + * type: string + * description: "The date with timezone at which the resource was created." + * format: date-time + * updated_at: + * type: string + * description: "The date with timezone at which the resource was updated." + * format: date-time + * deleted_at: + * type: string + * description: "The date with timezone at which the resource was deleted." + * format: date-time + */ diff --git a/packages/medusa/src/models/sales-channel.ts b/packages/medusa/src/models/sales-channel.ts index 4d9250deec..1b05f84a7c 100644 --- a/packages/medusa/src/models/sales-channel.ts +++ b/packages/medusa/src/models/sales-channel.ts @@ -1,8 +1,9 @@ -import { BeforeInsert, Column } from "typeorm" +import { BeforeInsert, Column, OneToMany } from "typeorm" import { FeatureFlagEntity } from "../utils/feature-flag-decorators" import { SoftDeletableEntity } from "../interfaces" import { generateEntityId } from "../utils" +import { SalesChannelLocation } from "./sales-channel-location" @FeatureFlagEntity("sales_channels") export class SalesChannel extends SoftDeletableEntity { @@ -15,6 +16,15 @@ export class SalesChannel extends SoftDeletableEntity { @Column({ default: false }) is_disabled: boolean + @OneToMany( + () => SalesChannelLocation, + (scLocation) => scLocation.sales_channel, + { + cascade: ["soft-remove", "remove"], + } + ) + locations: SalesChannelLocation[] + @BeforeInsert() private beforeInsert(): void { this.id = generateEntityId(this.id, "sc") @@ -45,6 +55,11 @@ export class SalesChannel extends SoftDeletableEntity { * description: "Specify if the sales channel is enabled or disabled." * type: boolean * default: false + * locations: + * description: The Stock Locations related to the sales channel. Available if the relation `locations` is expanded. + * type: array + * items: + * $ref: "#/components/schemas/SalesChannelLocation" * created_at: * type: string * description: "The date with timezone at which the resource was created." diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index ac9554c0e3..0bebf60fda 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -311,12 +311,12 @@ class ProductVariantInventoryService extends TransactionBaseService { /** * Remove a variant from an inventory item - * @param variantId variant id + * @param variantId variant id or undefined if all the variants will be affected * @param inventoryItemId inventory item id */ async detachInventoryItem( - variantId: string, - inventoryItemId: string + inventoryItemId: string, + variantId?: string ): Promise { const manager = this.transactionManager_ || this.manager_ @@ -324,15 +324,19 @@ class ProductVariantInventoryService extends TransactionBaseService { ProductVariantInventoryItem ) - const existing = await variantInventoryRepo.findOne({ - where: { - variant_id: variantId, - inventory_item_id: inventoryItemId, - }, + const where: any = { + inventory_item_id: inventoryItemId, + } + if (variantId) { + where.variant_id = variantId + } + + const varInvItems = await variantInventoryRepo.find({ + where, }) - if (existing) { - await variantInventoryRepo.remove(existing) + if (varInvItems.length) { + await variantInventoryRepo.remove(varInvItems) } } diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index d6232d65ee..0c2e0140c2 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -655,7 +655,7 @@ class ProductVariantService extends TransactionBaseService { const variant = await variantRepo.findOne({ where: { id: variantId }, - relations: ["prices", "options"], + relations: ["prices", "options", "inventory_items"], }) if (!variant) { diff --git a/packages/medusa/src/services/sales-channel-inventory.ts b/packages/medusa/src/services/sales-channel-inventory.ts index 8351c87195..12be70583d 100644 --- a/packages/medusa/src/services/sales-channel-inventory.ts +++ b/packages/medusa/src/services/sales-channel-inventory.ts @@ -33,19 +33,19 @@ class SalesChannelInventoryService { /** * Retrieves the available quantity of an item across all sales channel locations * @param salesChannelId Sales channel id - * @param itemId Item id + * @param inventoryItemId Item id * @returns available quantity of item across all sales channel locations */ async retrieveAvailableItemQuantity( salesChannelId: string, - itemId: string + inventoryItemId: string ): Promise { const locations = await this.salesChannelLocationService_.listLocations( salesChannelId ) return await this.inventoryService_.retrieveAvailableQuantity( - itemId, + inventoryItemId, locations ) } diff --git a/packages/medusa/src/services/sales-channel-location.ts b/packages/medusa/src/services/sales-channel-location.ts index 153f48d28c..d0dd7a45b9 100644 --- a/packages/medusa/src/services/sales-channel-location.ts +++ b/packages/medusa/src/services/sales-channel-location.ts @@ -2,7 +2,7 @@ import { EntityManager } from "typeorm" import { IStockLocationService, TransactionBaseService } from "../interfaces" import { SalesChannelService, EventBusService } from "./" -import { SalesChannelLocation } from "../models" +import { SalesChannelLocation } from "../models/sales-channel-location" type InjectedDependencies = { stockLocationService: IStockLocationService @@ -40,26 +40,39 @@ class SalesChannelLocationService extends TransactionBaseService { /** * Removes an association between a sales channel and a stock location. - * @param {string} salesChannelId - The ID of the sales channel. - * @param {string} locationId - The ID of the stock location. - * @returns {Promise} A promise that resolves when the association has been removed. + * @param salesChannelId - The ID of the sales channel or undefined if all the sales channel will be affected. + * @param locationId - The ID of the stock location. + * @returns A promise that resolves when the association has been removed. */ async removeLocation( - salesChannelId: string, - locationId: string + locationId: string, + salesChannelId?: string ): Promise { const manager = this.transactionManager_ || this.manager_ - await manager.delete(SalesChannelLocation, { - sales_channel_id: salesChannelId, + + const salesChannelLocationRepo = manager.getRepository(SalesChannelLocation) + + const where: any = { location_id: locationId, + } + if (salesChannelId) { + where.sales_channel_id = salesChannelId + } + + const scLoc = await salesChannelLocationRepo.find({ + where, }) + + if (scLoc.length) { + await salesChannelLocationRepo.remove(scLoc) + } } /** * Associates a sales channel with a stock location. - * @param {string} salesChannelId - The ID of the sales channel. - * @param {string} locationId - The ID of the stock location. - * @returns {Promise} A promise that resolves when the association has been created. + * @param salesChannelId - The ID of the sales channel. + * @param locationId - The ID of the stock location. + * @returns A promise that resolves when the association has been created. */ async associateLocation( salesChannelId: string, @@ -70,16 +83,14 @@ class SalesChannelLocationService extends TransactionBaseService { .withTransaction(manager) .retrieve(salesChannelId) - const stockLocationId = locationId - if (this.stockLocationService) { - const stockLocation = await this.stockLocationService.retrieve(locationId) - locationId = stockLocation.id + // trhows error if not found + await this.stockLocationService.retrieve(locationId) } const salesChannelLocation = manager.create(SalesChannelLocation, { sales_channel_id: salesChannel.id, - location_id: stockLocationId, + location_id: locationId, }) await manager.save(salesChannelLocation) @@ -87,8 +98,8 @@ class SalesChannelLocationService extends TransactionBaseService { /** * Lists the stock locations associated with a sales channel. - * @param {string} salesChannelId - The ID of the sales channel. - * @returns {Promise} A promise that resolves with an array of location IDs. + * @param salesChannelId - The ID of the sales channel. + * @returns A promise that resolves with an array of location IDs. */ async listLocations(salesChannelId: string): Promise { const manager = this.transactionManager_ || this.manager_ diff --git a/packages/medusa/src/services/sales-channel.ts b/packages/medusa/src/services/sales-channel.ts index d25c71cbfe..a92fccc29d 100644 --- a/packages/medusa/src/services/sales-channel.ts +++ b/packages/medusa/src/services/sales-channel.ts @@ -230,9 +230,9 @@ class SalesChannelService extends TransactionBaseService { this.salesChannelRepository_ ) - const salesChannel = await this.retrieve(salesChannelId).catch( - () => void 0 - ) + const salesChannel = await this.retrieve(salesChannelId, { + relations: ["locations"], + }).catch(() => void 0) if (!salesChannel) { return