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:
5
.changeset/moody-eyes-judge.md
Normal file
5
.changeset/moody-eyes-judge.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
Multi Warehouse: Add locations for fulfillments
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user