From 748833383f4bafd05109dac7afa1286fe851cba3 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 4 Apr 2023 11:36:51 +0200 Subject: [PATCH] hotfix(medusa): Temporary multi-warehouse support for draft orders (#3665) * remove reservations from draft order creation and show correct inventory * add changeset * add integration tests * adjust inventory on payment if no inventory service is installed --- .changeset/four-maps-suffer.md | 5 + .../__tests__/inventory/order/draft-order.js | 185 ++++++++++++++++++ .../inventory/products/list-variants.js | 106 ++++++++++ .../details/create-fulfillment/item-table.tsx | 2 +- .../admin/draft-orders/register-payment.ts | 18 +- .../routes/admin/variants/list-variants.ts | 39 +++- 6 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 .changeset/four-maps-suffer.md create mode 100644 integration-tests/plugins/__tests__/inventory/order/draft-order.js create mode 100644 integration-tests/plugins/__tests__/inventory/products/list-variants.js diff --git a/.changeset/four-maps-suffer.md b/.changeset/four-maps-suffer.md new file mode 100644 index 0000000000..7cfd48c81d --- /dev/null +++ b/.changeset/four-maps-suffer.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): draft order adjustments for mw diff --git a/integration-tests/plugins/__tests__/inventory/order/draft-order.js b/integration-tests/plugins/__tests__/inventory/order/draft-order.js new file mode 100644 index 0000000000..6fccd061f0 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/order/draft-order.js @@ -0,0 +1,185 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const adminSeeder = require("../../../helpers/admin-seeder") +const cartSeeder = require("../../../helpers/cart-seeder") +const { + simpleProductFactory, + simpleCustomerFactory, +} = require("../../../../api/factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") +const { + simpleOrderFactory, + simpleRegionFactory, + simpleCartFactory, + simpleShippingOptionFactory, +} = require("../../../factories") +const { + simpleDiscountFactory, +} = require("../../../factories/simple-discount-factory") +const draftOrderSeeder = require("../../../../api/helpers/draft-order-seeder") +const { + simpleAddressFactory, +} = require("../../../factories/simple-address-factory") + +jest.setTimeout(30000) + +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("/store/carts", () => { + let express + let appContainer + let dbConnection + + const doAfterEach = async () => { + const db = useDb() + return await db.teardown() + } + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + beforeEach(async () => {}) + + describe("POST /store/carts", () => { + const variantId = "test-variant" + + let region + let order + let invItemId + let prodVarInventoryService + let inventoryService + let lineItemService + let stockLocationService + let salesChannelLocationService + + let address + let shippingOption + let customer + + beforeEach(async () => { + await adminSeeder(dbConnection) + const api = useApi() + + prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + lineItemService = appContainer.resolve("lineItemService") + inventoryService = appContainer.resolve("inventoryService") + stockLocationService = appContainer.resolve("stockLocationService") + salesChannelLocationService = appContainer.resolve( + "salesChannelLocationService" + ) + + // create region + region = await simpleRegionFactory(dbConnection, {}) + + // create product + const product = await simpleProductFactory(dbConnection, { + variants: [{ id: variantId }], + }) + + const location = await stockLocationService.create({ + name: "test-location", + }) + + const invItem = await inventoryService.createInventoryItem({ + sku: "test-sku", + }) + invItemId = invItem.id + + await inventoryService.createInventoryLevel({ + inventory_item_id: invItem.id, + location_id: location.id, + stocked_quantity: 10, + }) + + await prodVarInventoryService.attachInventoryItem(variantId, invItem.id) + + // create customer + customer = await simpleCustomerFactory(dbConnection, {}) + + address = await simpleAddressFactory(dbConnection, {}) + + // create shipping option + shippingOption = await simpleShippingOptionFactory(dbConnection, { + region_id: region.id, + }) + }) + + it("creates an order from a draft order and doesn't adjust reservations", async () => { + const api = useApi() + let inventoryItem = await api.get( + `/admin/inventory-items/${invItemId}`, + adminHeaders + ) + + expect(inventoryItem.data.inventory_item.location_levels.length).toEqual( + 1 + ) + let locationLevel = inventoryItem.data.inventory_item.location_levels[0] + + expect(locationLevel.stocked_quantity).toEqual(10) + expect(locationLevel.reserved_quantity).toEqual(0) + + const payload = { + email: "test@test.dk", + shipping_address: address.id, + discounts: [], + items: [ + { + variant_id: variantId, + quantity: 2, + metadata: {}, + }, + ], + region_id: region.id, + customer_id: customer.id, + shipping_methods: [ + { + option_id: shippingOption.id, + }, + ], + } + + const createResponse = await api.post( + "/admin/draft-orders", + payload, + adminHeaders + ) + expect(createResponse.status).toEqual(200) + + const registerPaymentResponse = await api.post( + `/admin/draft-orders/${createResponse.data.draft_order.id}/pay`, + payload, + adminHeaders + ) + expect(registerPaymentResponse.status).toEqual(200) + + inventoryItem = await api.get( + `/admin/inventory-items/${invItemId}`, + adminHeaders + ) + + expect(inventoryItem.data.inventory_item.location_levels.length).toEqual( + 1 + ) + locationLevel = inventoryItem.data.inventory_item.location_levels[0] + + expect(locationLevel.stocked_quantity).toEqual(10) + expect(locationLevel.reserved_quantity).toEqual(0) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/products/list-variants.js b/integration-tests/plugins/__tests__/inventory/products/list-variants.js new file mode 100644 index 0000000000..42c617f175 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/products/list-variants.js @@ -0,0 +1,106 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const { + ProductVariantInventoryService, + ProductVariantService, +} = require("@medusajs/medusa") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("List Variants", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Items", () => { + const variantId = "test-variant" + + beforeEach(async () => { + await adminSeeder(dbConnection) + + const salesChannelLocationService = appContainer.resolve( + "salesChannelLocationService" + ) + const salesChannelService = appContainer.resolve("salesChannelService") + const inventoryService = appContainer.resolve("inventoryService") + const stockLocationService = appContainer.resolve("stockLocationService") + const prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const location = await stockLocationService.create({ + name: "test-location", + }) + + const salesChannel = await simpleSalesChannelFactory(dbConnection, {}) + + const product = await simpleProductFactory(dbConnection, { + variants: [{ id: variantId }], + }) + + await salesChannelService.addProducts(salesChannel.id, [product.id]) + await salesChannelLocationService.associateLocation( + salesChannel.id, + location.id + ) + + const invItem = await inventoryService.createInventoryItem({ + sku: "test-sku", + }) + const invItemId = invItem.id + + await prodVarInventoryService.attachInventoryItem(variantId, invItem.id) + + await inventoryService.createInventoryLevel({ + inventory_item_id: invItem.id, + location_id: location.id, + stocked_quantity: 10, + }) + }) + it("Decorates inventory quantities when listing variants", async () => { + const api = useApi() + + const listVariantsRes = await api.get(`/admin/variants`, adminHeaders) + + expect(listVariantsRes.status).toEqual(200) + expect(listVariantsRes.data.variants.length).toEqual(1) + expect(listVariantsRes.data.variants[0]).toEqual( + expect.objectContaining({ id: variantId, inventory_quantity: 10 }) + ) + }) + }) +}) diff --git a/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx b/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx index 19593b40fa..8207de33d6 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx @@ -125,7 +125,7 @@ const FulfillmentLine = ({ const validQuantity = !locationId || (locationId && - (!availableQuantity || quantities[item.id] < availableQuantity)) + (!availableQuantity || quantities[item.id] <= availableQuantity)) React.useEffect(() => { setErrors((errors) => { diff --git a/packages/medusa/src/api/routes/admin/draft-orders/register-payment.ts b/packages/medusa/src/api/routes/admin/draft-orders/register-payment.ts index 541d0e98e5..3c70863820 100644 --- a/packages/medusa/src/api/routes/admin/draft-orders/register-payment.ts +++ b/packages/medusa/src/api/routes/admin/draft-orders/register-payment.ts @@ -5,14 +5,14 @@ import { PaymentProviderService, ProductVariantInventoryService, } from "../../../../services" - -import { MedusaError } from "medusa-core-utils" -import { EntityManager } from "typeorm" -import { Order } from "../../../../models" import { defaultAdminOrdersFields as defaultOrderFields, defaultAdminOrdersRelations as defaultOrderRelations, } from "../../../../types/orders" + +import { EntityManager } from "typeorm" +import { MedusaError } from "medusa-core-utils" +import { Order } from "../../../../models" import { cleanResponseData } from "../../../../utils/clean-response-data" /** @@ -76,6 +76,7 @@ export default async (req, res) => { "paymentProviderService" ) const orderService: OrderService = req.scope.resolve("orderService") + const inventoryService: OrderService = req.scope.resolve("inventoryService") const cartService: CartService = req.scope.resolve("cartService") const productVariantInventoryService: ProductVariantInventoryService = req.scope.resolve("productVariantInventoryService") @@ -113,9 +114,12 @@ export default async (req, res) => { select: defaultOrderFields, }) - await reserveQuantityForDraftOrder(order, { - productVariantInventoryService, - }) + // TODO: Re-enable when we have a way to handle inventory for draft orders on creation + if (!inventoryService) { + await reserveQuantityForDraftOrder(order, { + productVariantInventoryService, + }) + } return order }) diff --git a/packages/medusa/src/api/routes/admin/variants/list-variants.ts b/packages/medusa/src/api/routes/admin/variants/list-variants.ts index f4e323925b..9467ec2e52 100644 --- a/packages/medusa/src/api/routes/admin/variants/list-variants.ts +++ b/packages/medusa/src/api/routes/admin/variants/list-variants.ts @@ -1,16 +1,20 @@ -import { IsInt, IsOptional, IsString } from "class-validator" - -import { Type } from "class-transformer" -import { omit } from "lodash" import { CartService, PricingService, + ProductVariantInventoryService, RegionService, + SalesChannelService, } from "../../../../services" -import ProductVariantService from "../../../../services/product-variant" -import { NumericalComparisonOperator } from "../../../../types/common" +import { IsInt, IsOptional, IsString } from "class-validator" + import { AdminPriceSelectionParams } from "../../../../types/price-selection" +import { IInventoryService } from "@medusajs/types" import { IsType } from "../../../../utils/validators/is-type" +import { NumericalComparisonOperator } from "../../../../types/common" +import { PricedVariant } from "../../../../types/pricing" +import ProductVariantService from "../../../../services/product-variant" +import { Type } from "class-transformer" +import { omit } from "lodash" /** * @oas [get] /admin/variants @@ -143,7 +147,7 @@ export default async (req, res) => { currencyCode = region.currency_code } - const variants = await pricingService.setVariantPrices(rawVariants, { + let variants = await pricingService.setVariantPrices(rawVariants, { cart_id: req.validatedQuery.cart_id, region_id: regionId, currency_code: currencyCode, @@ -152,6 +156,27 @@ export default async (req, res) => { ignore_cache: true, }) + const inventoryService: IInventoryService | undefined = + req.scope.resolve("inventoryService") + + const salesChannelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") + + if (inventoryService) { + const [salesChannelsIds] = await salesChannelService.listAndCount( + {}, + { select: ["id"] } + ) + + variants = (await productVariantInventoryService.setVariantAvailability( + variants, + salesChannelsIds.map((salesChannel) => salesChannel.id) + )) as PricedVariant[] + } + res.json({ variants, count,