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:
1 parent
28bec599ae
commit
16716f5a4f
16 files changed
+546
-86
No files matched your search
@@ -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", [
|
||||||
|
|||||||
@@ -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_
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user