From 16716f5a4f94cb6bc1dcea278d1789da760f2767 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 9 Jan 2023 14:44:34 +0100 Subject: [PATCH] feat(medusa): Create fulfillment with location (#2931) * remove duplicate key from oas * changeset * initial suggestion for adding locations to fulfillments * update migration * re-add functionality for removing entire reservations * fix tests * add location when adjusting reserved inventory of a line_item * add changest * handle multiple reservations for a product in the same channel * confirm inventory in stock location previous to creating the fulfillment * fix tests after updating create-fulfillment to confirm inventory prior to creating fulfillment * remove bugged code * initial validation * initial changes for review * chekcpoint * update validate inventory at location * redo some unwanted changes * typing * update snapshots * redo change for eslintrc * add eslint disable * re-order methods in interface * assert no_notification * iterate one time less * add test for validation of correct inventory adjustments in case of no inventory service installation * ensure correct adjustments for order cancellations * remove comment * fix tests * fix but with coalescing * remove location id from confirm inventory * don't throw when adjusting reservations for a line item without reservations * move reservation adjustments to the api * add multiplication for updating a reservation quantity * move inventory adjustments from the service layer to the api * delete reservation if quantity is adjusted to 0 * rename updateReservation to updateReservationItem * update dto fields * reference the correct fields * update with transaction * add jsdocs * force boolean cast * context-ize cancel and create fulfillment transaction methods * undo notification cast * update with changes * refactor withTransaction to variable * use maps * fix service mocks --- .changeset/moody-eyes-judge.md | 5 + .../api/__tests__/admin/order/order.js | 106 +++++++++ .../__snapshots__/index.js.snap | 4 + .../routes/admin/orders/cancel-fulfillment.ts | 41 +++- .../routes/admin/orders/create-fulfillment.ts | 76 ++++++- .../src/interfaces/services/inventory.ts | 6 + .../1671711415179-multi_location.ts | 6 + packages/medusa/src/models/fulfillment.ts | 7 + .../medusa/src/services/__mocks__/order.js | 1 + .../__mocks__/product-variant-inventory.js | 23 +- .../src/services/__tests__/fulfillment.js | 9 +- .../medusa/src/services/__tests__/order.js | 43 +++- packages/medusa/src/services/fulfillment.ts | 43 ++-- packages/medusa/src/services/order.ts | 28 ++- .../src/services/product-variant-inventory.ts | 215 +++++++++++++++--- packages/medusa/src/types/inventory.ts | 19 +- 16 files changed, 546 insertions(+), 86 deletions(-) create mode 100644 .changeset/moody-eyes-judge.md diff --git a/.changeset/moody-eyes-judge.md b/.changeset/moody-eyes-judge.md new file mode 100644 index 0000000000..3fd4ee89d6 --- /dev/null +++ b/.changeset/moody-eyes-judge.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +Multi Warehouse: Add locations for fulfillments diff --git a/integration-tests/api/__tests__/admin/order/order.js b/integration-tests/api/__tests__/admin/order/order.js index 710467b307..79a0f4e9bb 100644 --- a/integration-tests/api/__tests__/admin/order/order.js +++ b/integration-tests/api/__tests__/admin/order/order.js @@ -5,6 +5,7 @@ const { LineItem, CustomShippingOption, ShippingMethod, + Fulfillment, } = require("@medusajs/medusa") const idMap = require("medusa-test-utils/src/id-map").default @@ -203,6 +204,83 @@ describe("/admin/orders", () => { }) await manager.save(li2) + const order3 = manager.create(Order, { + id: "test-order-not-payed-with-fulfillment", + customer_id: "test-customer", + email: "test@email.com", + fulfillment_status: "not_fulfilled", + payment_status: "awaiting", + billing_address: { + id: "test-billing-address", + first_name: "lebron", + }, + shipping_address: { + id: "test-shipping-address", + first_name: "lebron", + country_code: "us", + }, + region_id: "test-region", + currency_code: "usd", + tax_rate: 0, + discounts: [ + { + id: "test-discount", + code: "TEST134", + is_dynamic: false, + rule: { + id: "test-rule", + description: "Test Discount", + type: "percentage", + value: 10, + allocation: "total", + }, + is_disabled: false, + regions: [ + { + id: "test-region", + }, + ], + }, + ], + payments: [ + { + id: "test-payment", + amount: 10000, + currency_code: "usd", + amount_refunded: 0, + provider_id: "test-pay", + data: {}, + }, + ], + items: [], + }) + + await manager.save(order3) + + const li3 = manager.create(LineItem, { + id: "test-item-ful", + fulfilled_quantity: 1, + returned_quantity: 0, + title: "Line Item", + description: "Line Item Desc", + thumbnail: "https://test.js/1234", + unit_price: 8000, + quantity: 2, + variant_id: "test-variant", + order_id: "test-order-not-payed-with-fulfillment", + }) + + await manager.save(li3) + + const ful1 = manager.create(Fulfillment, { + id: "ful-1", + order_id: "test-order-not-payed-with-fulfillment", + provider_id: "test-ful", + items: [{ item_id: "test-item-ful", quantity: 1 }], + data: {}, + }) + + await manager.save(ful1) }) afterEach(async () => { @@ -229,6 +307,34 @@ describe("/admin/orders", () => { expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(2) }) + it("cancels a fulfillment and then an order and increments inventory_quantity correctly", async () => { + const api = useApi() + + const initialInventoryRes = await api.get("/store/variants/test-variant") + + expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1) + + const cancelRes = await api.post( + `/admin/orders/test-order-not-payed-with-fulfillment/fulfillments/ful-1/cancel`, + {}, + adminReqConfig + ) + + expect(cancelRes.status).toEqual(200) + + const response = await api.post( + `/admin/orders/test-order-not-payed-with-fulfillment/cancel`, + {}, + adminReqConfig + ) + + expect(response.status).toEqual(200) + + const secondInventoryRes = await api.get("/store/variants/test-variant") + + expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(3) + }) + it("cancels an order but does not increment inventory_quantity of unmanaged variant", async () => { const api = useApi() const manager = dbConnection.manager diff --git a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap index 850d4f39ca..c3ee82a6ed 100644 --- a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap +++ b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap @@ -152,6 +152,7 @@ Object { "quantity": 1, }, ], + "location_id": null, "metadata": Object {}, "no_notification": null, "order_id": null, @@ -1198,6 +1199,7 @@ Object { "quantity": 2, }, ], + "location_id": null, "metadata": Object {}, "no_notification": null, "order_id": Any, @@ -1254,6 +1256,7 @@ Object { "quantity": 2, }, ], + "location_id": null, "metadata": Object {}, "no_notification": null, "order_id": Any, @@ -1931,6 +1934,7 @@ Object { "quantity": 1, }, ], + "location_id": null, "metadata": Object {}, "no_notification": null, "order_id": null, diff --git a/packages/medusa/src/api/routes/admin/orders/cancel-fulfillment.ts b/packages/medusa/src/api/routes/admin/orders/cancel-fulfillment.ts index f8d877c789..cf8dc9a0ad 100644 --- a/packages/medusa/src/api/routes/admin/orders/cancel-fulfillment.ts +++ b/packages/medusa/src/api/routes/admin/orders/cancel-fulfillment.ts @@ -1,8 +1,13 @@ -import { FulfillmentService, OrderService } from "../../../../services" +import { + FulfillmentService, + OrderService, + ProductVariantInventoryService, +} from "../../../../services" import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "." import { EntityManager } from "typeorm" import { MedusaError } from "medusa-core-utils" +import { Fulfillment } from "../../../../models" /** * @oas [post] /orders/{id}/fulfillments/{fulfillment_id}/cancel @@ -61,6 +66,9 @@ export default async (req, res) => { const { id, fulfillment_id } = req.params const orderService: OrderService = req.scope.resolve("orderService") + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") + const fulfillmentService: FulfillmentService = req.scope.resolve("fulfillmentService") @@ -75,9 +83,18 @@ export default async (req, res) => { const manager: EntityManager = req.scope.resolve("manager") await manager.transaction(async (transactionManager) => { - return await orderService + await orderService .withTransaction(transactionManager) .cancelFulfillment(fulfillment_id) + + const fulfillment = await fulfillmentService + .withTransaction(transactionManager) + .retrieve(fulfillment_id, { relations: ["items", "items.item"] }) + + await adjustInventoryForCancelledFulfillment(fulfillment, { + productVariantInventoryService: + productVariantInventoryService.withTransaction(transactionManager), + }) }) const order = await orderService.retrieve(id, { @@ -87,3 +104,23 @@ export default async (req, res) => { res.json({ order }) } + +export const adjustInventoryForCancelledFulfillment = async ( + fulfillment: Fulfillment, + context: { + productVariantInventoryService: ProductVariantInventoryService + } +) => { + const { productVariantInventoryService } = context + await Promise.all( + fulfillment.items.map(async ({ item, quantity }) => { + if (item.variant_id) { + await productVariantInventoryService.adjustInventory( + item.variant_id, + fulfillment.location_id!, + quantity + ) + } + }) + ) +} diff --git a/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts index b661509de2..139e0478bc 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts +++ b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts @@ -12,9 +12,13 @@ import { Transform, Type } from "class-transformer" import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "." import { EntityManager } from "typeorm" -import { OrderService } from "../../../../services" +import { + OrderService, + ProductVariantInventoryService, +} from "../../../../services" import { validator } from "../../../../utils/validator" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" +import { Fulfillment, LineItem } from "../../../../models" /** * @oas [post] /orders/{id}/fulfillment @@ -98,15 +102,39 @@ export default async (req, res) => { ) const orderService: OrderService = req.scope.resolve("orderService") - + const pvInventoryService: ProductVariantInventoryService = req.scope.resolve( + "productVariantInventoryService" + ) const manager: EntityManager = req.scope.resolve("manager") await manager.transaction(async (transactionManager) => { - return await orderService + const { fulfillments: existingFulfillments } = await orderService + .withTransaction(transactionManager) + .retrieve(id, { + relations: ["fulfillments"], + }) + const existingFulfillmentMap = new Map( + existingFulfillments.map((fulfillment) => [fulfillment.id, fulfillment]) + ) + + const { fulfillments } = await orderService .withTransaction(transactionManager) .createFulfillment(id, validated.items, { metadata: validated.metadata, no_notification: validated.no_notification, }) + + const pvInventoryServiceTx = + pvInventoryService.withTransaction(transactionManager) + + if (validated.location_id) { + await updateInventoryAndReservations( + fulfillments.filter((f) => !existingFulfillmentMap[f.id]), + { + inventoryService: pvInventoryServiceTx, + locationId: validated.location_id, + } + ) + } }) const order = await orderService.retrieve(id, { @@ -117,6 +145,44 @@ export default async (req, res) => { res.json({ order }) } +const updateInventoryAndReservations = async ( + fulfillments: Fulfillment[], + context: { + inventoryService: ProductVariantInventoryService + locationId: string + } +) => { + const { inventoryService, locationId } = context + + fulfillments.map(async ({ items }) => { + await inventoryService.validateInventoryAtLocation( + items.map(({ item, quantity }) => ({ ...item, quantity } as LineItem)), + locationId + ) + + await Promise.all( + items.map(async ({ item, quantity }) => { + if (!item.variant_id) { + return + } + + await inventoryService.adjustReservationsQuantityByLineItem( + item.id, + item.variant_id, + locationId, + -quantity + ) + + await inventoryService.adjustInventory( + item.variant_id, + locationId, + -quantity + ) + }) + ) + }) +} + /** * @schema AdminPostOrdersOrderFulfillmentsReq * type: object @@ -150,6 +216,10 @@ export class AdminPostOrdersOrderFulfillmentsReq { @Type(() => Item) items: Item[] + @IsString() + @IsOptional() + location_id?: string + @IsBoolean() @IsOptional() @Transform(({ value }) => optionalBooleanMapper.get(value)) diff --git a/packages/medusa/src/interfaces/services/inventory.ts b/packages/medusa/src/interfaces/services/inventory.ts index b1be137bf5..5344390c7c 100644 --- a/packages/medusa/src/interfaces/services/inventory.ts +++ b/packages/medusa/src/interfaces/services/inventory.ts @@ -11,6 +11,7 @@ import { FilterableReservationItemProps, CreateInventoryLevelInput, UpdateInventoryLevelInput, + UpdateReservationItemInput, } from "../../types/inventory" export interface IInventoryService { @@ -62,6 +63,11 @@ export interface IInventoryService { input: CreateInventoryItemInput ): Promise + updateReservationItem( + reservationId: string, + update: UpdateReservationItemInput + ): Promise + deleteReservationItemsByLineItem(lineItemId: string): Promise deleteReservationItem(id: string): Promise diff --git a/packages/medusa/src/migrations/1671711415179-multi_location.ts b/packages/medusa/src/migrations/1671711415179-multi_location.ts index 7de1686884..f2f540040c 100644 --- a/packages/medusa/src/migrations/1671711415179-multi_location.ts +++ b/packages/medusa/src/migrations/1671711415179-multi_location.ts @@ -25,6 +25,9 @@ export class multiLocation1671711415179 implements MigrationInterface { await queryRunner.query( `ALTER TABLE "return" ADD "location_id" character varying` ) + await queryRunner.query( + `ALTER TABLE "fulfillment" ADD "location_id" character varying` + ) await queryRunner.query( `ALTER TABLE "store" ADD "default_location_id" character varying` ) @@ -34,6 +37,9 @@ export class multiLocation1671711415179 implements MigrationInterface { await queryRunner.query( `ALTER TABLE "store" DROP COLUMN "default_location_id"` ) + await queryRunner.query( + `ALTER TABLE "fulfillment" DROP COLUMN "location_id"` + ) await queryRunner.query(`ALTER TABLE "return" DROP COLUMN "location_id"`) await queryRunner.query(`DROP INDEX "IDX_bf5386e7f2acc460adbf96d6f3"`) await queryRunner.query(`DROP INDEX "IDX_c74e8c2835094a37dead376a3b"`) diff --git a/packages/medusa/src/models/fulfillment.ts b/packages/medusa/src/models/fulfillment.ts index be46d0e3d4..b5a6fc29e7 100644 --- a/packages/medusa/src/models/fulfillment.ts +++ b/packages/medusa/src/models/fulfillment.ts @@ -51,6 +51,9 @@ export class Fulfillment extends BaseEntity { @Column() provider_id: string + @Column({ nullable: true, type: "text" }) + location_id: string | null + @ManyToOne(() => FulfillmentProvider) @JoinColumn({ name: "provider_id" }) provider: FulfillmentProvider @@ -127,6 +130,10 @@ export class Fulfillment extends BaseEntity { * description: "The id of the Fulfillment Provider responsible for handling the fulfillment" * type: string * example: manual + * location_id: + * description: "The id of the stock location the fulfillment will be shipped from" + * type: string + * example: sloc_01G8TJSYT9M6AVS5N4EMNFS1EK * provider: * description: Available if the relation `provider` is expanded. * $ref: "#/components/schemas/FulfillmentProvider" diff --git a/packages/medusa/src/services/__mocks__/order.js b/packages/medusa/src/services/__mocks__/order.js index 3cb70ecd85..ed9118b818 100644 --- a/packages/medusa/src/services/__mocks__/order.js +++ b/packages/medusa/src/services/__mocks__/order.js @@ -44,6 +44,7 @@ export const orders = { regionid: IdMap.getId("testRegion"), currency_code: "USD", customerid: IdMap.getId("testCustomer"), + fulfillments: [], payment_method: { providerid: "default_provider", data: {}, diff --git a/packages/medusa/src/services/__mocks__/product-variant-inventory.js b/packages/medusa/src/services/__mocks__/product-variant-inventory.js index 290ab65023..2607804741 100644 --- a/packages/medusa/src/services/__mocks__/product-variant-inventory.js +++ b/packages/medusa/src/services/__mocks__/product-variant-inventory.js @@ -10,17 +10,22 @@ export const ProductVariantInventoryServiceMock = { confirmInventory: jest .fn() .mockImplementation((variantId, quantity, options) => { - if (quantity < 10) { - return true - } else { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Variant with id: ${variantId} does not have the required inventory` - ) - } + return quantity < 10 }), - releaseReservationsByLineItem: jest.fn().mockImplementation((lineItem) => {}), + adjustReservationsQuantityByLineItem: jest + .fn() + .mockImplementation((lineItem) => {}), + deleteReservationsByLineItem: jest.fn().mockImplementation((lineItem) => {}), reserveQuantity: jest .fn() .mockImplementation((variantId, quantity, options) => {}), + validateInventoryAtLocation: jest + .fn() + .mockImplementation((items, locationId) => {}), } + +const mock = jest.fn().mockImplementation(() => { + return ProductVariantInventoryServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/fulfillment.js b/packages/medusa/src/services/__tests__/fulfillment.js index 960426e38b..62eb4371e7 100644 --- a/packages/medusa/src/services/__tests__/fulfillment.js +++ b/packages/medusa/src/services/__tests__/fulfillment.js @@ -1,5 +1,6 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import FulfillmentService from "../fulfillment" +import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" describe("FulfillmentService", () => { describe("createFulfillment", () => { @@ -34,6 +35,7 @@ describe("FulfillmentService", () => { fulfillmentRepository, shippingProfileService, lineItemRepository, + productVariantInventoryService: ProductVariantInventoryServiceMock, }) beforeEach(async () => { @@ -51,12 +53,12 @@ describe("FulfillmentService", () => { }, }, ], - items: [{ id: IdMap.getId("test-line"), quantity: 10 }], + items: [{ id: IdMap.getId("test-line"), quantity: 9 }], }, [ { item_id: IdMap.getId("test-line"), - quantity: 10, + quantity: 9, }, ], { order_id: "test", metadata: {} } @@ -66,7 +68,7 @@ describe("FulfillmentService", () => { expect(fulfillmentRepository.create).toHaveBeenCalledWith({ order_id: "test", provider_id: "GLS Express", - items: [{ item_id: IdMap.getId("test-line"), quantity: 10 }], + items: [{ item_id: IdMap.getId("test-line"), quantity: 9 }], data: expect.anything(), metadata: {}, }) @@ -132,6 +134,7 @@ describe("FulfillmentService", () => { fulfillmentProviderService, fulfillmentRepository, lineItemService, + productVariantInventoryService: ProductVariantInventoryServiceMock, }) beforeEach(async () => { diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index a11c14f14a..041dd4078e 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -652,7 +652,9 @@ describe("OrderService", () => { fulfillment_status: "not_fulfilled", payment_status: "awaiting", status: "pending", - fulfillments: [{ id: "fulfillment_test", canceled_at: now }], + fulfillments: [ + { id: "fulfillment_test", canceled_at: now, items: [] }, + ], payments: [{ id: "payment_test" }], items: [ { id: "item_1", variant_id: "variant-1", quantity: 12 }, @@ -711,7 +713,7 @@ describe("OrderService", () => { payment_status: "canceled", canceled_at: expect.any(Date), status: "canceled", - fulfillments: [{ id: "fulfillment_test", canceled_at: now }], + fulfillments: [{ id: "fulfillment_test", canceled_at: now, items: [] }], payments: [{ id: "payment_test" }], items: [ { @@ -915,7 +917,8 @@ describe("OrderService", () => { quantity: 2, }, ], - { metadata: {}, order_id: "test-order" } + { metadata: {}, order_id: "test-order" }, + { location_id: undefined } ) expect(lineItemService.update).toHaveBeenCalledTimes(1) @@ -947,7 +950,8 @@ describe("OrderService", () => { quantity: 2, }, ], - { metadata: {}, order_id: "partial" } + { metadata: {}, order_id: "partial" }, + { location_id: undefined } ) expect(lineItemService.update).toHaveBeenCalledTimes(1) @@ -979,7 +983,8 @@ describe("OrderService", () => { quantity: 1, }, ], - { metadata: {}, order_id: "test" } + { metadata: {}, order_id: "test" }, + { location_id: undefined } ) expect(lineItemService.update).toHaveBeenCalledTimes(1) @@ -994,6 +999,34 @@ describe("OrderService", () => { }) }) + it("Calls createFulfillment with locationId", async () => { + await orderService.createFulfillment( + "test", + [ + { + item_id: "item_1", + quantity: 1, + }, + ], + { + location_id: "loc_1", + } + ) + + expect(fulfillmentService.createFulfillment).toHaveBeenCalledTimes(1) + expect(fulfillmentService.createFulfillment).toHaveBeenCalledWith( + order, + [ + { + item_id: "item_1", + quantity: 1, + }, + ], + { metadata: {}, order_id: "test", no_notification: undefined }, + { locationId: "loc_1" } + ) + }) + it("fails if order is canceled", async () => { await expect( orderService.createFulfillment("canceled", [ diff --git a/packages/medusa/src/services/fulfillment.ts b/packages/medusa/src/services/fulfillment.ts index a5659216c3..bfdc6d794f 100644 --- a/packages/medusa/src/services/fulfillment.ts +++ b/packages/medusa/src/services/fulfillment.ts @@ -1,6 +1,6 @@ import { isDefined, MedusaError } from "medusa-core-utils" import { EntityManager } from "typeorm" -import { ShippingProfileService } from "." +import { ProductVariantInventoryService, ShippingProfileService } from "." import { TransactionBaseService } from "../interfaces" import { Fulfillment, LineItem, ShippingMethod } from "../models" import { FulfillmentRepository } from "../repositories/fulfillment" @@ -27,6 +27,7 @@ type InjectedDependencies = { fulfillmentRepository: typeof FulfillmentRepository trackingLinkRepository: typeof TrackingLinkRepository lineItemRepository: typeof LineItemRepository + productVariantInventoryService: ProductVariantInventoryService } /** @@ -43,6 +44,8 @@ class FulfillmentService extends TransactionBaseService { protected readonly fulfillmentRepository_: typeof FulfillmentRepository protected readonly trackingLinkRepository_: typeof TrackingLinkRepository protected readonly lineItemRepository_: typeof LineItemRepository + // eslint-disable-next-line max-len + protected readonly productVariantInventoryService_: ProductVariantInventoryService constructor({ manager, @@ -53,6 +56,7 @@ class FulfillmentService extends TransactionBaseService { lineItemService, fulfillmentProviderService, lineItemRepository, + productVariantInventoryService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -66,6 +70,7 @@ class FulfillmentService extends TransactionBaseService { this.shippingProfileService_ = shippingProfileService this.lineItemService_ = lineItemService this.fulfillmentProviderService_ = fulfillmentProviderService + this.productVariantInventoryService_ = productVariantInventoryService } partitionItems_( @@ -73,6 +78,11 @@ class FulfillmentService extends TransactionBaseService { items: LineItem[] ): FulfillmentItemPartition[] { const partitioned: FulfillmentItemPartition[] = [] + + if (shippingMethods.length === 1) { + return [{ items, shipping_method: shippingMethods[0] }] + } + // partition order items to their dedicated shipping method for (const method of shippingMethods) { const temp: FulfillmentItemPartition = { @@ -82,15 +92,11 @@ class FulfillmentService extends TransactionBaseService { // for each method find the items in the order, that are associated // with the profile on the current shipping method - if (shippingMethods.length === 1) { - temp.items = items - } else { - const methodProfile = method.shipping_option.profile_id + const methodProfile = method.shipping_option.profile_id - temp.items = items.filter(({ variant }) => { - variant.product.profile_id === methodProfile - }) - } + temp.items = items.filter(({ variant }) => { + variant.product.profile_id === methodProfile + }) partitioned.push(temp) } return partitioned @@ -205,8 +211,10 @@ class FulfillmentService extends TransactionBaseService { async createFulfillment( order: CreateFulfillmentOrder, itemsToFulfill: FulFillmentItemType[], - custom: Partial = {} + custom: Partial = {}, + context: { locationId?: string } = {} ): Promise { + const { locationId } = context return await this.atomicPhase_(async (manager) => { const fulfillmentRepository = manager.getCustomRepository( this.fulfillmentRepository_ @@ -229,6 +237,7 @@ class FulfillmentService extends TransactionBaseService { provider_id: shipping_method.shipping_option.provider_id, items: items.map((i) => ({ item_id: i.id, quantity: i.quantity })), data: {}, + location_id: locationId, }) const result = await fulfillmentRepository.save(ful) @@ -283,13 +292,15 @@ class FulfillmentService extends TransactionBaseService { const lineItemServiceTx = this.lineItemService_.withTransaction(manager) - for (const fItem of fulfillment.items) { - const item = await lineItemServiceTx.retrieve(fItem.item_id) - const fulfilledQuantity = item.fulfilled_quantity! - fItem.quantity - await lineItemServiceTx.update(item.id, { - fulfilled_quantity: fulfilledQuantity, + await Promise.all( + fulfillment.items.map(async (fItem) => { + const item = await lineItemServiceTx.retrieve(fItem.item_id) + const fulfilledQuantity = item.fulfilled_quantity! - fItem.quantity + await lineItemServiceTx.update(item.id, { + fulfilled_quantity: fulfilledQuantity, + }) }) - } + ) const fulfillmentRepo = manager.getCustomRepository( this.fulfillmentRepository_ diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index 02b0a70026..487ef00c40 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -1144,13 +1144,24 @@ class OrderService extends TransactionBaseService { const inventoryServiceTx = this.productVariantInventoryService_.withTransaction(manager) + + const previouslyFulfilledQuantities = order.fulfillments.reduce( + (acc, f) => { + return f.items.reduce((acc, item) => { + acc[item.item_id] = (acc[item.item_id] || 0) + item.quantity + return acc + }, acc) + }, + {} + ) + await Promise.all( order.items.map(async (item) => { if (item.variant_id) { - return await inventoryServiceTx.releaseReservationsByLineItem( + return await inventoryServiceTx.deleteReservationsByLineItem( item.id, item.variant_id, - item.quantity + item.quantity - (previouslyFulfilledQuantities[item.id] || 0) ) } }) @@ -1295,13 +1306,11 @@ class OrderService extends TransactionBaseService { itemsToFulfill: FulFillmentItemType[], config: { no_notification?: boolean + location_id?: string metadata?: Record - } = { - no_notification: undefined, - metadata: {}, - } + } = {} ): Promise { - const { metadata, no_notification } = config + const { metadata, no_notification, location_id } = config return await this.atomicPhase_(async (manager) => { // NOTE: we are telling the service to calculate all totals for us which @@ -1354,9 +1363,12 @@ class OrderService extends TransactionBaseService { order as unknown as CreateFulfillmentOrder, itemsToFulfill, { - metadata, + metadata: metadata ?? {}, no_notification: no_notification, order_id: orderId, + }, + { + locationId: location_id, } ) let successfullyFulfilled: FulfillmentItem[] = [] diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index 7933176db8..8d6c03bcda 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -8,7 +8,7 @@ import { import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item" import { ProductVariantService, SalesChannelLocationService } from "./" import { InventoryItemDTO, ReserveQuantityContext } from "../types/inventory" -import { ProductVariant } from "../models" +import { LineItem, ProductVariant } from "../models" type InjectedDependencies = { manager: EntityManager @@ -117,6 +117,37 @@ class ProductVariantInventoryService extends TransactionBaseService { return hasInventory.every(Boolean) } + /** + * Retrieves a product variant inventory item by its inventory item ID and variant ID. + * + * @param inventoryItemId - The ID of the inventory item to retrieve. + * @param variantId - The ID of the variant to retrieve. + * @returns A promise that resolves with the product variant inventory item. + */ + async retrieve( + inventoryItemId: string, + variantId: string + ): Promise { + const manager = this.transactionManager_ || this.manager_ + + const variantInventoryRepo = manager.getRepository( + ProductVariantInventoryItem + ) + + const variantInventory = await variantInventoryRepo.findOne({ + where: { inventory_item_id: inventoryItemId, variant_id: variantId }, + }) + + if (!variantInventory) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Inventory item with id ${inventoryItemId} not found` + ) + } + + return variantInventory + } + /** * list registered inventory items * @param itemIds list inventory item ids @@ -142,7 +173,7 @@ class ProductVariantInventoryService extends TransactionBaseService { * @returns variant inventory items for the variant id */ private async listByVariant( - variantId: string + variantId: string | string[] ): Promise { const manager = this.transactionManager_ || this.manager_ @@ -150,8 +181,10 @@ class ProductVariantInventoryService extends TransactionBaseService { ProductVariantInventoryItem ) + const ids = Array.isArray(variantId) ? variantId : [variantId] + const variantInventory = await variantInventoryRepo.find({ - where: { variant_id: variantId }, + where: { variant_id: In(ids) }, }) return variantInventory @@ -298,15 +331,17 @@ class ProductVariantInventoryService extends TransactionBaseService { const manager = this.transactionManager_ || this.manager_ if (!this.inventoryService_) { - const variantServiceTx = - this.productVariantService_.withTransaction(manager) - const variant = await variantServiceTx.retrieve(variantId, { - select: ["id", "inventory_quantity"], + return this.atomicPhase_(async (manager) => { + const variantServiceTx = + this.productVariantService_.withTransaction(manager) + const variant = await variantServiceTx.retrieve(variantId, { + select: ["id", "inventory_quantity"], + }) + await variantServiceTx.update(variant.id, { + inventory_quantity: variant.inventory_quantity - quantity, + }) + return }) - await variantServiceTx.update(variant.id, { - inventory_quantity: variant.inventory_quantity - quantity, - }) - return } const toReserve = { @@ -342,7 +377,7 @@ class ProductVariantInventoryService extends TransactionBaseService { return await this.inventoryService_.createReservationItem({ ...toReserve, location_id: locationId as string, - item_id: inventoryPart.inventory_item_id, + inventory_item_id: inventoryPart.inventory_item_id, quantity: itemQuantity, }) }) @@ -350,31 +385,144 @@ class ProductVariantInventoryService extends TransactionBaseService { } /** - * Remove reservation of variant quantity + * Adjusts the quantity of reservations for a line item by a given amount. + * @param {string} lineItemId - The ID of the line item + * @param {string} variantId - The ID of the variant + * @param {string} locationId - The ID of the location to prefer adjusting quantities at + * @param {number} quantity - The amount to adjust the quantity by + */ + async adjustReservationsQuantityByLineItem( + lineItemId: string, + variantId: string, + locationId: string, + quantity: number + ): Promise { + if (!this.inventoryService_) { + return this.atomicPhase_(async (manager) => { + const variantServiceTx = + this.productVariantService_.withTransaction(manager) + const variant = await variantServiceTx.retrieve(variantId, { + select: ["id", "inventory_quantity", "manage_inventory"], + }) + + if (!variant.manage_inventory) { + return + } + + await variantServiceTx.update(variantId, { + inventory_quantity: variant.inventory_quantity - quantity, + }) + }) + } + const [reservations, reservationCount] = + await this.inventoryService_.listReservationItems( + { + line_item_id: lineItemId, + }, + { + order: { created_at: "DESC" }, + } + ) + + if (reservationCount) { + let reservation = reservations[0] + + reservation = + reservations.find( + (r) => r.location_id === locationId && r.quantity >= quantity + ) ?? reservation + + const productVariantInventory = await this.retrieve( + reservation.inventory_item_id, + variantId + ) + + const reservationQtyUpdate = + reservation.quantity - quantity * productVariantInventory.quantity + + if (reservationQtyUpdate === 0) { + await this.inventoryService_.deleteReservationItem(reservation.id) + } else { + await this.inventoryService_.updateReservationItem(reservation.id, { + quantity: reservationQtyUpdate, + }) + } + } + } + + /** + * Validate stock at a location for fulfillment items + * @param items Fulfillment Line items to validate quantities for + * @param locationId Location to validate stock at + * @returns nothing if successful, throws error if not + */ + async validateInventoryAtLocation(items: LineItem[], locationId: string) { + if (!this.inventoryService_) { + return + } + + const itemsToValidate = items.filter((item) => item.variant_id) + + for (const item of itemsToValidate) { + const pvInventoryItems = await this.listByVariant(item.variant_id!) + + const [inventoryLevels] = + await this.inventoryService_.listInventoryLevels({ + inventory_item_id: pvInventoryItems.map((i) => i.inventory_item_id), + location_id: locationId, + }) + + const pviMap: Map = new Map( + pvInventoryItems.map((pvi) => [pvi.inventory_item_id, pvi]) + ) + + for (const inventoryLevel of inventoryLevels) { + const pvInventoryItem = pviMap[inventoryLevel.inventory_item_id] + + if ( + !pvInventoryItem || + pvInventoryItem.quantity * item.quantity > + inventoryLevel.stocked_quantity + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Insufficient stock for item: ${item.title}` + ) + } + } + } + } + + /** + * delete a reservation of variant quantity * @param lineItemId line item id * @param variantId variant id * @param quantity quantity to release */ - async releaseReservationsByLineItem( + async deleteReservationsByLineItem( lineItemId: string, variantId: string, quantity: number ): Promise { if (!this.inventoryService_) { - const variant = await this.productVariantService_.retrieve(variantId, { - select: ["id", "inventory_quantity", "manage_inventory"], - }) + return this.atomicPhase_(async (manager) => { + const productVariantServiceTx = + this.productVariantService_.withTransaction(manager) + const variant = await productVariantServiceTx.retrieve(variantId, { + select: ["id", "inventory_quantity", "manage_inventory"], + }) - if (!variant.manage_inventory) { - return - } + if (!variant.manage_inventory) { + return + } - await this.productVariantService_.update(variantId, { - inventory_quantity: variant.inventory_quantity + quantity, + await productVariantServiceTx.update(variantId, { + inventory_quantity: variant.inventory_quantity + quantity, + }) }) - } else { - await this.inventoryService_.deleteReservationItemsByLineItem(lineItemId) } + + await this.inventoryService_.deleteReservationItemsByLineItem(lineItemId) } /** @@ -388,23 +536,22 @@ class ProductVariantInventoryService extends TransactionBaseService { locationId: string, quantity: number ): Promise { - const manager = this.transactionManager_ || this.manager_ if (!this.inventoryService_) { - const variant = await this.productVariantService_ - .withTransaction(manager) - .retrieve(variantId, { + return this.atomicPhase_(async (manager) => { + const productVariantServiceTx = + this.productVariantService_.withTransaction(manager) + const variant = await productVariantServiceTx.retrieve(variantId, { select: ["id", "inventory_quantity", "manage_inventory"], }) - if (!variant.manage_inventory) { - return - } + if (!variant.manage_inventory) { + return + } - await this.productVariantService_ - .withTransaction(manager) - .update(variantId, { + await productVariantServiceTx.update(variantId, { inventory_quantity: variant.inventory_quantity + quantity, }) + }) } else { const variantInventory = await this.listByVariant(variantId) diff --git a/packages/medusa/src/types/inventory.ts b/packages/medusa/src/types/inventory.ts index 5c35141588..87ea4e61ad 100644 --- a/packages/medusa/src/types/inventory.ts +++ b/packages/medusa/src/types/inventory.ts @@ -21,7 +21,8 @@ export type InventoryItemDTO = { export type ReservationItemDTO = { id: string location_id: string - item_id: string + inventory_item_id: string + quantity: number metadata: Record | null created_at: string | Date updated_at: string | Date @@ -30,7 +31,7 @@ export type ReservationItemDTO = { export type InventoryLevelDTO = { id: string - item_id: string + inventory_item_id: string location_id: string stocked_quantity: number incoming_quantity: number @@ -44,7 +45,7 @@ export type FilterableReservationItemProps = { id?: string | string[] type?: string | string[] line_item_id?: string | string[] - item_id?: string | string[] + inventory_item_id?: string | string[] location_id?: string | string[] quantity?: number | NumericalComparisonOperator } @@ -76,21 +77,21 @@ export type CreateInventoryItemInput = { export type CreateReservationItemInput = { type?: string line_item_id?: string - item_id: string + inventory_item_id: string location_id: string quantity: number metadata?: Record | null } export type FilterableInventoryLevelProps = { - item_id?: string | string[] + inventory_item_id?: string | string[] location_id?: string | string[] stocked_quantity?: number | NumericalComparisonOperator incoming_quantity?: number | NumericalComparisonOperator } export type CreateInventoryLevelInput = { - item_id: string + inventory_item_id: string location_id: string stocked_quantity: number incoming_quantity: number @@ -101,6 +102,12 @@ export type UpdateInventoryLevelInput = { incoming_quantity?: number } +export type UpdateReservationItemInput = { + quantity?: number + location_id?: string + metadata?: Record | null +} + export type ReserveQuantityContext = { locationId?: string lineItemId?: string