chore(inventory, core-flows): big number support (#8204)

This commit is contained in:
Carlos R. L. Rodrigues
2024-07-22 06:32:25 -03:00
committed by GitHub
parent c307972a99
commit fb29b958fa
27 changed files with 363 additions and 218 deletions

View File

@@ -292,16 +292,6 @@ medusaIntegrationTestRunner({
})
it("should fail to update the location level to negative quantity", async () => {
await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
{
location_id: stockLocation1.id,
stocked_quantity: 17,
incoming_quantity: 2,
},
adminHeaders
)
const res = await api
.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${stockLocation1.id}`,
@@ -581,7 +571,7 @@ medusaIntegrationTestRunner({
})
)
})
it("should retrieve the inventory item with correct stocked quantity given location levels have been deleted", async () => {
await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
@@ -751,12 +741,6 @@ medusaIntegrationTestRunner({
adminHeaders
)
await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
{ location_id: stockLocation1.id },
adminHeaders
)
await api.post(
`/admin/reservations`,
{
@@ -784,7 +768,7 @@ medusaIntegrationTestRunner({
adminHeaders
)
).data
expect(levelsResponse.count).toEqual(2)
expect(levelsResponse.count).toEqual(1)
const res = await api.delete(
`/admin/inventory-items/${inventoryItem1.id}`,

View File

@@ -15,6 +15,7 @@ import {
StockLocationDTO,
} from "@medusajs/types"
import {
BigNumber,
ContainerRegistrationKeys,
ModuleRegistrationName,
Modules,
@@ -345,6 +346,10 @@ medusaIntegrationTestRunner({
})
it("should create a order fulfillment and cancel it", async () => {
const inventoryModule = container.resolve(
ModuleRegistrationName.INVENTORY
)
const order = await createOrderFixture({ container, product, location })
// Create a fulfillment
@@ -389,9 +394,6 @@ medusaIntegrationTestRunner({
expect(orderFulfill.fulfillments).toHaveLength(1)
expect(orderFulfill.items[0].detail.fulfilled_quantity).toEqual(1)
const inventoryModule = container.resolve(
ModuleRegistrationName.INVENTORY
)
const reservation = await inventoryModule.listReservationItems({
line_item_id: order.items![0].id,
})
@@ -401,7 +403,7 @@ medusaIntegrationTestRunner({
inventoryItem.id,
[location.id]
)
expect(stockAvailability).toEqual(1)
expect(stockAvailability).toEqual(new BigNumber(1))
// Cancel the fulfillment
const cancelFulfillmentData: OrderWorkflow.CancelOrderFulfillmentWorkflowInput =
@@ -443,7 +445,7 @@ medusaIntegrationTestRunner({
await inventoryModule.retrieveStockedQuantity(inventoryItem.id, [
location.id,
])
expect(stockAvailabilityAfterCancelled).toEqual(2)
expect(stockAvailabilityAfterCancelled.valueOf()).toEqual(2)
})
it("should revert an order fulfillment when it fails and recreate it when tried again", async () => {
@@ -518,7 +520,7 @@ medusaIntegrationTestRunner({
inventoryItem.id,
[location.id]
)
expect(stockAvailability).toEqual(1)
expect(stockAvailability.valueOf()).toEqual(1)
})
})
},

View File

@@ -418,7 +418,7 @@ medusaIntegrationTestRunner({
inventoryItem.id,
[location.id]
)
expect(stockAvailability).toEqual(1)
expect(stockAvailability.valueOf()).toEqual(1)
})
})
},

View File

@@ -1,7 +1,7 @@
import { IInventoryService, InventoryTypes } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { ModuleRegistrationName } from "@medusajs/utils"
import { MathBN, ModuleRegistrationName } from "@medusajs/utils"
export const adjustInventoryLevelsStepId = "adjust-inventory-levels-step"
export const adjustInventoryLevelsStep = createStep(
@@ -30,7 +30,7 @@ export const adjustInventoryLevelsStep = createStep(
input.map((item) => {
return {
...item,
adjustment: item.adjustment * -1,
adjustment: MathBN.mult(item.adjustment, -1),
}
})
)

View File

@@ -1,11 +1,16 @@
import { FulfillmentDTO, OrderDTO, OrderWorkflow } from "@medusajs/types"
import {
BigNumberInput,
FulfillmentDTO,
OrderDTO,
OrderWorkflow,
} from "@medusajs/types"
import { MedusaError, Modules } from "@medusajs/utils"
import {
WorkflowData,
createStep,
createWorkflow,
parallelize,
transform,
WorkflowData,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import { cancelFulfillmentWorkflow } from "../../fulfillment"
@@ -82,7 +87,7 @@ function prepareInventoryUpdate({
const inventoryAdjustment: {
inventory_item_id: string
location_id: string
adjustment: number // TODO: BigNumberInput
adjustment: BigNumberInput
}[] = []
for (const item of fulfillment.items) {

View File

@@ -1,11 +1,12 @@
import {
BigNumberInput,
FulfillmentDTO,
FulfillmentWorkflow,
OrderDTO,
OrderWorkflow,
ReservationItemDTO,
} from "@medusajs/types"
import { MedusaError, Modules } from "@medusajs/utils"
import { MathBN, MedusaError, Modules } from "@medusajs/utils"
import {
WorkflowData,
createStep,
@@ -68,6 +69,7 @@ function prepareFulfillmentData({
order,
input,
shippingOption,
shippingMethod,
reservations,
}: {
order: OrderDTO
@@ -77,6 +79,7 @@ function prepareFulfillmentData({
provider_id: string
service_zone: { fulfillment_set: { location?: { id: string } } }
}
shippingMethod: { data?: Record<string, unknown> | null }
reservations: ReservationItemDTO[]
}) {
const inputItems = input.items
@@ -120,8 +123,9 @@ function prepareFulfillmentData({
location_id: locationId,
provider_id: shippingOption.provider_id,
shipping_option_id: shippingOption.id,
data: shippingMethod.data,
items: fulfillmentItems,
labels: [] as FulfillmentWorkflow.CreateFulfillmentLabelWorkflowDTO[], // TODO: shipping labels
labels: input.labels ?? [],
delivery_address: shippingAddress as any,
},
}
@@ -136,13 +140,13 @@ function prepareInventoryUpdate({ reservations, order, input, inputItemsMap }) {
const toDelete: string[] = []
const toUpdate: {
id: string
quantity: number // TODO: BigNumberInput
quantity: BigNumberInput
location_id: string
}[] = []
const inventoryAdjustment: {
inventory_item_id: string
location_id: string
adjustment: number // TODO: BigNumberInput
adjustment: BigNumberInput
}[] = []
for (const item of order.items) {
@@ -163,7 +167,7 @@ function prepareInventoryUpdate({ reservations, order, input, inputItemsMap }) {
inventoryAdjustment.push({
inventory_item_id: reservation.inventory_item_id,
location_id: input.location_id ?? reservation.location_id,
adjustment: -item.quantity, // TODO: MathBN.mul(-1, item.quantity)
adjustment: MathBN.mult(item.quantity, -1),
})
if (quantity === 0) {
@@ -201,6 +205,7 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
"items.variant.manage_inventory",
"shipping_address.*",
"shipping_methods.shipping_option_id", // TODO: which shipping method to use when multiple?
"shipping_methods.data",
],
variables: { id: input.order_id },
list: false,
@@ -216,6 +221,10 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
}, {})
})
const shippingMethod = transform(order, (data) => {
return { data: data.shipping_methods?.[0].data }
})
const shippingOptionId = transform(order, (data) => {
return data.shipping_methods?.[0]?.shipping_option_id
})
@@ -250,7 +259,7 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
}).config({ name: "get-reservations" })
const fulfillmentData = transform(
{ order, input, shippingOption, reservations },
{ order, input, shippingOption, shippingMethod, reservations },
prepareFulfillmentData
)
@@ -278,12 +287,12 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
prepareInventoryUpdate
)
adjustInventoryLevelsStep(inventoryAdjustment)
parallelize(
registerOrderFulfillmentStep(registerOrderFulfillmentData),
createRemoteLinkStep(link),
updateReservationsStep(toUpdate),
deleteReservationsStep(toDelete),
adjustInventoryLevelsStep(inventoryAdjustment)
deleteReservationsStep(toDelete)
)
// trigger event OrderModuleService.Events.FULFILLMENT_CREATED

View File

@@ -92,7 +92,7 @@ export const createOrderShipmentWorkflow = createWorkflow(
const fulfillmentData = transform({ input }, ({ input }) => {
return {
id: input.fulfillment_id,
labels: input.labels,
labels: input.labels ?? [],
}
})

View File

@@ -161,7 +161,7 @@ function prepareFulfillmentData({
provider_id: returnShippingOption.provider_id,
shipping_option_id: input.return_shipping?.option_id,
items: fulfillmentItems,
labels: [] as FulfillmentWorkflow.CreateFulfillmentLabelWorkflowDTO[],
labels: input.return_shipping?.labels ?? [],
delivery_address: order.shipping_address ?? ({} as any), // TODO: should it be the stock location address?
order: order,
},

View File

@@ -2,6 +2,7 @@ import {
NumericalComparisonOperator,
StringComparisonOperator,
} from "../../common"
import { BigNumberInput } from "../../totals/big-number"
/**
* The reservation item details.
@@ -25,7 +26,7 @@ export interface ReservationItemDTO {
/**
* The quantity of the reservation item.
*/
quantity: number
quantity: BigNumberInput
/**
* The associated line item's ID.

View File

@@ -1,3 +1,5 @@
import { BigNumberInput } from "../../totals/big-number"
export interface CreateInventoryLevelInput {
/**
* The ID of the associated inventory item.
@@ -66,5 +68,5 @@ export type BulkAdjustInventoryLevelInput = {
/**
* The quantity to adjust the inventory level by.
*/
adjustment: number // TODO: BigNumberInput
adjustment: BigNumberInput
} & UpdateInventoryLevelInput

View File

@@ -1,3 +1,5 @@
import { BigNumberInput } from "../../totals"
/**
* @interface
*
@@ -8,7 +10,7 @@ export interface UpdateReservationItemInput {
/**
* The reserved quantity.
*/
quantity?: number
quantity?: BigNumberInput
/**
* The ID of the associated location.
*/
@@ -48,7 +50,7 @@ export interface CreateReservationItemInput {
/**
* The reserved quantity.
*/
quantity: number
quantity: BigNumberInput
/**
* Allow backorder of the item. If true, it won't check inventory levels before reserving it.
*/

View File

@@ -3,6 +3,7 @@ import { RestoreReturn, SoftDeleteReturn } from "../dal"
import { FindConfig } from "../common"
import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context"
import { BigNumberInput, IBigNumber } from "../totals"
import {
FilterableInventoryItemProps,
FilterableInventoryLevelProps,
@@ -1044,7 +1045,7 @@ export interface IInventoryService extends IModuleService {
data: {
inventoryItemId: string
locationId: string
adjustment: number
adjustment: BigNumberInput
}[],
context?: Context
): Promise<InventoryLevelDTO[]>
@@ -1052,7 +1053,7 @@ export interface IInventoryService extends IModuleService {
adjustInventory(
inventoryItemId: string,
locationId: string,
adjustment: number,
adjustment: BigNumberInput,
context?: Context
): Promise<InventoryLevelDTO>
@@ -1076,7 +1077,7 @@ export interface IInventoryService extends IModuleService {
confirmInventory(
inventoryItemId: string,
locationIds: string[],
quantity: number,
quantity: BigNumberInput,
context?: Context
): Promise<boolean>
@@ -1086,7 +1087,7 @@ export interface IInventoryService extends IModuleService {
* @param {string} inventoryItemId - The inventory item's ID.
* @param {string[]} locationIds - The locations' IDs.
* @param {Context} context - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<number>} The available quantity of the item.
* @returns {Promise<BigNumber>} The available quantity of the item.
*
* @example
* const availableQuantity =
@@ -1099,7 +1100,7 @@ export interface IInventoryService extends IModuleService {
inventoryItemId: string,
locationIds: string[],
context?: Context
): Promise<number>
): Promise<IBigNumber>
/**
* This method retrieves the stocked quantity of an inventory item in the specified location.
@@ -1107,7 +1108,7 @@ export interface IInventoryService extends IModuleService {
* @param {string} inventoryItemId - The inventory item's ID.
* @param {string[]} locationIds - The locations' IDs.
* @param {Context} context - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<number>} The stocked quantity of the item.
* @returns {Promise<BigNumber>} The stocked quantity of the item.
*
* @example
* const stockedQuantity =
@@ -1120,7 +1121,7 @@ export interface IInventoryService extends IModuleService {
inventoryItemId: string,
locationIds: string[],
context?: Context
): Promise<number>
): Promise<IBigNumber>
/**
* This method retrieves the reserved quantity of an inventory item in the specified location.
@@ -1128,7 +1129,7 @@ export interface IInventoryService extends IModuleService {
* @param {string} inventoryItemId - The inventory item's ID.
* @param {string[]} locationIds - The locations' IDs.
* @param {Context} context - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<number>} The reserved quantity of the item.
* @returns {Promise<BigNumber>} The reserved quantity of the item.
*
* @example
* const reservedQuantity =
@@ -1141,5 +1142,5 @@ export interface IInventoryService extends IModuleService {
inventoryItemId: string,
locationIds: string[],
context?: Context
): Promise<number>
): Promise<IBigNumber>
}

View File

@@ -1,10 +1,24 @@
import BigNumberJS from "bignumber.js"
export interface IBigNumber {
numeric: number
raw?: BigNumberRawValue
bigNumber?: BigNumberJS
toJSON(): number
valueOf(): number
}
export type BigNumberRawValue = {
value: string | number
[key: string]: unknown
}
export type BigNumberInput = BigNumberRawValue | number | string | BigNumberJS
export type BigNumberInput =
| BigNumberRawValue
| number
| string
| BigNumberJS
| IBigNumber
export type BigNumberValue = BigNumberJS | number | string
export type BigNumberValue = BigNumberJS | number | string | IBigNumber

View File

@@ -1,4 +1,5 @@
import { BigNumberInput } from "../../totals"
import { CreateFulfillmentLabelWorkflowDTO } from "../fulfillment/create-fulfillment"
interface CreateOrderFulfillmentItem {
id: string
@@ -9,6 +10,7 @@ export interface CreateOrderFulfillmentWorkflowInput {
order_id: string
created_by?: string // The id of the authenticated user
items: CreateOrderFulfillmentItem[]
labels?: CreateFulfillmentLabelWorkflowDTO[]
no_notification?: boolean
location_id?: string | null
metadata?: Record<string, any> | null

View File

@@ -1,4 +1,5 @@
import { BigNumberInput } from "../../totals"
import { CreateFulfillmentLabelWorkflowDTO } from "../fulfillment/create-fulfillment"
export interface CreateReturnItem {
id: string
@@ -16,6 +17,7 @@ export interface CreateOrderReturnWorkflowInput {
return_shipping?: {
option_id: string
price?: number
labels?: CreateFulfillmentLabelWorkflowDTO[]
}
note?: string | null
receive_now?: boolean

View File

@@ -12,7 +12,7 @@ export interface CreateOrderShipmentWorkflowInput {
fulfillment_id: string
created_by?: string // The id of the authenticated user
items: CreateOrderShipmentItem[]
labels: CreateFulfillmentLabelWorkflowDTO[]
labels?: CreateFulfillmentLabelWorkflowDTO[]
no_notification?: boolean
metadata?: MetadataType
}

View File

@@ -1,8 +1,8 @@
import { BigNumberInput, BigNumberRawValue } from "@medusajs/types"
import { BigNumberInput, BigNumberRawValue, IBigNumber } from "@medusajs/types"
import { BigNumber as BigNumberJS } from "bignumber.js"
import { isBigNumber, isString } from "../common"
export class BigNumber {
export class BigNumber implements IBigNumber {
static DEFAULT_PRECISION = 20
private numeric_: number
@@ -110,7 +110,7 @@ export class BigNumber {
this.bignumber_ = newValue.bignumber_
}
toJSON() {
toJSON(): number {
return this.bignumber_
? this.bignumber_?.toNumber()
: this.raw_
@@ -118,7 +118,7 @@ export class BigNumber {
: this.numeric_
}
valueOf() {
valueOf(): number {
return this.numeric_
}
}

View File

@@ -1,6 +1,6 @@
import { IInventoryService, InventoryItemDTO } from "@medusajs/types"
import { BigNumber, Module, Modules } from "@medusajs/utils"
import { moduleIntegrationTestRunner } from "medusa-test-utils"
import { Module, Modules } from "@medusajs/utils"
import { InventoryModuleService } from "../../src/services"
jest.setTimeout(100000)
@@ -882,7 +882,7 @@ moduleIntegrationTestRunner<IInventoryService>({
["location-1", "location-2"]
)
expect(level).toEqual(6)
expect(level).toEqual(new BigNumber(6))
})
})
@@ -930,7 +930,7 @@ moduleIntegrationTestRunner<IInventoryService>({
["location-1", "location-2"]
)
expect(stockedQuantity).toEqual(8)
expect(stockedQuantity.valueOf()).toEqual(8)
})
})
@@ -991,12 +991,12 @@ moduleIntegrationTestRunner<IInventoryService>({
})
it("retrieves reserved quantity", async () => {
const reservedQuantity = await service.retrieveReservedQuantity(
const reservedQuantity = (await service.retrieveReservedQuantity(
inventoryItem.id,
["location-1", "location-2"]
)
)) as any
expect(reservedQuantity).toEqual(2)
expect(reservedQuantity + 0).toEqual(2)
})
})

View File

@@ -24,9 +24,6 @@ export class Migration20240307132720 extends Migration {
this.addSql(
'CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);'
)
this.addSql(
'CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);'
)
this.addSql(
'create table if not exists "reservation_item" ("id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "line_item_id" text null, "location_id" text not null, "quantity" integer not null, "external_id" text null, "description" text null, "created_by" text null, "metadata" jsonb null, "inventory_item_id" text not null, constraint "reservation_item_pkey" primary key ("id"));'

View File

@@ -0,0 +1,73 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240719123015 extends Migration {
async up(): Promise<void> {
this.addSql(
`
ALTER TABLE "reservation_item" ALTER COLUMN "quantity" TYPE numeric;
ALTER TABLE "reservation_item" ADD COLUMN IF NOT EXISTS "raw_quantity" JSONB NULL;
ALTER TABLE "inventory_level" ALTER COLUMN "stocked_quantity" TYPE numeric;
ALTER TABLE "inventory_level" ADD COLUMN IF NOT EXISTS "raw_stocked_quantity" JSONB NULL;
ALTER TABLE "inventory_level" ALTER COLUMN "reserved_quantity" TYPE numeric;
ALTER TABLE "inventory_level" ADD COLUMN IF NOT EXISTS "raw_reserved_quantity" JSONB NULL;
ALTER TABLE "inventory_level" ALTER COLUMN "incoming_quantity" TYPE numeric;
ALTER TABLE "inventory_level" ADD COLUMN IF NOT EXISTS "raw_incoming_quantity" JSONB NULL;
DROP INDEX IF EXISTS "IDX_inventory_item_sku_unique";
DROP INDEX IF EXISTS "IDX_inventory_level_inventory_item_id";
DROP INDEX IF EXISTS "IDX_inventory_level_location_id";
DROP INDEX IF EXISTS "IDX_reservation_item_line_item_id";
DROP INDEX IF EXISTS "IDX_reservation_item_location_id";
DROP INDEX IF EXISTS "IDX_reservation_item_inventory_item_id";
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_inventory_item_sku_unique" ON "inventory_item" (sku) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS "IDX_inventory_level_inventory_item_id" ON "inventory_level" (inventory_item_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS "IDX_reservation_item_line_item_id" ON "reservation_item" (line_item_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS "IDX_reservation_item_location_id" ON "reservation_item" (location_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS "IDX_reservation_item_inventory_item_id" ON "reservation_item" (inventory_item_id) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX "IDX_inventory_level_item_location" ON "inventory_level" (inventory_item_id, location_id) WHERE deleted_at IS NULL;
`
)
}
async down(): Promise<void> {
this.addSql(
`
ALTER TABLE "reservation_item" ALTER COLUMN "quantity" TYPE integer;
ALTER TABLE "reservation_item" DROP COLUMN IF EXISTS "raw_quantity";
ALTER TABLE "inventory_level" ALTER COLUMN "stocked_quantity" TYPE integer;
ALTER TABLE "inventory_level" DROP COLUMN IF NOT EXISTS "raw_stocked_quantity";
ALTER TABLE "inventory_level" ALTER COLUMN "reserved_quantity" TYPE integer;
ALTER TABLE "inventory_level" DROP COLUMN IF NOT EXISTS "raw_reserved_quantity";
ALTER TABLE "inventory_level" ALTER COLUMN "incoming_quantity" TYPE integer;
ALTER TABLE "inventory_level" DROP COLUMN IF NOT EXISTS "raw_incoming_quantity";
DROP INDEX IF EXISTS "IDX_inventory_item_sku_unique";
DROP INDEX IF EXISTS "IDX_inventory_level_inventory_item_id";
DROP INDEX IF EXISTS "IDX_inventory_level_location_id";
DROP INDEX IF EXISTS "IDX_reservation_item_line_item_id";
DROP INDEX IF EXISTS "IDX_reservation_item_location_id";
DROP INDEX IF EXISTS "IDX_reservation_item_inventory_item_id";
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_inventory_item_sku_unique" ON "inventory_item" (sku);
CREATE INDEX IF NOT EXISTS "IDX_inventory_level_inventory_item_id" ON "inventory_level" (inventory_item_id);
CREATE INDEX IF NOT EXISTS "IDX_inventory_level_location_id" ON "inventory_level" (location_id);
CREATE INDEX IF NOT EXISTS "IDX_reservation_item_line_item_id" ON "reservation_item" (line_item_id);
CREATE INDEX IF NOT EXISTS "IDX_reservation_item_location_id" ON "reservation_item" (location_id);
CREATE INDEX IF NOT EXISTS "IDX_reservation_item_inventory_item_id" ON "reservation_item" (inventory_item_id);
DROP INDEX IF EXISTS "IDX_inventory_level_item_location"
`
)
}
}

View File

@@ -32,6 +32,7 @@ const InventoryItemSkuIndex = createPsqlIndexStatementHelper({
tableName: "inventory_item",
columns: "sku",
unique: true,
where: "deleted_at IS NULL",
})
type InventoryItemOptionalProps = DAL.SoftDeletableModelDateColumns

View File

@@ -1,4 +1,4 @@
import { DALUtils, isDefined } from "@medusajs/utils"
import { DALUtils, isDefined, MathBN } from "@medusajs/utils"
import {
BeforeCreate,
Entity,
@@ -11,9 +11,12 @@ import {
Rel,
} from "@mikro-orm/core"
import { BigNumberRawValue } from "@medusajs/types"
import {
BigNumber,
createPsqlIndexStatementHelper,
generateEntityId,
MikroOrmBigNumberProperty,
} from "@medusajs/utils"
import { InventoryItem } from "./inventory-item"
@@ -26,17 +29,21 @@ const InventoryLevelDeletedAtIndex = createPsqlIndexStatementHelper({
const InventoryLevelInventoryItemIdIndex = createPsqlIndexStatementHelper({
tableName: "inventory_level",
columns: "inventory_item_id",
where: "deleted_at IS NULL",
})
const InventoryLevelLocationIdIndex = createPsqlIndexStatementHelper({
tableName: "inventory_level",
columns: "location_id",
where: "deleted_at IS NULL",
})
const InventoryLevelLocationIdInventoryItemIdIndex =
createPsqlIndexStatementHelper({
tableName: "inventory_level",
columns: "location_id",
columns: ["inventory_item_id", "location_id"],
unique: true,
where: "deleted_at IS NULL",
})
@Entity()
@@ -78,14 +85,23 @@ export class InventoryLevel {
@Property({ type: "text" })
location_id: string
@Property({ type: "int" })
stocked_quantity: number = 0
@MikroOrmBigNumberProperty()
stocked_quantity: BigNumber | number = 0
@Property({ type: "int" })
reserved_quantity: number = 0
@Property({ columnType: "jsonb" })
raw_stocked_quantity: BigNumberRawValue
@Property({ type: "int" })
incoming_quantity: number = 0
@MikroOrmBigNumberProperty()
reserved_quantity: BigNumber | number = 0
@Property({ columnType: "jsonb" })
raw_reserved_quantity: BigNumberRawValue
@MikroOrmBigNumberProperty()
incoming_quantity: BigNumber | number = 0
@Property({ columnType: "jsonb" })
raw_incoming_quantity: BigNumberRawValue
@Property({ columnType: "jsonb", nullable: true })
metadata: Record<string, unknown> | null
@@ -95,7 +111,7 @@ export class InventoryLevel {
})
inventory_item: Rel<InventoryItem>
available_quantity: number | null = null
available_quantity: BigNumber | number | null = null
@BeforeCreate()
private beforeCreate(): void {
@@ -111,7 +127,9 @@ export class InventoryLevel {
@OnLoad()
private onLoad(): void {
if (isDefined(this.stocked_quantity) && isDefined(this.reserved_quantity)) {
this.available_quantity = this.stocked_quantity - this.reserved_quantity
this.available_quantity = new BigNumber(
MathBN.sub(this.raw_stocked_quantity, this.raw_reserved_quantity)
)
}
}
}

View File

@@ -9,8 +9,11 @@ import {
Rel,
} from "@mikro-orm/core"
import { BigNumberRawValue } from "@medusajs/types"
import {
BigNumber,
DALUtils,
MikroOrmBigNumberProperty,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
@@ -24,16 +27,19 @@ const ReservationItemDeletedAtIndex = createPsqlIndexStatementHelper({
const ReservationItemLineItemIdIndex = createPsqlIndexStatementHelper({
tableName: "reservation_item",
columns: "line_item_id",
where: "deleted_at IS NULL",
})
const ReservationItemInventoryItemIdIndex = createPsqlIndexStatementHelper({
tableName: "reservation_item",
columns: "inventory_item_id",
where: "deleted_at IS NULL",
})
const ReservationItemLocationIdIndex = createPsqlIndexStatementHelper({
tableName: "reservation_item",
columns: "location_id",
where: "deleted_at IS NULL",
})
@Entity()
@@ -72,8 +78,11 @@ export class ReservationItem {
@Property({ type: "text" })
location_id: string
@Property({ columnType: "integer" })
quantity: number
@MikroOrmBigNumberProperty()
quantity: BigNumber | number
@Property({ columnType: "jsonb" })
raw_quantity: BigNumberRawValue
@Property({ type: "text", nullable: true })
external_id: string | null = null

View File

@@ -1,7 +1,11 @@
import { Context } from "@medusajs/types"
import { InventoryLevel } from "@models"
import {
BigNumber,
MathBN,
mikroOrmBaseRepositoryFactory,
} from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { mikroOrmBaseRepositoryFactory } from "@medusajs/utils"
import { InventoryLevel } from "@models"
export class InventoryLevelRepository extends mikroOrmBaseRepositoryFactory(
InventoryLevel
@@ -10,42 +14,42 @@ export class InventoryLevelRepository extends mikroOrmBaseRepositoryFactory(
inventoryItemId: string,
locationIds: string[],
context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
const manager = super.getActiveManager<SqlEntityManager>(context)
const [result] = (await manager
const result = await manager
.getKnex()({ il: "inventory_level" })
.sum("reserved_quantity")
.select("raw_reserved_quantity")
.whereIn("location_id", locationIds)
.andWhere("inventory_item_id", inventoryItemId)) as {
sum: string
}[]
.andWhere("inventory_item_id", inventoryItemId)
.andWhereRaw("deleted_at IS NULL")
return parseInt(result.sum)
return new BigNumber(
MathBN.sum(...result.map((r) => r.raw_reserved_quantity))
)
}
async getAvailableQuantity(
inventoryItemId: string,
locationIds: string[],
context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
const knex = super.getActiveManager<SqlEntityManager>(context).getKnex()
const [result] = (await knex({
const result = await knex({
il: "inventory_level",
})
.sum({
stocked_quantity: "stocked_quantity",
reserved_quantity: "reserved_quantity",
})
.select("raw_stocked_quantity", "raw_reserved_quantity")
.whereIn("location_id", locationIds)
.andWhere("inventory_item_id", inventoryItemId)) as {
reserved_quantity: string
stocked_quantity: string
}[]
.andWhere("inventory_item_id", inventoryItemId)
.andWhereRaw("deleted_at IS NULL")
return (
parseInt(result.stocked_quantity) - parseInt(result.reserved_quantity)
return new BigNumber(
MathBN.sum(
...result.map((r) => {
return MathBN.sub(r.raw_stocked_quantity, r.raw_reserved_quantity)
})
)
)
}
@@ -53,20 +57,19 @@ export class InventoryLevelRepository extends mikroOrmBaseRepositoryFactory(
inventoryItemId: string,
locationIds: string[],
context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
const knex = super.getActiveManager<SqlEntityManager>(context).getKnex()
const [result] = (await knex({
const result = await knex({
il: "inventory_level",
})
.sum({
stocked_quantity: "stocked_quantity",
})
.select("raw_stocked_quantity")
.whereIn("location_id", locationIds)
.andWhere("inventory_item_id", inventoryItemId)) as {
stocked_quantity: string
}[]
.andWhere("inventory_item_id", inventoryItemId)
.andWhereRaw("deleted_at IS NULL")
return parseInt(result.stocked_quantity)
return new BigNumber(
MathBN.sum(...result.map((r) => r.raw_stocked_quantity))
)
}
}

View File

@@ -1,5 +1,5 @@
import { Context } from "@medusajs/types"
import { ModulesSdkUtils } from "@medusajs/utils"
import { BigNumber, ModulesSdkUtils } from "@medusajs/utils"
import { InventoryLevelRepository } from "@repositories"
import { InventoryLevel } from "../models/inventory-level"
@@ -23,7 +23,7 @@ export default class InventoryLevelService extends ModulesSdkUtils.MedusaInterna
inventoryItemId: string,
locationIds: string[] | string,
context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
const locationIdArray = Array.isArray(locationIds)
? locationIds
: [locationIds]
@@ -39,7 +39,7 @@ export default class InventoryLevelService extends ModulesSdkUtils.MedusaInterna
inventoryItemId: string,
locationIds: string[] | string,
context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
const locationIdArray = Array.isArray(locationIds)
? locationIds
: [locationIds]

View File

@@ -1,5 +1,6 @@
import { InternalModuleDeclaration } from "@medusajs/modules-sdk"
import {
BigNumberInput,
Context,
DAL,
InventoryTypes,
@@ -11,19 +12,20 @@ import {
} from "@medusajs/types"
import { IInventoryService } from "@medusajs/types/dist/inventory"
import {
arrayDifference,
BigNumber,
CommonEvents,
EmitEvents,
InjectManager,
InjectTransactionManager,
InventoryEvents,
isDefined,
isString,
MathBN,
MedusaContext,
MedusaError,
MedusaService,
arrayDifference,
isDefined,
isString,
partitionArray,
promiseAll,
} from "@medusajs/utils"
import { InventoryItem, InventoryLevel, ReservationItem } from "@models"
import { joinerConfig } from "../joiner-config"
@@ -36,6 +38,14 @@ type InjectedDependencies = {
reservationItemService: ModulesSdkTypes.IMedusaInternalService<any>
}
type InventoryItemCheckLevel = {
id?: string
location_id: string
inventory_item_id: string
quantity?: BigNumberInput
allow_backorder?: boolean
}
export default class InventoryModuleService
extends MedusaService<{
InventoryItem: {
@@ -84,14 +94,23 @@ export default class InventoryModuleService
}
private async ensureInventoryLevels(
data: (
| { location_id: string; inventory_item_id: string }
| { id: string }
)[],
context: Context
data: InventoryItemCheckLevel[],
options?: {
validateQuantityAtLocation?: boolean
},
context?: Context
): Promise<InventoryTypes.InventoryLevelDTO[]> {
options ??= {}
const validateQuantityAtLocation =
options.validateQuantityAtLocation ?? false
const data_ = data.map((dt: any) => ({
location_id: dt.location_id,
inventory_item_id: dt.inventory_item_id,
})) as InventoryItemCheckLevel[]
const [idData, itemLocationData] = partitionArray(
data,
data_,
({ id }) => !!id
) as [
{ id: string }[],
@@ -122,13 +141,14 @@ export default class InventoryModuleService
return acc
}, new Map())
const missing = data.filter((i) => {
if ("id" in i) {
return !inventoryLevelIdMap.has(i.id)
const missing = data.filter((item) => {
if (item.id) {
return !inventoryLevelIdMap.has(item.id!)
}
return !inventoryLevelItemLocationMap
.get(i.inventory_item_id)
?.has(i.location_id)
.get(item.inventory_item_id)
?.has(item.location_id)
})
if (missing.length) {
@@ -144,6 +164,27 @@ export default class InventoryModuleService
throw new MedusaError(MedusaError.Types.NOT_FOUND, error)
}
if (validateQuantityAtLocation) {
for (const item of data) {
if (!!item.allow_backorder) {
continue
}
const locations = inventoryLevelItemLocationMap.get(
item.inventory_item_id
)!
const level = locations?.get(item.location_id)!
if (MathBN.lt(level.available_quantity, item.quantity!)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Not enough stock available for item ${item.inventory_item_id} at location ${item.location_id}`
)
}
}
}
return inventoryLevels
}
@@ -151,7 +192,7 @@ export default class InventoryModuleService
// We sanitize the inputs here to prevent that from being used to update it
private sanitizeInventoryLevelInput<TDTO = unknown>(
input: (TDTO & {
reserved_quantity?: number
reserved_quantity?: BigNumberInput
})[]
): TDTO[] {
return input.map((input) => {
@@ -173,37 +214,6 @@ export default class InventoryModuleService
})
}
private async ensureInventoryAvailability(
data: {
allow_backorder: boolean
inventory_item_id: string
location_id: string
quantity: number
}[],
context: Context
) {
const checkLevels = data.map(async (reservation) => {
if (!!reservation.allow_backorder) {
return
}
const available = await this.retrieveAvailableQuantity(
reservation.inventory_item_id,
[reservation.location_id],
context
)
if (available < reservation.quantity) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Not enough stock available for item ${reservation.inventory_item_id} at location ${reservation.location_id}`
)
}
})
await promiseAll(checkLevels)
}
// @ts-ignore
async createReservationItems(
input: InventoryTypes.CreateReservationItemInput[],
@@ -225,13 +235,6 @@ export default class InventoryModuleService
InventoryTypes.ReservationItemDTO[] | InventoryTypes.ReservationItemDTO
> {
const toCreate = Array.isArray(input) ? input : [input]
const sanitized = toCreate.map((d) => ({
...d,
allow_backorder: d.allow_backorder || false,
}))
await this.ensureInventoryAvailability(sanitized, context)
const created = await this.createReservationItems_(toCreate, context)
context.messageAggregator?.saveRawMessageData(
@@ -262,10 +265,17 @@ export default class InventoryModuleService
@MedusaContext() context: Context = {}
): Promise<ReservationItem[]> {
const inventoryLevels = await this.ensureInventoryLevels(
input.map(({ location_id, inventory_item_id }) => ({
location_id,
inventory_item_id,
})),
input.map(
({ location_id, inventory_item_id, quantity, allow_backorder }) => ({
location_id,
inventory_item_id,
quantity,
allow_backorder,
})
),
{
validateQuantityAtLocation: true,
},
context
)
const created = await this.reservationItemService_.create(input, context)
@@ -275,7 +285,7 @@ export default class InventoryModuleService
const locationMap = acc.get(curr.inventory_item_id) ?? new Map()
const adjustment = locationMap.get(curr.location_id) ?? 0
locationMap.set(curr.location_id, adjustment + curr.quantity)
locationMap.set(curr.location_id, MathBN.add(adjustment, curr.quantity))
acc.set(curr.inventory_item_id, locationMap)
return acc
@@ -294,7 +304,7 @@ export default class InventoryModuleService
return {
id: level.id,
reserved_quantity: level.reserved_quantity + adjustment,
reserved_quantity: MathBN.add(level.reserved_quantity, adjustment),
}
})
@@ -581,6 +591,7 @@ export default class InventoryModuleService
location_id,
inventory_item_id,
})),
undefined,
context
)
@@ -682,21 +693,6 @@ export default class InventoryModuleService
reservationItems.map((r) => [r.id, r])
)
const availabilityData = input.map((data) => {
const reservation = reservationMap.get(data.id)!
return {
...data,
quantity: data.quantity ?? reservation.quantity,
allow_backorder:
data.allow_backorder || reservation.allow_backorder || false,
inventory_item_id: reservation.inventory_item_id,
location_id: data.location_id ?? reservation.location_id,
}
})
await this.ensureInventoryAvailability(availabilityData, context)
const adjustments: Map<string, Map<string, number>> = input.reduce(
(acc, update) => {
const reservation = reservationMap.get(update.id)!
@@ -711,7 +707,7 @@ export default class InventoryModuleService
locationMap.set(
reservation.location_id,
reservationLocationAdjustment - reservation.quantity
MathBN.sub(reservationLocationAdjustment, reservation.quantity)
)
const updateLocationAdjustment =
@@ -719,18 +715,24 @@ export default class InventoryModuleService
locationMap.set(
update.location_id,
updateLocationAdjustment + (update.quantity || reservation.quantity)
MathBN.add(
updateLocationAdjustment,
update.quantity || reservation.quantity
)
)
} else if (
isDefined(update.quantity) &&
update.quantity !== reservation.quantity
!MathBN.eq(update.quantity, reservation.quantity)
) {
const locationAdjustment =
locationMap.get(reservation.location_id) ?? 0
locationMap.set(
reservation.location_id,
locationAdjustment + (update.quantity! - reservation.quantity)
MathBN.add(
locationAdjustment,
MathBN.sub(update.quantity!, reservation.quantity)
)
)
}
@@ -740,17 +742,28 @@ export default class InventoryModuleService
},
new Map()
)
const availabilityData = input.map((data) => {
const reservation = reservationMap.get(data.id)!
const result = await this.reservationItemService_.update(input, context)
return {
inventory_item_id: reservation.inventory_item_id,
location_id: data.location_id ?? reservation.location_id,
quantity: data.quantity ?? reservation.quantity,
allow_backorder:
data.allow_backorder || reservation.allow_backorder || false,
}
})
const inventoryLevels = await this.ensureInventoryLevels(
reservationItems.map((r) => ({
inventory_item_id: r.inventory_item_id,
location_id: r.location_id,
})),
availabilityData,
{
validateQuantityAtLocation: true,
},
context
)
const result = await this.reservationItemService_.update(input, context)
const levelAdjustmentUpdates = inventoryLevels
.map((level) => {
const adjustment = adjustments
@@ -763,7 +776,7 @@ export default class InventoryModuleService
return {
id: level.id,
reserved_quantity: level.reserved_quantity + adjustment,
reserved_quantity: MathBN.add(level.reserved_quantity, adjustment),
}
})
.filter(Boolean)
@@ -932,7 +945,7 @@ export default class InventoryModuleService
adjustInventory(
inventoryItemId: string,
locationId: string,
adjustment: number,
adjustment: BigNumberInput,
context: Context
): Promise<InventoryTypes.InventoryLevelDTO>
@@ -940,7 +953,7 @@ export default class InventoryModuleService
data: {
inventoryItemId: string
locationId: string
adjustment: number
adjustment: BigNumberInput
}[],
context: Context
): Promise<InventoryTypes.InventoryLevelDTO[]>
@@ -950,7 +963,7 @@ export default class InventoryModuleService
async adjustInventory(
inventoryItemIdOrData: string | any,
locationId?: string | Context,
adjustment?: number,
adjustment?: BigNumberInput,
@MedusaContext() context: Context = {}
): Promise<
InventoryTypes.InventoryLevelDTO | InventoryTypes.InventoryLevelDTO[]
@@ -1000,7 +1013,7 @@ export default class InventoryModuleService
async adjustInventory_(
inventoryItemId: string,
locationId: string,
adjustment: number,
adjustment: BigNumberInput,
@MedusaContext() context: Context = {}
): Promise<InventoryLevel> {
const inventoryLevel = await this.retrieveInventoryLevelByItemAndLocation(
@@ -1012,7 +1025,10 @@ export default class InventoryModuleService
const result = await this.inventoryLevelService_.update(
{
id: inventoryLevel.id,
stocked_quantity: inventoryLevel.stocked_quantity + adjustment,
stocked_quantity: MathBN.add(
inventoryLevel.stocked_quantity,
adjustment
),
},
context
)
@@ -1026,9 +1042,9 @@ export default class InventoryModuleService
locationId: string,
@MedusaContext() context: Context = {}
): Promise<InventoryTypes.InventoryLevelDTO> {
const [inventoryLevel] = await this.listInventoryLevels(
const inventoryLevel = await this.listInventoryLevels(
{ inventory_item_id: inventoryItemId, location_id: locationId },
{ take: 1 },
{ take: null },
context
)
@@ -1039,7 +1055,7 @@ export default class InventoryModuleService
)
}
return inventoryLevel
return inventoryLevel[0]
}
/**
@@ -1055,9 +1071,9 @@ export default class InventoryModuleService
inventoryItemId: string,
locationIds: string[],
@MedusaContext() context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
if (locationIds.length === 0) {
return 0
return new BigNumber(0)
}
await this.inventoryItemService_.retrieve(
@@ -1091,9 +1107,9 @@ export default class InventoryModuleService
inventoryItemId: string,
locationIds: string[],
@MedusaContext() context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
if (locationIds.length === 0) {
return 0
return new BigNumber(0)
}
// Throws if item does not exist
@@ -1128,7 +1144,7 @@ export default class InventoryModuleService
inventoryItemId: string,
locationIds: string[],
@MedusaContext() context: Context = {}
): Promise<number> {
): Promise<BigNumber> {
// Throws if item does not exist
await this.inventoryItemService_.retrieve(
inventoryItemId,
@@ -1139,7 +1155,7 @@ export default class InventoryModuleService
)
if (locationIds.length === 0) {
return 0
return new BigNumber(0)
}
const reservedQuantity =
@@ -1164,7 +1180,7 @@ export default class InventoryModuleService
async confirmInventory(
inventoryItemId: string,
locationIds: string[],
quantity: number,
quantity: BigNumberInput,
@MedusaContext() context: Context = {}
): Promise<boolean> {
const availableQuantity = await this.retrieveAvailableQuantity(
@@ -1172,7 +1188,7 @@ export default class InventoryModuleService
locationIds,
context
)
return availableQuantity >= quantity
return MathBN.gte(availableQuantity, quantity)
}
private async adjustInventoryLevelsForReservationsDeletion(
@@ -1208,6 +1224,7 @@ export default class InventoryModuleService
inventory_item_id: r.inventory_item_id,
location_id: r.location_id,
})),
undefined,
context
)
@@ -1218,8 +1235,11 @@ export default class InventoryModuleService
const inventoryLevelMap = acc.get(curr.inventory_item_id) ?? new Map()
const adjustment = inventoryLevelMap.has(curr.location_id)
? inventoryLevelMap.get(curr.location_id) + curr.quantity * multiplier
: curr.quantity * multiplier
? MathBN.add(
inventoryLevelMap.get(curr.location_id),
MathBN.mult(curr.quantity, multiplier)
)
: MathBN.mult(curr.quantity, multiplier)
inventoryLevelMap.set(curr.location_id, adjustment)
acc.set(curr.inventory_item_id, inventoryLevelMap)
@@ -1237,7 +1257,7 @@ export default class InventoryModuleService
return {
id: level.id,
reserved_quantity: level.reserved_quantity + adjustment,
reserved_quantity: MathBN.add(level.reserved_quantity, adjustment),
}
})