Repository: saleor/saleor
Pull request: Order bulk create allow to import archive orders
Fix: https://github.com/saleor/saleor/issues/11720

To enable importing archive orders, we for now we introduce 2 changes to bulk create:
- allow to not provide user at all
- allow to skip variants

# Impact

- [ ] New migrations
- [ ] New/Updated API fields or mutations
- [ ] Deprecated API fields or mutations
- [ ] Removed API types, fields, or mutations

# Docs

<!-- Docs are stored in a separate repository: https://github.com/saleor/saleor-docs/. -->
<!-- Please provide a link to the PR that updates documentation for your changes. -->
<!-- If changes in docs are not required, please mention that in the description. -->

- [ ] Link to documentation: https://github.com/saleor/saleor-docs/pull/968

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

- [ ] Privileged queries and mutations are either absent or guarded by proper permission checks
- [ ] Database queries are optimized and the number of queries is constant
- [ ] Database migrations are either absent or optimized for zero downtime
- [ ] The changes are covered by test cases


Problem statement:
Allow to create Order with arbitrary product lines 
### What I'm trying to achieve

I want to create integration that imports orders from another commerce engine to Saleor.

I assume orders are already fulfilled or "archived" at some point (I don't modify them, I only transfer the history)

At the moment Order require relation to actual products (and probably - actual customer). These products don't exist, since they are not migrated/don't exist in Saleor (yet, or never will be).

### Describe a proposed solution
Either:
1. Create a separate entity like ArchivedOrder which will be the final status of existing order + allow to create such order with the API, where all products, customers etc can be created with mutation input (deep, not relations)
2. Extend existing API to allow creating orders with arbitrary lines and customers, probably with "fulfilled" status (or some extra status, to avoid invalid states).
3. ... or other solution I don't know about

It'd be great if this also comes with a bulk creation API

### Other solutions I've tried and won't work

### Screenshots or mockups
<!-- Please provide any illustrations that could help others understand the problem or the proposed solution. -->

Hints from issue discussion:
The [bulkOrderCreate](https://docs.saleor.io/docs/3.x/developer/bulks/bulk-orders) mutation should work in this case, the only thing we would need to change is not requiring a valid variant ID. In Saleor orders and their lines can exist without matching variants in the database, but this mutation requires that. Since this is still a Feature Preview, we could change that.

Commit message: add changelog

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6eaa5da8b1d..95ba723fc2d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,11 @@ All notable, unreleased changes to this project will be documented in this file.
 
 ### Breaking changes
 
+- **Feature preview breaking change**:
+  - `orderBulkCreate` now will attempt to create order with `IGNORE_FAILED` policy even if: - #14177 by @kadewu
+    - `User` cannot be resolved, and `email` wasn't provided.
+    - `Variant` wasn't provided.
+
 ### GraphQL API
 
 ### Saleor Apps
diff --git a/saleor/graphql/order/bulk_mutations/order_bulk_create.py b/saleor/graphql/order/bulk_mutations/order_bulk_create.py
index 0570cc9482e..1597927c038 100644
--- a/saleor/graphql/order/bulk_mutations/order_bulk_create.py
+++ b/saleor/graphql/order/bulk_mutations/order_bulk_create.py
@@ -1021,7 +1021,7 @@ def get_instances_related_to_order(
         order_data.shipping_address = shipping_address
         order_data.voucher = voucher
 
-        if not (user or user_email) or not channel or not billing_address:
+        if not channel or not billing_address:
             order_data.is_critical_error = True
 
         return
@@ -1518,7 +1518,17 @@ def create_single_order_line(
             object_storage=object_storage,
             path=f"lines.{index}",
         )
-        if not variant:
+        if variant is None and not order_line_input.get("product_name"):
+            order_data.errors.append(
+                OrderBulkError(
+                    message=(
+                        "Order line input must contain product name when"
+                        " no variant provided."
+                    ),
+                    path=f"lines.{index}",
+                    code=OrderBulkCreateErrorCode.REQUIRED,
+                )
+            )
             return None
 
         warehouse = cls.get_instance_with_errors(
@@ -1559,12 +1569,13 @@ def create_single_order_line(
             order=order_data.order,
             variant=variant,
             product_name=order_line_input.get("product_name") or variant.product.name,
-            variant_name=order_line_input.get("variant_name") or variant.name,
+            variant_name=order_line_input.get("variant_name")
+            or (variant.name if variant else ""),
             translated_product_name=order_line_input.get("translated_product_name")
             or "",
             translated_variant_name=order_line_input.get("translated_variant_name")
             or "",
-            product_variant_id=variant.get_global_id(),
+            product_variant_id=(variant.get_global_id() if variant else None),
             created_at=order_line_input["created_at"],
             is_shipping_required=order_line_input["is_shipping_required"],
             is_gift_card=order_line_input["is_gift_card"],
@@ -1650,8 +1661,6 @@ def create_single_fulfillment(
                 object_storage=object_storage,
                 path=path,
             )
-            if not variant:
-                return None
 
             warehouse = cls.get_instance_with_errors(
                 input=line_input,
@@ -1700,7 +1709,13 @@ def create_single_fulfillment(
                 )
                 return None
 
-            if order_line.line.variant.id != variant.id:
+            if (
+                variant
+                and order_line.line.variant
+                and order_line.line.variant.id != variant.id
+                or (variant and not order_line.line.variant)
+                or (not variant and order_line.line.variant)
+            ):
                 code = OrderBulkCreateErrorCode.ORDER_LINE_FULFILLMENT_LINE_MISMATCH
                 order_data.errors.append(
                     OrderBulkError(
@@ -1896,7 +1911,7 @@ def create_single_order(
             order_data.user.email
             if order_data.user
             else order_input["user"].get("email")
-        )
+        ) or ""
         order_data.order.collection_point = delivery_method.warehouse
         order_data.order.collection_point_name = delivery_method.warehouse_name
         order_data.order.shipping_method = delivery_method.shipping_method
diff --git a/saleor/graphql/order/tests/mutations/test_order_bulk_create.py b/saleor/graphql/order/tests/mutations/test_order_bulk_create.py
index 424e616eee3..34336b149f2 100644
--- a/saleor/graphql/order/tests/mutations/test_order_bulk_create.py
+++ b/saleor/graphql/order/tests/mutations/test_order_bulk_create.py
@@ -843,6 +843,120 @@ def test_order_bulk_create_multiple_lines(
     assert OrderLine.objects.count() == lines_count + 2
 
 
+def test_order_bulk_create_line_without_variant(
+    staff_api_client,
+    permission_manage_orders,
+    permission_manage_orders_import,
+    order_bulk_input,
+):
+    # given
+    lines_count = OrderLine.objects.count()
+
+    order = order_bulk_input
+    order["lines"][0]["variantId"] = None
+    order["lines"][0]["variantName"] = None
+    order["fulfillments"][0]["lines"][0]["variantId"] = None
+
+    staff_api_client.user.user_permissions.add(
+        permission_manage_orders_import,
+        permission_manage_orders,
+    )
+    variables = {
+        "orders": [order],
+        "stockUpdatePolicy": StockUpdatePolicyEnum.SKIP.name,
+        "errorPolicy": ErrorPolicyEnum.IGNORE_FAILED.name,
+    }
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_BULK_CREATE, variables)
+    content = get_graphql_content(response)
+
+    # then
+    assert content["data"]["orderBulkCreate"]["count"] == 1
+    assert len(content["data"]["orderBulkCreate"]["results"][0]["errors"]) == 2
+
+    order = content["data"]["orderBulkCreate"]["results"][0]["order"]
+
+    line_1 = order["lines"][0]
+    assert line_1["unitPrice"]["gross"]["amount"] == Decimal(120 / 5)
+    assert line_1["unitPrice"]["net"]["amount"] == Decimal(100 / 5)
+
+    db_lines = OrderLine.objects.all()
+    db_line_1 = db_lines[0]
+    assert db_line_1.unit_price.gross.amount == Decimal(120 / 5)
+    assert db_line_1.unit_price.net.amount == Decimal(100 / 5)
+
+    assert order["total"]["gross"]["amount"] == 120
+    assert order["total"]["net"]["amount"] == 100
+    db_order = Order.objects.get()
+    assert db_order.total_gross_amount == 120
+    assert db_order.total_net_amount == 100
+
+    error0 = content["data"]["orderBulkCreate"]["results"][0]["errors"][0]
+    assert error0["message"] == (
+        "One of [variant_id, variant_external_reference, variant_sku] arguments"
+        " must be provided to resolve ProductVariant instance."
+    )
+    assert error0["code"] == OrderBulkCreateErrorCode.REQUIRED.name
+    assert error0["path"] == "lines.0"
+
+    error1 = content["data"]["orderBulkCreate"]["results"][0]["errors"][1]
+    assert error1["message"] == (
+        "One of [variant_id, variant_external_reference, variant_sku] arguments"
+        " must be provided to resolve ProductVariant instance."
+    )
+    assert error1["code"] == OrderBulkCreateErrorCode.REQUIRED.name
+    assert error1["path"] == "fulfillments.0.lines.0"
+
+    assert OrderLine.objects.count() == lines_count + 1
+
+
+def test_order_bulk_create_line_without_variant_and_product_name_fails(
+    staff_api_client,
+    permission_manage_orders,
+    permission_manage_orders_import,
+    order_bulk_input,
+):
+    # given
+
+    order = order_bulk_input
+    order["lines"][0]["variantId"] = None
+    order["lines"][0]["productName"] = None
+
+    staff_api_client.user.user_permissions.add(
+        permission_manage_orders_import,
+        permission_manage_orders,
+    )
+    variables = {
+        "orders": [order],
+        "stockUpdatePolicy": StockUpdatePolicyEnum.SKIP.name,
+        "errorPolicy": ErrorPolicyEnum.IGNORE_FAILED.name,
+    }
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_BULK_CREATE, variables)
+    content = get_graphql_content(response)
+
+    # then
+    assert content["data"]["orderBulkCreate"]["count"] == 0
+    assert len(content["data"]["orderBulkCreate"]["results"][0]["errors"]) == 2
+
+    error0 = content["data"]["orderBulkCreate"]["results"][0]["errors"][0]
+    assert error0["message"] == (
+        "One of [variant_id, variant_external_reference, variant_sku] arguments"
+        " must be provided to resolve ProductVariant instance."
+    )
+    assert error0["code"] == OrderBulkCreateErrorCode.REQUIRED.name
+    assert error0["path"] == "lines.0"
+
+    error1 = content["data"]["orderBulkCreate"]["results"][0]["errors"][1]
+    assert error1["message"] == (
+        "Order line input must contain product name when no variant provided."
+    )
+    assert error1["code"] == OrderBulkCreateErrorCode.REQUIRED.name
+    assert error1["path"] == "lines.0"
+
+
 def test_order_bulk_create_multiple_notes(
     staff_api_client,
     permission_manage_orders,
@@ -2424,6 +2538,44 @@ def test_order_bulk_create_error_get_instance_with_no_keys(
     assert Order.objects.count() == orders_count
 
 
+def test_order_bulk_create_no_user_input_provided_ignore_failed_policy(
+    staff_api_client,
+    permission_manage_orders,
+    permission_manage_orders_import,
+    order_bulk_input,
+):
+    # given
+    order = order_bulk_input
+    order["user"]["id"] = None
+
+    staff_api_client.user.user_permissions.add(
+        permission_manage_orders_import,
+        permission_manage_orders,
+    )
+    variables = {
+        "orders": [order],
+        "errorPolicy": ErrorPolicyEnum.IGNORE_FAILED.name,
+        "stockUpdatePolicy": StockUpdatePolicyEnum.SKIP.name,
+    }
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_BULK_CREATE, variables)
+    content = get_graphql_content(response)
+
+    # then
+    assert content["data"]["orderBulkCreate"]["count"] == 1
+    assert content["data"]["orderBulkCreate"]["results"][0]["order"]
+    error = content["data"]["orderBulkCreate"]["results"][0]["errors"][0]
+    assert (
+        error["message"] == "One of [id, email, external_reference] arguments"
+        " must be provided to resolve User instance."
+    )
+    assert error["path"] == "user"
+    assert error["code"] == OrderBulkCreateErrorCode.REQUIRED.name
+    db_order = Order.objects.get()
+    assert not db_order.user
+
+
 def test_order_bulk_create_error_invalid_quantity(
     staff_api_client,
     permission_manage_orders,


Code review comments:
diff_hunk: @@ -1700,7 +1709,13 @@ def create_single_fulfillment(
                 )
                 return None
 
-            if order_line.line.variant.id != variant.id:
+            if (
+                variant
+                and order_line.line.variant
+                and order_line.line.variant.id != variant.id
+                or (variant and not order_line.line.variant)
+                or (not variant and order_line.line.variant)
+            ):
path: saleor/graphql/order/bulk_mutations/order_bulk_create.py
text: @user1:
Could we split this into smaller part. I'm thinking about introducing variables to dedicated check, like: `line_variant_mismatch` and `missing_line_variant` and then use it here. What do you think?

@author:
Sure, it makes sense.