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
This commit is contained in:
Philip Korsholm authored and GitHub committed 2023-01-09 14:44:34 +01:00
1 parent 28bec599ae
commit 16716f5a4f
16 files changed
+546 -86

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
Multi Warehouse: Add locations for fulfillments
@@ -5,6 +5,7 @@ const {
LineItem, LineItem,
CustomShippingOption, CustomShippingOption,
ShippingMethod, ShippingMethod,
Fulfillment,
} = require("@medusajs/medusa") } = require("@medusajs/medusa")
const idMap = require("medusa-test-utils/src/id-map").default const idMap = require("medusa-test-utils/src/id-map").default
@@ -203,6 +204,83 @@ describe("/admin/orders", () => {
}) })
await manager.save(li2) 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 () => { afterEach(async () => {
@@ -229,6 +307,34 @@ describe("/admin/orders", () => {
expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(2) 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 () => { it("cancels an order but does not increment inventory_quantity of unmanaged variant", async () => {
const api = useApi() const api = useApi()
const manager = dbConnection.manager const manager = dbConnection.manager
@@ -152,6 +152,7 @@ Object {
"quantity": 1, "quantity": 1,
}, },
], ],
"location_id": null,
"metadata": Object {}, "metadata": Object {},
"no_notification": null, "no_notification": null,
"order_id": null, "order_id": null,
@@ -1198,6 +1199,7 @@ Object {
"quantity": 2, "quantity": 2,
}, },
], ],
"location_id": null,
"metadata": Object {}, "metadata": Object {},
"no_notification": null, "no_notification": null,
"order_id": Any<String>, "order_id": Any<String>,
@@ -1254,6 +1256,7 @@ Object {
"quantity": 2, "quantity": 2,
}, },
], ],
"location_id": null,
"metadata": Object {}, "metadata": Object {},
"no_notification": null, "no_notification": null,
"order_id": Any<String>, "order_id": Any<String>,
@@ -1931,6 +1934,7 @@ Object {
"quantity": 1, "quantity": 1,
}, },
], ],
"location_id": null,
"metadata": Object {}, "metadata": Object {},
"no_notification": null, "no_notification": null,
"order_id": null, "order_id": null,
@@ -1,8 +1,13 @@
import { FulfillmentService, OrderService } from "../../../../services" import {
FulfillmentService,
OrderService,
ProductVariantInventoryService,
} from "../../../../services"
import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "." import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "."
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { Fulfillment } from "../../../../models"
/** /**
* @oas [post] /orders/{id}/fulfillments/{fulfillment_id}/cancel * @oas [post] /orders/{id}/fulfillments/{fulfillment_id}/cancel
@@ -61,6 +66,9 @@ export default async (req, res) => {
const { id, fulfillment_id } = req.params const { id, fulfillment_id } = req.params
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
const productVariantInventoryService: ProductVariantInventoryService =
req.scope.resolve("productVariantInventoryService")
const fulfillmentService: FulfillmentService = const fulfillmentService: FulfillmentService =
req.scope.resolve("fulfillmentService") req.scope.resolve("fulfillmentService")
@@ -75,9 +83,18 @@ export default async (req, res) => {
const manager: EntityManager = req.scope.resolve("manager") const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => { await manager.transaction(async (transactionManager) => {
return await orderService await orderService
.withTransaction(transactionManager) .withTransaction(transactionManager)
.cancelFulfillment(fulfillment_id) .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, { const order = await orderService.retrieve(id, {
@@ -87,3 +104,23 @@ export default async (req, res) => {
res.json({ order }) 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
)
}
})
)
}
@@ -12,9 +12,13 @@ import { Transform, Type } from "class-transformer"
import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "." import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "."
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { OrderService } from "../../../../services" import {
OrderService,
ProductVariantInventoryService,
} from "../../../../services"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
import { Fulfillment, LineItem } from "../../../../models"
/** /**
* @oas [post] /orders/{id}/fulfillment * @oas [post] /orders/{id}/fulfillment
@@ -98,15 +102,39 @@ export default async (req, res) => {
) )
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
const pvInventoryService: ProductVariantInventoryService = req.scope.resolve(
"productVariantInventoryService"
)
const manager: EntityManager = req.scope.resolve("manager") const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => { 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) .withTransaction(transactionManager)
.createFulfillment(id, validated.items, { .createFulfillment(id, validated.items, {
metadata: validated.metadata, metadata: validated.metadata,
no_notification: validated.no_notification, 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, { const order = await orderService.retrieve(id, {
@@ -117,6 +145,44 @@ export default async (req, res) => {
res.json({ order }) 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 * @schema AdminPostOrdersOrderFulfillmentsReq
* type: object * type: object
@@ -150,6 +216,10 @@ export class AdminPostOrdersOrderFulfillmentsReq {
@Type(() => Item) @Type(() => Item)
items: Item[] items: Item[]
@IsString()
@IsOptional()
location_id?: string
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@Transform(({ value }) => optionalBooleanMapper.get(value)) @Transform(({ value }) => optionalBooleanMapper.get(value))
@@ -11,6 +11,7 @@ import {
FilterableReservationItemProps, FilterableReservationItemProps,
CreateInventoryLevelInput, CreateInventoryLevelInput,
UpdateInventoryLevelInput, UpdateInventoryLevelInput,
UpdateReservationItemInput,
} from "../../types/inventory" } from "../../types/inventory"
export interface IInventoryService { export interface IInventoryService {
@@ -62,6 +63,11 @@ export interface IInventoryService {
input: CreateInventoryItemInput input: CreateInventoryItemInput
): Promise<InventoryItemDTO> ): Promise<InventoryItemDTO>
updateReservationItem(
reservationId: string,
update: UpdateReservationItemInput
): Promise<ReservationItemDTO>
deleteReservationItemsByLineItem(lineItemId: string): Promise<void> deleteReservationItemsByLineItem(lineItemId: string): Promise<void>
deleteReservationItem(id: string): Promise<void> deleteReservationItem(id: string): Promise<void>
@@ -25,6 +25,9 @@ export class multiLocation1671711415179 implements MigrationInterface {
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "return" ADD "location_id" character varying` `ALTER TABLE "return" ADD "location_id" character varying`
) )
await queryRunner.query(
`ALTER TABLE "fulfillment" ADD "location_id" character varying`
)
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "store" ADD "default_location_id" character varying` `ALTER TABLE "store" ADD "default_location_id" character varying`
) )
@@ -34,6 +37,9 @@ export class multiLocation1671711415179 implements MigrationInterface {
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "store" DROP COLUMN "default_location_id"` `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(`ALTER TABLE "return" DROP COLUMN "location_id"`)
await queryRunner.query(`DROP INDEX "IDX_bf5386e7f2acc460adbf96d6f3"`) await queryRunner.query(`DROP INDEX "IDX_bf5386e7f2acc460adbf96d6f3"`)
await queryRunner.query(`DROP INDEX "IDX_c74e8c2835094a37dead376a3b"`) await queryRunner.query(`DROP INDEX "IDX_c74e8c2835094a37dead376a3b"`)
@@ -51,6 +51,9 @@ export class Fulfillment extends BaseEntity {
@Column() @Column()
provider_id: string provider_id: string
@Column({ nullable: true, type: "text" })
location_id: string | null
@ManyToOne(() => FulfillmentProvider) @ManyToOne(() => FulfillmentProvider)
@JoinColumn({ name: "provider_id" }) @JoinColumn({ name: "provider_id" })
provider: FulfillmentProvider provider: FulfillmentProvider
@@ -127,6 +130,10 @@ export class Fulfillment extends BaseEntity {
* description: "The id of the Fulfillment Provider responsible for handling the fulfillment" * description: "The id of the Fulfillment Provider responsible for handling the fulfillment"
* type: string * type: string
* example: manual * example: manual
* location_id:
* description: "The id of the stock location the fulfillment will be shipped from"
* type: string
* example: sloc_01G8TJSYT9M6AVS5N4EMNFS1EK
* provider: * provider:
* description: Available if the relation `provider` is expanded. * description: Available if the relation `provider` is expanded.
* $ref: "#/components/schemas/FulfillmentProvider" * $ref: "#/components/schemas/FulfillmentProvider"
@@ -44,6 +44,7 @@ export const orders = {
regionid: IdMap.getId("testRegion"), regionid: IdMap.getId("testRegion"),
currency_code: "USD", currency_code: "USD",
customerid: IdMap.getId("testCustomer"), customerid: IdMap.getId("testCustomer"),
fulfillments: [],
payment_method: { payment_method: {
providerid: "default_provider", providerid: "default_provider",
data: {}, data: {},
@@ -10,17 +10,22 @@ export const ProductVariantInventoryServiceMock = {
confirmInventory: jest confirmInventory: jest
.fn() .fn()
.mockImplementation((variantId, quantity, options) => { .mockImplementation((variantId, quantity, options) => {
if (quantity < 10) { return quantity < 10
return true
} else {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Variant with id: ${variantId} does not have the required inventory`
)
}
}), }),
releaseReservationsByLineItem: jest.fn().mockImplementation((lineItem) => {}), adjustReservationsQuantityByLineItem: jest
.fn()
.mockImplementation((lineItem) => {}),
deleteReservationsByLineItem: jest.fn().mockImplementation((lineItem) => {}),
reserveQuantity: jest reserveQuantity: jest
.fn() .fn()
.mockImplementation((variantId, quantity, options) => {}), .mockImplementation((variantId, quantity, options) => {}),
validateInventoryAtLocation: jest
.fn()
.mockImplementation((items, locationId) => {}),
} }
const mock = jest.fn().mockImplementation(() => {
return ProductVariantInventoryServiceMock
})
export default mock
@@ -1,5 +1,6 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import FulfillmentService from "../fulfillment" import FulfillmentService from "../fulfillment"
import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory"
describe("FulfillmentService", () => { describe("FulfillmentService", () => {
describe("createFulfillment", () => { describe("createFulfillment", () => {
@@ -34,6 +35,7 @@ describe("FulfillmentService", () => {
fulfillmentRepository, fulfillmentRepository,
shippingProfileService, shippingProfileService,
lineItemRepository, lineItemRepository,
productVariantInventoryService: ProductVariantInventoryServiceMock,
}) })
beforeEach(async () => { 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"), item_id: IdMap.getId("test-line"),
quantity: 10, quantity: 9,
}, },
], ],
{ order_id: "test", metadata: {} } { order_id: "test", metadata: {} }
@@ -66,7 +68,7 @@ describe("FulfillmentService", () => {
expect(fulfillmentRepository.create).toHaveBeenCalledWith({ expect(fulfillmentRepository.create).toHaveBeenCalledWith({
order_id: "test", order_id: "test",
provider_id: "GLS Express", 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(), data: expect.anything(),
metadata: {}, metadata: {},
}) })
@@ -132,6 +134,7 @@ describe("FulfillmentService", () => {
fulfillmentProviderService, fulfillmentProviderService,
fulfillmentRepository, fulfillmentRepository,
lineItemService, lineItemService,
productVariantInventoryService: ProductVariantInventoryServiceMock,
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -652,7 +652,9 @@ describe("OrderService", () => {
fulfillment_status: "not_fulfilled", fulfillment_status: "not_fulfilled",
payment_status: "awaiting", payment_status: "awaiting",
status: "pending", status: "pending",
fulfillments: [{ id: "fulfillment_test", canceled_at: now }], fulfillments: [
{ id: "fulfillment_test", canceled_at: now, items: [] },
],
payments: [{ id: "payment_test" }], payments: [{ id: "payment_test" }],
items: [ items: [
{ id: "item_1", variant_id: "variant-1", quantity: 12 }, { id: "item_1", variant_id: "variant-1", quantity: 12 },
@@ -711,7 +713,7 @@ describe("OrderService", () => {
payment_status: "canceled", payment_status: "canceled",
canceled_at: expect.any(Date), canceled_at: expect.any(Date),
status: "canceled", status: "canceled",
fulfillments: [{ id: "fulfillment_test", canceled_at: now }], fulfillments: [{ id: "fulfillment_test", canceled_at: now, items: [] }],
payments: [{ id: "payment_test" }], payments: [{ id: "payment_test" }],
items: [ items: [
{ {
@@ -915,7 +917,8 @@ describe("OrderService", () => {
quantity: 2, quantity: 2,
}, },
], ],
{ metadata: {}, order_id: "test-order" } { metadata: {}, order_id: "test-order" },
{ location_id: undefined }
) )
expect(lineItemService.update).toHaveBeenCalledTimes(1) expect(lineItemService.update).toHaveBeenCalledTimes(1)
@@ -947,7 +950,8 @@ describe("OrderService", () => {
quantity: 2, quantity: 2,
}, },
], ],
{ metadata: {}, order_id: "partial" } { metadata: {}, order_id: "partial" },
{ location_id: undefined }
) )
expect(lineItemService.update).toHaveBeenCalledTimes(1) expect(lineItemService.update).toHaveBeenCalledTimes(1)
@@ -979,7 +983,8 @@ describe("OrderService", () => {
quantity: 1, quantity: 1,
}, },
], ],
{ metadata: {}, order_id: "test" } { metadata: {}, order_id: "test" },
{ location_id: undefined }
) )
expect(lineItemService.update).toHaveBeenCalledTimes(1) 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 () => { it("fails if order is canceled", async () => {
await expect( await expect(
orderService.createFulfillment("canceled", [ orderService.createFulfillment("canceled", [
+27 -16
View File
@@ -1,6 +1,6 @@
import { isDefined, MedusaError } from "medusa-core-utils" import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { ShippingProfileService } from "." import { ProductVariantInventoryService, ShippingProfileService } from "."
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { Fulfillment, LineItem, ShippingMethod } from "../models" import { Fulfillment, LineItem, ShippingMethod } from "../models"
import { FulfillmentRepository } from "../repositories/fulfillment" import { FulfillmentRepository } from "../repositories/fulfillment"
@@ -27,6 +27,7 @@ type InjectedDependencies = {
fulfillmentRepository: typeof FulfillmentRepository fulfillmentRepository: typeof FulfillmentRepository
trackingLinkRepository: typeof TrackingLinkRepository trackingLinkRepository: typeof TrackingLinkRepository
lineItemRepository: typeof LineItemRepository lineItemRepository: typeof LineItemRepository
productVariantInventoryService: ProductVariantInventoryService
} }
/** /**
@@ -43,6 +44,8 @@ class FulfillmentService extends TransactionBaseService {
protected readonly fulfillmentRepository_: typeof FulfillmentRepository protected readonly fulfillmentRepository_: typeof FulfillmentRepository
protected readonly trackingLinkRepository_: typeof TrackingLinkRepository protected readonly trackingLinkRepository_: typeof TrackingLinkRepository
protected readonly lineItemRepository_: typeof LineItemRepository protected readonly lineItemRepository_: typeof LineItemRepository
// eslint-disable-next-line max-len
protected readonly productVariantInventoryService_: ProductVariantInventoryService
constructor({ constructor({
manager, manager,
@@ -53,6 +56,7 @@ class FulfillmentService extends TransactionBaseService {
lineItemService, lineItemService,
fulfillmentProviderService, fulfillmentProviderService,
lineItemRepository, lineItemRepository,
productVariantInventoryService,
}: InjectedDependencies) { }: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -66,6 +70,7 @@ class FulfillmentService extends TransactionBaseService {
this.shippingProfileService_ = shippingProfileService this.shippingProfileService_ = shippingProfileService
this.lineItemService_ = lineItemService this.lineItemService_ = lineItemService
this.fulfillmentProviderService_ = fulfillmentProviderService this.fulfillmentProviderService_ = fulfillmentProviderService
this.productVariantInventoryService_ = productVariantInventoryService
} }
partitionItems_( partitionItems_(
@@ -73,6 +78,11 @@ class FulfillmentService extends TransactionBaseService {
items: LineItem[] items: LineItem[]
): FulfillmentItemPartition[] { ): FulfillmentItemPartition[] {
const partitioned: FulfillmentItemPartition[] = [] const partitioned: FulfillmentItemPartition[] = []
if (shippingMethods.length === 1) {
return [{ items, shipping_method: shippingMethods[0] }]
}
// partition order items to their dedicated shipping method // partition order items to their dedicated shipping method
for (const method of shippingMethods) { for (const method of shippingMethods) {
const temp: FulfillmentItemPartition = { const temp: FulfillmentItemPartition = {
@@ -82,15 +92,11 @@ class FulfillmentService extends TransactionBaseService {
// for each method find the items in the order, that are associated // for each method find the items in the order, that are associated
// with the profile on the current shipping method // with the profile on the current shipping method
if (shippingMethods.length === 1) { const methodProfile = method.shipping_option.profile_id
temp.items = items
} else {
const methodProfile = method.shipping_option.profile_id
temp.items = items.filter(({ variant }) => { temp.items = items.filter(({ variant }) => {
variant.product.profile_id === methodProfile variant.product.profile_id === methodProfile
}) })
}
partitioned.push(temp) partitioned.push(temp)
} }
return partitioned return partitioned
@@ -205,8 +211,10 @@ class FulfillmentService extends TransactionBaseService {
async createFulfillment( async createFulfillment(
order: CreateFulfillmentOrder, order: CreateFulfillmentOrder,
itemsToFulfill: FulFillmentItemType[], itemsToFulfill: FulFillmentItemType[],
custom: Partial<Fulfillment> = {} custom: Partial<Fulfillment> = {},
context: { locationId?: string } = {}
): Promise<Fulfillment[]> { ): Promise<Fulfillment[]> {
const { locationId } = context
return await this.atomicPhase_(async (manager) => { return await this.atomicPhase_(async (manager) => {
const fulfillmentRepository = manager.getCustomRepository( const fulfillmentRepository = manager.getCustomRepository(
this.fulfillmentRepository_ this.fulfillmentRepository_
@@ -229,6 +237,7 @@ class FulfillmentService extends TransactionBaseService {
provider_id: shipping_method.shipping_option.provider_id, provider_id: shipping_method.shipping_option.provider_id,
items: items.map((i) => ({ item_id: i.id, quantity: i.quantity })), items: items.map((i) => ({ item_id: i.id, quantity: i.quantity })),
data: {}, data: {},
location_id: locationId,
}) })
const result = await fulfillmentRepository.save(ful) const result = await fulfillmentRepository.save(ful)
@@ -283,13 +292,15 @@ class FulfillmentService extends TransactionBaseService {
const lineItemServiceTx = this.lineItemService_.withTransaction(manager) const lineItemServiceTx = this.lineItemService_.withTransaction(manager)
for (const fItem of fulfillment.items) { await Promise.all(
const item = await lineItemServiceTx.retrieve(fItem.item_id) fulfillment.items.map(async (fItem) => {
const fulfilledQuantity = item.fulfilled_quantity! - fItem.quantity const item = await lineItemServiceTx.retrieve(fItem.item_id)
await lineItemServiceTx.update(item.id, { const fulfilledQuantity = item.fulfilled_quantity! - fItem.quantity
fulfilled_quantity: fulfilledQuantity, await lineItemServiceTx.update(item.id, {
fulfilled_quantity: fulfilledQuantity,
})
}) })
} )
const fulfillmentRepo = manager.getCustomRepository( const fulfillmentRepo = manager.getCustomRepository(
this.fulfillmentRepository_ this.fulfillmentRepository_
+20 -8
View File
@@ -1144,13 +1144,24 @@ class OrderService extends TransactionBaseService {
const inventoryServiceTx = const inventoryServiceTx =
this.productVariantInventoryService_.withTransaction(manager) 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( await Promise.all(
order.items.map(async (item) => { order.items.map(async (item) => {
if (item.variant_id) { if (item.variant_id) {
return await inventoryServiceTx.releaseReservationsByLineItem( return await inventoryServiceTx.deleteReservationsByLineItem(
item.id, item.id,
item.variant_id, item.variant_id,
item.quantity item.quantity - (previouslyFulfilledQuantities[item.id] || 0)
) )
} }
}) })
@@ -1295,13 +1306,11 @@ class OrderService extends TransactionBaseService {
itemsToFulfill: FulFillmentItemType[], itemsToFulfill: FulFillmentItemType[],
config: { config: {
no_notification?: boolean no_notification?: boolean
location_id?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} = { } = {}
no_notification: undefined,
metadata: {},
}
): Promise<Order> { ): Promise<Order> {
const { metadata, no_notification } = config const { metadata, no_notification, location_id } = config
return await this.atomicPhase_(async (manager) => { return await this.atomicPhase_(async (manager) => {
// NOTE: we are telling the service to calculate all totals for us which // 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, order as unknown as CreateFulfillmentOrder,
itemsToFulfill, itemsToFulfill,
{ {
metadata, metadata: metadata ?? {},
no_notification: no_notification, no_notification: no_notification,
order_id: orderId, order_id: orderId,
},
{
locationId: location_id,
} }
) )
let successfullyFulfilled: FulfillmentItem[] = [] let successfullyFulfilled: FulfillmentItem[] = []
@@ -8,7 +8,7 @@ import {
import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item" import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item"
import { ProductVariantService, SalesChannelLocationService } from "./" import { ProductVariantService, SalesChannelLocationService } from "./"
import { InventoryItemDTO, ReserveQuantityContext } from "../types/inventory" import { InventoryItemDTO, ReserveQuantityContext } from "../types/inventory"
import { ProductVariant } from "../models" import { LineItem, ProductVariant } from "../models"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -117,6 +117,37 @@ class ProductVariantInventoryService extends TransactionBaseService {
return hasInventory.every(Boolean) 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<ProductVariantInventoryItem> {
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 * list registered inventory items
* @param itemIds list inventory item ids * @param itemIds list inventory item ids
@@ -142,7 +173,7 @@ class ProductVariantInventoryService extends TransactionBaseService {
* @returns variant inventory items for the variant id * @returns variant inventory items for the variant id
*/ */
private async listByVariant( private async listByVariant(
variantId: string variantId: string | string[]
): Promise<ProductVariantInventoryItem[]> { ): Promise<ProductVariantInventoryItem[]> {
const manager = this.transactionManager_ || this.manager_ const manager = this.transactionManager_ || this.manager_
@@ -150,8 +181,10 @@ class ProductVariantInventoryService extends TransactionBaseService {
ProductVariantInventoryItem ProductVariantInventoryItem
) )
const ids = Array.isArray(variantId) ? variantId : [variantId]
const variantInventory = await variantInventoryRepo.find({ const variantInventory = await variantInventoryRepo.find({
where: { variant_id: variantId }, where: { variant_id: In(ids) },
}) })
return variantInventory return variantInventory
@@ -298,15 +331,17 @@ class ProductVariantInventoryService extends TransactionBaseService {
const manager = this.transactionManager_ || this.manager_ const manager = this.transactionManager_ || this.manager_
if (!this.inventoryService_) { if (!this.inventoryService_) {
const variantServiceTx = return this.atomicPhase_(async (manager) => {
this.productVariantService_.withTransaction(manager) const variantServiceTx =
const variant = await variantServiceTx.retrieve(variantId, { this.productVariantService_.withTransaction(manager)
select: ["id", "inventory_quantity"], 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 = { const toReserve = {
@@ -342,7 +377,7 @@ class ProductVariantInventoryService extends TransactionBaseService {
return await this.inventoryService_.createReservationItem({ return await this.inventoryService_.createReservationItem({
...toReserve, ...toReserve,
location_id: locationId as string, location_id: locationId as string,
item_id: inventoryPart.inventory_item_id, inventory_item_id: inventoryPart.inventory_item_id,
quantity: itemQuantity, 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<void> {
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<string, ProductVariantInventoryItem> = 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 lineItemId line item id
* @param variantId variant id * @param variantId variant id
* @param quantity quantity to release * @param quantity quantity to release
*/ */
async releaseReservationsByLineItem( async deleteReservationsByLineItem(
lineItemId: string, lineItemId: string,
variantId: string, variantId: string,
quantity: number quantity: number
): Promise<void> { ): Promise<void> {
if (!this.inventoryService_) { if (!this.inventoryService_) {
const variant = await this.productVariantService_.retrieve(variantId, { return this.atomicPhase_(async (manager) => {
select: ["id", "inventory_quantity", "manage_inventory"], const productVariantServiceTx =
}) this.productVariantService_.withTransaction(manager)
const variant = await productVariantServiceTx.retrieve(variantId, {
select: ["id", "inventory_quantity", "manage_inventory"],
})
if (!variant.manage_inventory) { if (!variant.manage_inventory) {
return return
} }
await this.productVariantService_.update(variantId, { await productVariantServiceTx.update(variantId, {
inventory_quantity: variant.inventory_quantity + quantity, 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, locationId: string,
quantity: number quantity: number
): Promise<void> { ): Promise<void> {
const manager = this.transactionManager_ || this.manager_
if (!this.inventoryService_) { if (!this.inventoryService_) {
const variant = await this.productVariantService_ return this.atomicPhase_(async (manager) => {
.withTransaction(manager) const productVariantServiceTx =
.retrieve(variantId, { this.productVariantService_.withTransaction(manager)
const variant = await productVariantServiceTx.retrieve(variantId, {
select: ["id", "inventory_quantity", "manage_inventory"], select: ["id", "inventory_quantity", "manage_inventory"],
}) })
if (!variant.manage_inventory) { if (!variant.manage_inventory) {
return return
} }
await this.productVariantService_ await productVariantServiceTx.update(variantId, {
.withTransaction(manager)
.update(variantId, {
inventory_quantity: variant.inventory_quantity + quantity, inventory_quantity: variant.inventory_quantity + quantity,
}) })
})
} else { } else {
const variantInventory = await this.listByVariant(variantId) const variantInventory = await this.listByVariant(variantId)
+13 -6
View File
@@ -21,7 +21,8 @@ export type InventoryItemDTO = {
export type ReservationItemDTO = { export type ReservationItemDTO = {
id: string id: string
location_id: string location_id: string
item_id: string inventory_item_id: string
quantity: number
metadata: Record<string, unknown> | null metadata: Record<string, unknown> | null
created_at: string | Date created_at: string | Date
updated_at: string | Date updated_at: string | Date
@@ -30,7 +31,7 @@ export type ReservationItemDTO = {
export type InventoryLevelDTO = { export type InventoryLevelDTO = {
id: string id: string
item_id: string inventory_item_id: string
location_id: string location_id: string
stocked_quantity: number stocked_quantity: number
incoming_quantity: number incoming_quantity: number
@@ -44,7 +45,7 @@ export type FilterableReservationItemProps = {
id?: string | string[] id?: string | string[]
type?: string | string[] type?: string | string[]
line_item_id?: string | string[] line_item_id?: string | string[]
item_id?: string | string[] inventory_item_id?: string | string[]
location_id?: string | string[] location_id?: string | string[]
quantity?: number | NumericalComparisonOperator quantity?: number | NumericalComparisonOperator
} }
@@ -76,21 +77,21 @@ export type CreateInventoryItemInput = {
export type CreateReservationItemInput = { export type CreateReservationItemInput = {
type?: string type?: string
line_item_id?: string line_item_id?: string
item_id: string inventory_item_id: string
location_id: string location_id: string
quantity: number quantity: number
metadata?: Record<string, unknown> | null metadata?: Record<string, unknown> | null
} }
export type FilterableInventoryLevelProps = { export type FilterableInventoryLevelProps = {
item_id?: string | string[] inventory_item_id?: string | string[]
location_id?: string | string[] location_id?: string | string[]
stocked_quantity?: number | NumericalComparisonOperator stocked_quantity?: number | NumericalComparisonOperator
incoming_quantity?: number | NumericalComparisonOperator incoming_quantity?: number | NumericalComparisonOperator
} }
export type CreateInventoryLevelInput = { export type CreateInventoryLevelInput = {
item_id: string inventory_item_id: string
location_id: string location_id: string
stocked_quantity: number stocked_quantity: number
incoming_quantity: number incoming_quantity: number
@@ -101,6 +102,12 @@ export type UpdateInventoryLevelInput = {
incoming_quantity?: number incoming_quantity?: number
} }
export type UpdateReservationItemInput = {
quantity?: number
location_id?: string
metadata?: Record<string, unknown> | null
}
export type ReserveQuantityContext = { export type ReserveQuantityContext = {
locationId?: string locationId?: string
lineItemId?: string lineItemId?: string