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
2023-01-09 14:44:34 +01:00
committed by GitHub
parent 28bec599ae
commit 16716f5a4f
16 changed files with 546 additions and 86 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
Multi Warehouse: Add locations for fulfillments

View File

@@ -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

View File

@@ -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<String>,
@@ -1254,6 +1256,7 @@ Object {
"quantity": 2,
},
],
"location_id": null,
"metadata": Object {},
"no_notification": null,
"order_id": Any<String>,
@@ -1931,6 +1934,7 @@ Object {
"quantity": 1,
},
],
"location_id": null,
"metadata": Object {},
"no_notification": null,
"order_id": null,

View File

@@ -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
)
}
})
)
}

View File

@@ -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))

View File

@@ -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<InventoryItemDTO>
updateReservationItem(
reservationId: string,
update: UpdateReservationItemInput
): Promise<ReservationItemDTO>
deleteReservationItemsByLineItem(lineItemId: string): Promise<void>
deleteReservationItem(id: string): Promise<void>

View File

@@ -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"`)

View File

@@ -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"

View File

@@ -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: {},

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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", [

View File

@@ -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<Fulfillment> = {}
custom: Partial<Fulfillment> = {},
context: { locationId?: string } = {}
): Promise<Fulfillment[]> {
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_

View File

@@ -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<string, unknown>
} = {
no_notification: undefined,
metadata: {},
}
} = {}
): Promise<Order> {
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[] = []

View File

@@ -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<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
* @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<ProductVariantInventoryItem[]> {
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<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 variantId variant id
* @param quantity quantity to release
*/
async releaseReservationsByLineItem(
async deleteReservationsByLineItem(
lineItemId: string,
variantId: string,
quantity: number
): Promise<void> {
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<void> {
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)

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null
}
export type ReserveQuantityContext = {
locationId?: string
lineItemId?: string