diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f38fc85d7..418d0947392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add metadata to page model - #6292 by @dominik-zeglen - Fix for unnecesary attributes validation while updating simple product - #6300 by @GrzegorzDerdak - Include order line total price to webhook payload - #6354 by @korycins +- Fix for fulfilling an order when product quantity equals allocated quantity - #6333 by @GrzegorzDerdak ## 2.10.2 diff --git a/saleor/graphql/order/dataloaders.py b/saleor/graphql/order/dataloaders.py new file mode 100644 index 00000000000..db087d02224 --- /dev/null +++ b/saleor/graphql/order/dataloaders.py @@ -0,0 +1,17 @@ +from collections import defaultdict + +from ...warehouse.models import Allocation +from ..core.dataloaders import DataLoader + + +class AllocationsByOrderLineIdLoader(DataLoader): + context_key = "allocations_by_orderline_id" + + def batch_load(self, keys): + allocations = Allocation.objects.filter(order_line__pk__in=keys) + order_lines_to_allocations = defaultdict(list) + + for allocation in allocations: + order_lines_to_allocations[allocation.order_line_id].append(allocation) + + return [order_lines_to_allocations[order_line_id] for order_line_id in keys] diff --git a/saleor/graphql/order/tests/test_order.py b/saleor/graphql/order/tests/test_order.py index 4e4159ea72d..e195b8a02fb 100644 --- a/saleor/graphql/order/tests/test_order.py +++ b/saleor/graphql/order/tests/test_order.py @@ -101,6 +101,13 @@ def test_orderline_query(staff_api_client, permission_manage_orders, fulfilled_o id } quantity + allocations { + id + quantity + warehouse { + id + } + } unitPrice { currency gross { @@ -126,23 +133,82 @@ def test_orderline_query(staff_api_client, permission_manage_orders, fulfilled_o response = staff_api_client.post_graphql(query) content = get_graphql_content(response) order_data = content["data"]["orders"]["edges"][0]["node"] - assert order_data["lines"][0]["thumbnail"] is None + first_order_data_line = order_data["lines"][0] variant_id = graphene.Node.to_global_id("ProductVariant", line.variant.pk) - assert order_data["lines"][0]["variant"]["id"] == variant_id - assert order_data["lines"][0]["quantity"] == line.quantity - assert order_data["lines"][0]["unitPrice"]["currency"] == line.unit_price.currency + + assert first_order_data_line["thumbnail"] is None + assert first_order_data_line["variant"]["id"] == variant_id + assert first_order_data_line["quantity"] == line.quantity + assert first_order_data_line["unitPrice"]["currency"] == line.unit_price.currency + expected_unit_price = Money( - amount=str(order_data["lines"][0]["unitPrice"]["gross"]["amount"]), + amount=str(first_order_data_line["unitPrice"]["gross"]["amount"]), currency="USD", ) - assert order_data["lines"][0]["totalPrice"]["currency"] == line.unit_price.currency + assert first_order_data_line["totalPrice"]["currency"] == line.unit_price.currency assert expected_unit_price == line.unit_price.gross + expected_total_price = Money( - amount=str(order_data["lines"][0]["totalPrice"]["gross"]["amount"]), + amount=str(first_order_data_line["totalPrice"]["gross"]["amount"]), currency="USD", ) assert expected_total_price == line.unit_price.gross * line.quantity + allocation = line.allocations.first() + allocation_id = graphene.Node.to_global_id("Allocation", allocation.pk) + warehouse_id = graphene.Node.to_global_id( + "Warehouse", allocation.stock.warehouse.pk + ) + assert first_order_data_line["allocations"] == [ + {"id": allocation_id, "quantity": 0, "warehouse": {"id": warehouse_id}} + ] + + +def test_order_line_with_allocations( + staff_api_client, permission_manage_orders, order_with_lines, +): + # given + order = order_with_lines + query = """ + query OrdersQuery { + orders(first: 1) { + edges { + node { + lines { + id + allocations { + id + quantity + warehouse { + id + } + } + } + } + } + } + } + """ + staff_api_client.user.user_permissions.add(permission_manage_orders) + + # when + response = staff_api_client.post_graphql(query) + + # then + content = get_graphql_content(response) + lines = content["data"]["orders"]["edges"][0]["node"]["lines"] + + for line in lines: + _, _id = graphene.Node.from_global_id(line["id"]) + order_line = order.lines.get(pk=_id) + allocations_from_query = { + allocation["quantity"] for allocation in line["allocations"] + } + allocations_from_db = set( + order_line.allocations.values_list("quantity_allocated", flat=True) + ) + assert allocations_from_query == allocations_from_db + def test_order_query( staff_api_client, permission_manage_orders, fulfilled_order, shipping_zone diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py index a2e57ebd079..07fa449ed76 100644 --- a/saleor/graphql/order/types.py +++ b/saleor/graphql/order/types.py @@ -4,7 +4,7 @@ from ...core.anonymize import obfuscate_address, obfuscate_email from ...core.exceptions import PermissionDenied -from ...core.permissions import AccountPermissions, OrderPermissions +from ...core.permissions import AccountPermissions, OrderPermissions, ProductPermissions from ...core.taxes import display_gross_prices from ...graphql.utils import get_user_or_app_from_context from ...order import OrderStatus, models @@ -18,7 +18,7 @@ from ..core.connection import CountableDjangoObjectType from ..core.types.common import Image from ..core.types.money import Money, TaxedMoney -from ..decorators import permission_required +from ..decorators import one_of_permissions_required, permission_required from ..giftcard.types import GiftCard from ..invoice.types import Invoice from ..meta.deprecated.resolvers import resolve_meta, resolve_private_meta @@ -26,7 +26,8 @@ from ..payment.types import OrderAction, Payment, PaymentChargeStatusEnum from ..product.types import ProductVariant from ..shipping.types import ShippingMethod -from ..warehouse.types import Warehouse +from ..warehouse.types import Allocation, Warehouse +from .dataloaders import AllocationsByOrderLineIdLoader from .enums import OrderEventsEmailsEnum, OrderEventsEnum from .utils import validate_draft_order @@ -255,6 +256,10 @@ class OrderLine(CountableDjangoObjectType): translated_variant_name = graphene.String( required=True, description="Variant name in the customer's language" ) + allocations = graphene.List( + graphene.NonNull(Allocation), + description="List of allocations across warehouses.", + ) class Meta: description = "Represents order line of particular order." @@ -299,6 +304,13 @@ def resolve_translated_product_name(root: models.OrderLine, _info): def resolve_translated_variant_name(root: models.OrderLine, _info): return root.translated_variant_name + @staticmethod + @one_of_permissions_required( + [ProductPermissions.MANAGE_PRODUCTS, OrderPermissions.MANAGE_ORDERS] + ) + def resolve_allocations(root: models.OrderLine, info): + return AllocationsByOrderLineIdLoader(info.context).load(root.id) + class Order(CountableDjangoObjectType): fulfillments = graphene.List( diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 2f4e6f5a556..d14b198ad3f 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -195,6 +195,12 @@ type AddressValidationData { postalCodePrefix: String } +type Allocation implements Node { + id: ID! + quantity: Int! + warehouse: Warehouse! +} + type App implements Node & ObjectWithMetadata { id: ID! name: String @@ -3142,6 +3148,7 @@ type OrderLine implements Node { variant: ProductVariant translatedProductName: String! translatedVariantName: String! + allocations: [Allocation!] } input OrderLineCreateInput { diff --git a/saleor/graphql/warehouse/types.py b/saleor/graphql/warehouse/types.py index 70063bfe09b..60442dd3b31 100644 --- a/saleor/graphql/warehouse/types.py +++ b/saleor/graphql/warehouse/types.py @@ -90,3 +90,32 @@ def resolve_quantity_allocated(root, *_args): return root.allocations.aggregate( quantity_allocated=Coalesce(Sum("quantity_allocated"), 0) )["quantity_allocated"] + + +class Allocation(CountableDjangoObjectType): + quantity = graphene.Int(required=True, description="Quantity allocated for orders.") + warehouse = graphene.Field( + Warehouse, required=True, description="The warehouse were items were allocated." + ) + + class Meta: + description = "Represents allocation." + model = models.Allocation + interfaces = [graphene.relay.Node] + only_fields = ["id"] + + @staticmethod + @one_of_permissions_required( + [ProductPermissions.MANAGE_PRODUCTS, OrderPermissions.MANAGE_ORDERS] + ) + def resolve_warehouse(root, *_args): + return root.stock.warehouse + + @staticmethod + @one_of_permissions_required( + [ProductPermissions.MANAGE_PRODUCTS, OrderPermissions.MANAGE_ORDERS] + ) + def resolve_quantity(root, *_args): + return root.stock.allocations.aggregate( + quantity_allocated=Coalesce(Sum("quantity_allocated"), 0) + )["quantity_allocated"]