From 205469a6c7f5559344898d69b6da02315ec582bd Mon Sep 17 00:00:00 2001 From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:01:04 +0100 Subject: [PATCH] Shopify API 2026-01 uptake --- .../ShpfyCommunicationMgt.Codeunit.al | 2 +- .../ShpfyBulkOperationAPI.Codeunit.al | 19 - .../ShpfyBulkOperationMgt.Codeunit.al | 23 +- .../ShpfyGQLBulkOperations.Codeunit.al | 29 -- .../ShpfyGQLModifyInventory.Codeunit.al | 2 +- .../Codeunits/ShpfyGQLNextPayouts.Codeunit.al | 2 +- .../ShpfyGQLNextReturnLines.Codeunit.al | 2 +- .../Codeunits/ShpfyGQLPayouts.Codeunit.al | 2 +- .../Codeunits/ShpfyGQLReturnLines.Codeunit.al | 2 +- .../Codeunits/ShpfyGQLVariantById.Codeunit.al | 2 +- .../GraphQL/Enums/ShpfyGraphQLType.Enum.al | 5 - .../Codeunits/ShpfyInventoryAPI.Codeunit.al | 83 ++++- .../ShpfyMtfldTypeArticleRef.Codeunit.al | 34 ++ .../Enums/ShpfyMetafieldType.Enum.al | 6 + .../Codeunits/ShpfyReturnsAPI.Codeunit.al | 6 +- .../Pages/ShpfyReturnLines.Page.al | 11 + .../Tables/ShpfyReturnLine.Table.al | 22 ++ .../Codeunits/ShpfyPaymentsAPI.Codeunit.al | 3 +- .../src/Payments/Tables/ShpfyPayout.Table.al | 5 + .../ShpfyObjects.PermissionSet.al | 1 - .../Codeunits/ShpfyCreateProduct.Codeunit.al | 4 - .../Codeunits/ShpfyProductExport.Codeunit.al | 3 - .../Codeunits/ShpfyVariantAPI.Codeunit.al | 43 ++- .../src/Products/Pages/ShpfyVariants.Page.al | 6 + .../src/Products/Tables/ShpfyVariant.Table.al | 10 + .../CurrentBulkOperationCompletedResult.txt | 1 - .../CurrentBulkOperationRunningResult.txt | 1 - .../ShpfyBulkOpSubscriber.Codeunit.al | 30 -- .../ShpfyInventoryExportTest.Codeunit.al | 347 ++++++++++++++++++ .../ShpfyInventoryRetryScenario.Enum.al | 28 ++ .../ShpfyInventorySubscriber.Codeunit.al | 103 ++++++ .../ShpfyOrderRefundsHelper.Codeunit.al | 41 ++- .../Payments/ShpfyPaymentsTest.Codeunit.al | 56 +++ .../ShpfyVariantBatchingTest.Codeunit.al | 196 ++++++++++ 34 files changed, 972 insertions(+), 158 deletions(-) delete mode 100644 src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLBulkOperations.Codeunit.al create mode 100644 src/Apps/W1/Shopify/App/src/Metafields/Codeunits/IMetafieldType/ShpfyMtfldTypeArticleRef.Codeunit.al delete mode 100644 src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationCompletedResult.txt delete mode 100644 src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationRunningResult.txt create mode 100644 src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryExportTest.Codeunit.al create mode 100644 src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryRetryScenario.Enum.al create mode 100644 src/Apps/W1/Shopify/Test/Inventory/ShpfyInventorySubscriber.Codeunit.al create mode 100644 src/Apps/W1/Shopify/Test/Products/ShpfyVariantBatchingTest.Codeunit.al diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al index 62f4019655..1b30497ac6 100644 --- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al @@ -22,7 +22,7 @@ codeunit 30103 "Shpfy Communication Mgt." CommunicationEvents: Codeunit "Shpfy Communication Events"; GraphQLQueries: Codeunit "Shpfy GraphQL Queries"; NextExecutionTime: DateTime; - VersionTok: Label '2025-07', Locked = true; + VersionTok: Label '2026-01', Locked = true; OutgoingRequestsNotEnabledConfirmLbl: Label 'Importing data to your Shopify shop is not enabled, do you want to go to shop card to enable?'; OutgoingRequestsNotEnabledErr: Label 'Importing data to your Shopify shop is not enabled, navigate to shop card to enable.'; IsTestInProgress: Boolean; diff --git a/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationAPI.Codeunit.al b/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationAPI.Codeunit.al index f860462b81..0e2ed89009 100644 --- a/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationAPI.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationAPI.Codeunit.al @@ -22,25 +22,6 @@ codeunit 30278 "Shpfy Bulk Operation API" CommunicationMgt.SetShop(Shop); end; - internal procedure GetCurrentBulkRequest(var BulkOperationId: BigInteger; var Status: Enum "Shpfy Bulk Operation Status"; var ErrorCode: Text; var CompletedAt: DateTime; var Url: Text; var PartialDataUrl: Text) - var - JsonHelper: Codeunit "Shpfy Json Helper"; - GraphQLType: Enum "Shpfy GraphQL Type"; - Parameters: Dictionary of [Text, Text]; - JResponse: JsonToken; - JBulkOperation: JsonObject; - begin - JResponse := CommunicationMgt.ExecuteGraphQL(GraphQLType::GetCurrentBulkOperation, Parameters); - if JsonHelper.GetJsonObject(JResponse, JBulkOperation, 'data.currentBulkOperation') then begin - BulkOperationId := CommunicationMgt.GetIdOfGId(JsonHelper.GetValueAsText(JBulkOperation, 'id')); - Status := ConvertToBulkOperationStatus(JsonHelper.GetValueAsText(JBulkOperation, 'status')); - ErrorCode := JsonHelper.GetValueAsText(JBulkOperation, 'errorCode'); - CompletedAt := JsonHelper.GetValueAsDateTime(JBulkOperation, 'completedAt'); - Url := JsonHelper.GetValueAsText(JBulkOperation, 'url'); - PartialDataUrl := JsonHelper.GetValueAsText(JBulkOperation, 'partialDataUrl'); - end; - end; - internal procedure GetBulkRequest(BulkOperationId: BigInteger; var Status: Enum "Shpfy Bulk Operation Status"; var ErrorCode: Text; var CompletedAt: DateTime; var Url: Text; var PartialDataUrl: Text) var JsonHelper: Codeunit "Shpfy Json Helper"; diff --git a/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationMgt.Codeunit.al index 3efdaf0142..5b93622889 100644 --- a/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationMgt.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Bulk Operations/Codeunits/ShpfyBulkOperationMgt.Codeunit.al @@ -11,8 +11,6 @@ codeunit 30270 "Shpfy Bulk Operation Mgt." { var InvalidUserErr: Label 'You must sign in with a Business Central licensed user to enable the feature.'; - CategoryTok: Label 'Shopify Integration', Locked = true; - BulkOperationsDontMatchLbl: Label 'Searched bulk operation (%1, %2, %3) does not match with current one (%4)', Comment = '%1 = Bulk Operation Id, %2 = Shop Code, %3 = Type, %4 = Bulk Operation Id', Locked = true; BulkOperationCreatedLbl: Label 'A bulk request was sent to Shopify. You can check the status of the synchronization in the Shopify Bulk Operations page.'; internal procedure EnableBulkOperations(var Shop: Record "Shpfy Shop") @@ -112,15 +110,14 @@ codeunit 30270 "Shpfy Bulk Operation Mgt." var BulkOperation: Record "Shpfy Bulk Operation"; BulkOperationAPI: Codeunit "Shpfy Bulk Operation API"; - BulkOperationId: BigInteger; ErrorCode: Text; CompletedAt: DateTime; Url: Text; PartialDataUrl: Text; begin BulkOperationAPI.SetShop(Shop); - BulkOperationAPI.GetCurrentBulkRequest(BulkOperationId, BulkOperationStatus, ErrorCode, CompletedAt, Url, PartialDataUrl); - if BulkOperation.Get(BulkOperationId, Shop.Code, Type) then begin + BulkOperationAPI.GetBulkRequest(SearchBulkOperationId, BulkOperationStatus, ErrorCode, CompletedAt, Url, PartialDataUrl); + if BulkOperation.Get(SearchBulkOperationId, Shop.Code, Type) then begin BulkOperation.Status := BulkOperationStatus; if ErrorCode <> '' then BulkOperation."Error Code" := CopyStr(ErrorCode, 1, MaxStrLen(BulkOperation."Error Code")); @@ -131,22 +128,6 @@ codeunit 30270 "Shpfy Bulk Operation Mgt." if PartialDataUrl <> '' then BulkOperation."Partial Data Url" := CopyStr(PartialDataUrl, 1, MaxStrLen(BulkOperation."Partial Data Url")); BulkOperation.Modify(true); - - if BulkOperationId <> SearchBulkOperationId then begin - Session.LogMessage('0000KZC', StrSubstNo(BulkOperationsDontMatchLbl, SearchBulkOperationId, Shop.Code, Type, BulkOperationId), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); - BulkOperationAPI.GetBulkRequest(SearchBulkOperationId, BulkOperationStatus, ErrorCode, CompletedAt, Url, PartialDataUrl); - BulkOperation.Get(SearchBulkOperationId, Shop.Code, Type); - BulkOperation.Status := BulkOperationStatus; - if ErrorCode <> '' then - BulkOperation."Error Code" := CopyStr(ErrorCode, 1, MaxStrLen(BulkOperation."Error Code")); - if CompletedAt <> 0DT then - BulkOperation."Completed At" := CompletedAt; - if Url <> '' then - BulkOperation.Url := CopyStr(Url, 1, MaxStrLen(BulkOperation.Url)); - if PartialDataUrl <> '' then - BulkOperation."Partial Data Url" := CopyStr(PartialDataUrl, 1, MaxStrLen(BulkOperation."Partial Data Url")); - BulkOperation.Modify(true); - end; end; end; diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLBulkOperations.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLBulkOperations.Codeunit.al deleted file mode 100644 index af0a4d2520..0000000000 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLBulkOperations.Codeunit.al +++ /dev/null @@ -1,29 +0,0 @@ -// ------------------------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. -// ------------------------------------------------------------------------------------------------ - -namespace Microsoft.Integration.Shopify; - -codeunit 30277 "Shpfy GQL BulkOperations" implements "Shpfy IGraphQL" -{ - Access = Internal; - - /// - /// GetGraphQL. - /// - /// Return value of type Text. - internal procedure GetGraphQL(): Text - begin - exit('{"query": "query { currentBulkOperation(type: MUTATION) { id status errorCode createdAt completedAt fileSize url partialDataUrl }}"}'); - end; - - /// - /// GetExpectedCost. - /// - /// Return value of type Integer. - internal procedure GetExpectedCost(): Integer - begin - exit(1); - end; -} \ No newline at end of file diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLModifyInventory.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLModifyInventory.Codeunit.al index 8c1db4e02f..0e3d7ab1f8 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLModifyInventory.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLModifyInventory.Codeunit.al @@ -9,7 +9,7 @@ codeunit 30102 "Shpfy GQL Modify Inventory" implements "Shpfy IGraphQL" { procedure GetGraphQL(): Text begin - exit('{"query":"mutation inventorySetOnHandQuantities($input:InventorySetOnHandQuantitiesInput!) { inventorySetOnHandQuantities(input: $input) { userErrors { field message }}}","variables":{"input":{"reason":"correction","setQuantities":[]}}}'); + exit('{"query":"mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) { inventorySetQuantities(input: $input) @idempotent(key: \"{{IdempotencyKey}}\") { inventoryAdjustmentGroup { id } userErrors { field message code }}}","variables":{"input":{"name":"on_hand","reason":"correction","quantities":[]}}}'); end; procedure GetExpectedCost(): Integer diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextPayouts.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextPayouts.Codeunit.al index c07a419984..feef1f9531 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextPayouts.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextPayouts.Codeunit.al @@ -18,7 +18,7 @@ codeunit 30392 "Shpfy GQL NextPayouts" implements "Shpfy IGraphQL" /// Return value of type Text. procedure GetGraphQL(): Text begin - exit('{"query":"{ shopifyPaymentsAccount { payouts(first: 100, query: \"id:>={{SinceId}}\", after: \"{{After}}\") { edges { cursor node { id status summary { adjustmentsFee { amount } adjustmentsGross { amount } chargesFee { amount } chargesGross { amount } refundsFee { amount } refundsFeeGross { amount } reservedFundsFee { amount } reservedFundsGross { amount } retriedPayoutsFee { amount } retriedPayoutsGross { amount currencyCode } } issuedAt net { amount currencyCode } } } pageInfo { hasNextPage } } } }"}'); + exit('{"query":"{ shopifyPaymentsAccount { payouts(first: 100, query: \"id:>={{SinceId}}\", after: \"{{After}}\") { edges { cursor node { id status externalTraceId summary { adjustmentsFee { amount } adjustmentsGross { amount } chargesFee { amount } chargesGross { amount } refundsFee { amount } refundsFeeGross { amount } reservedFundsFee { amount } reservedFundsGross { amount } retriedPayoutsFee { amount } retriedPayoutsGross { amount currencyCode } } issuedAt net { amount currencyCode } } } pageInfo { hasNextPage } } } }"}'); end; /// diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextReturnLines.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextReturnLines.Codeunit.al index 756372ae45..1873c080a8 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextReturnLines.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLNextReturnLines.Codeunit.al @@ -10,7 +10,7 @@ codeunit 30227 "Shpfy GQL NextReturnLines" implements "Shpfy IGraphQL" internal procedure GetGraphQL(): Text begin - exit('{"query":"{ return(id: \"gid://shopify/Return/{{ReturnId}}\") { returnLineItems(first: 10, after:\"{{After}}\") { pageInfo { endCursor hasNextPage } nodes { id quantity returnReason returnReasonNote refundableQuantity refundedQuantity customerNote ... on UnverifiedReturnLineItem { __typename unitPrice { amount currencyCode } } ... on ReturnLineItem { __typename totalWeight { unit value } withCodeDiscountedTotalPriceSet { presentmentMoney { amount } shopMoney { amount } } fulfillmentLineItem { id lineItem { id } quantity originalTotalSet { presentmentMoney { amount } shopMoney { amount } } discountedTotalSet { presentmentMoney { amount } shopMoney { amount }}}}}}}}"}'); + exit('{"query":"{ return(id: \"gid://shopify/Return/{{ReturnId}}\") { returnLineItems(first: 10, after:\"{{After}}\") { pageInfo { endCursor hasNextPage } nodes { id quantity returnReasonDefinition { name handle } returnReasonNote refundableQuantity refundedQuantity customerNote ... on UnverifiedReturnLineItem { __typename unitPrice { amount currencyCode } } ... on ReturnLineItem { __typename totalWeight { unit value } withCodeDiscountedTotalPriceSet { presentmentMoney { amount } shopMoney { amount } } fulfillmentLineItem { id lineItem { id } quantity originalTotalSet { presentmentMoney { amount } shopMoney { amount } } discountedTotalSet { presentmentMoney { amount } shopMoney { amount }}}}}}}}"}'); end; internal procedure GetExpectedCost(): Integer diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLPayouts.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLPayouts.Codeunit.al index d28ed3aa6b..e5f259cc02 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLPayouts.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLPayouts.Codeunit.al @@ -18,7 +18,7 @@ codeunit 30391 "Shpfy GQL Payouts" implements "Shpfy IGraphQL" /// Return value of type Text. procedure GetGraphQL(): Text begin - exit('{"query":"{ shopifyPaymentsAccount { payouts(first: 100, query: \"id:>={{SinceId}}\") { edges { cursor node { id status summary { adjustmentsFee { amount } adjustmentsGross { amount } chargesFee { amount } chargesGross { amount } refundsFee { amount } refundsFeeGross { amount } reservedFundsFee { amount } reservedFundsGross { amount } retriedPayoutsFee { amount } retriedPayoutsGross { amount currencyCode } } issuedAt net { amount currencyCode } } } pageInfo { hasNextPage } } } }"}'); + exit('{"query":"{ shopifyPaymentsAccount { payouts(first: 100, query: \"id:>={{SinceId}}\") { edges { cursor node { id status externalTraceId summary { adjustmentsFee { amount } adjustmentsGross { amount } chargesFee { amount } chargesGross { amount } refundsFee { amount } refundsFeeGross { amount } reservedFundsFee { amount } reservedFundsGross { amount } retriedPayoutsFee { amount } retriedPayoutsGross { amount currencyCode } } issuedAt net { amount currencyCode } } } pageInfo { hasNextPage } } } }"}'); end; /// diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLReturnLines.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLReturnLines.Codeunit.al index f1b108c697..92e157c75a 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLReturnLines.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLReturnLines.Codeunit.al @@ -10,7 +10,7 @@ codeunit 30226 "Shpfy GQL ReturnLines" implements "Shpfy IGraphQL" internal procedure GetGraphQL(): Text begin - exit('{"query":"{ return(id: \"gid://shopify/Return/{{ReturnId}}\") { returnLineItems(first: 10) { pageInfo { endCursor hasNextPage } nodes { id quantity returnReason returnReasonNote refundableQuantity refundedQuantity customerNote ... on UnverifiedReturnLineItem { __typename unitPrice { amount currencyCode } } ... on ReturnLineItem { __typename totalWeight { unit value } withCodeDiscountedTotalPriceSet { presentmentMoney { amount } shopMoney { amount } } fulfillmentLineItem { id lineItem { id } quantity originalTotalSet { presentmentMoney { amount } shopMoney { amount } } discountedTotalSet { presentmentMoney { amount } shopMoney { amount }}}}}}}}"}'); + exit('{"query":"{ return(id: \"gid://shopify/Return/{{ReturnId}}\") { returnLineItems(first: 10) { pageInfo { endCursor hasNextPage } nodes { id quantity returnReasonDefinition { name handle } returnReasonNote refundableQuantity refundedQuantity customerNote ... on UnverifiedReturnLineItem { __typename unitPrice { amount currencyCode } } ... on ReturnLineItem { __typename totalWeight { unit value } withCodeDiscountedTotalPriceSet { presentmentMoney { amount } shopMoney { amount } } fulfillmentLineItem { id lineItem { id } quantity originalTotalSet { presentmentMoney { amount } shopMoney { amount } } discountedTotalSet { presentmentMoney { amount } shopMoney { amount }}}}}}}}"}'); end; internal procedure GetExpectedCost(): Integer diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLVariantById.Codeunit.al b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLVariantById.Codeunit.al index 85f306fa4e..5e5e86ae1f 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLVariantById.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Codeunits/ShpfyGQLVariantById.Codeunit.al @@ -18,7 +18,7 @@ codeunit 30150 "Shpfy GQL VariantById" implements "Shpfy IGraphQL" /// Return value of type Text. internal procedure GetGraphQL(): Text begin - exit('{"query":"{productVariant(id: \"gid://shopify/ProductVariant/{{VariantId}}\") {createdAt updatedAt availableForSale barcode compareAtPrice displayName inventoryPolicy position price sku taxCode taxable title product{id}selectedOptions{name value} inventoryItem{countryCodeOfOrigin createdAt id inventoryHistoryUrl legacyResourceId measurement { weight { value }} provinceCodeOfOrigin requiresShipping sku tracked updatedAt unitCost { amount currencyCode }} metafields(first: 50) {edges {node {id namespace type legacyResourceId key value}}}}}"}'); + exit('{"query":"{productVariant(id: \"gid://shopify/ProductVariant/{{VariantId}}\") {createdAt updatedAt availableForSale barcode compareAtPrice displayName inventoryPolicy position price sku taxable title product{id}selectedOptions{name value} inventoryItem{countryCodeOfOrigin createdAt id inventoryHistoryUrl legacyResourceId measurement { weight { value }} provinceCodeOfOrigin requiresShipping sku tracked updatedAt unitCost { amount currencyCode }} metafields(first: 50) {edges {node {id namespace type legacyResourceId key value}}}}}"}'); end; /// diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/Enums/ShpfyGraphQLType.Enum.al b/src/Apps/W1/Shopify/App/src/GraphQL/Enums/ShpfyGraphQLType.Enum.al index f682cdf682..2d11e93d8d 100644 --- a/src/Apps/W1/Shopify/App/src/GraphQL/Enums/ShpfyGraphQLType.Enum.al +++ b/src/Apps/W1/Shopify/App/src/GraphQL/Enums/ShpfyGraphQLType.Enum.al @@ -295,11 +295,6 @@ enum 30111 "Shpfy GraphQL Type" implements "Shpfy IGraphQL" Caption = 'Get Next Refund Lines'; Implementation = "Shpfy IGraphQL" = "Shpfy GQL NextRefundLines"; } - value(57; GetCurrentBulkOperation) - { - Caption = 'Get Current Bulk Operation'; - Implementation = "Shpfy IGraphQL" = "Shpfy GQL BulkOperations"; - } value(58; RunBulkOperationMutation) { Caption = 'Run Bulk Operation Mutation'; diff --git a/src/Apps/W1/Shopify/App/src/Inventory/Codeunits/ShpfyInventoryAPI.Codeunit.al b/src/Apps/W1/Shopify/App/src/Inventory/Codeunits/ShpfyInventoryAPI.Codeunit.al index 69248f3162..4b199ac927 100644 --- a/src/Apps/W1/Shopify/App/src/Inventory/Codeunits/ShpfyInventoryAPI.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Inventory/Codeunits/ShpfyInventoryAPI.Codeunit.al @@ -141,44 +141,95 @@ codeunit 30195 "Shpfy Inventory API" var IGraphQL: Interface "Shpfy IGraphQL"; JGraphQL: JsonObject; - JSetQuantities: JsonArray; - JSetQuantity: JsonObject; + JQuantities: JsonArray; + JQuantity: JsonObject; InputSize: Integer; begin if ShopInventory.FindSet() then begin IGraphQL := Enum::"Shpfy GraphQL Type"::ModifyInventory; JGraphQL.ReadFrom(IGraphQL.GetGraphQL()); - JSetQuantities := JsonHelper.GetJsonArray(JGraphQL, 'variables.input.setQuantities'); + JQuantities := JsonHelper.GetJsonArray(JGraphQL, 'variables.input.quantities'); repeat - JSetQuantity := CalcStock(ShopInventory); - if JSetQuantity.Keys.Count = 3 then begin - JSetQuantities.Add(JSetQuantity); + JQuantity := CalcStock(ShopInventory); + if JQuantity.Keys.Count = 4 then begin + JQuantities.Add(JQuantity); InputSize += 1; if InputSize = 250 then begin - ShopifyCommunicationMgt.ExecuteGraphQL(Format(JGraphQL), IGraphQL.GetExpectedCost()); + ExecuteInventoryGraphQL(JGraphQL, IGraphQL.GetExpectedCost()); Clear(JGraphQL); JGraphQL.ReadFrom(IGraphQL.GetGraphQL()); - JSetQuantities := JsonHelper.GetJsonArray(JGraphQL, 'variables.input.setQuantities'); + JQuantities := JsonHelper.GetJsonArray(JGraphQL, 'variables.input.quantities'); InputSize := 0; end; end; until ShopInventory.Next() = 0; - ShopifyCommunicationMgt.ExecuteGraphQL(Format(JGraphQL), IGraphQL.GetExpectedCost()); + ExecuteInventoryGraphQL(JGraphQL, IGraphQL.GetExpectedCost()); end; end; - local procedure CalcStock(var ShopInventory: Record "Shpfy Shop Inventory") JSetQuantity: JsonObject + local procedure ExecuteInventoryGraphQL(JGraphQL: JsonObject; ExpectedCost: Integer) + var + JResponse: JsonToken; + JUserErrors: JsonArray; + GraphQLText: Text; + IdempotencyKey: Guid; + RetryAttempt: Integer; + MaxRetries: Integer; + HasConcurrencyError: Boolean; + ErrorCode: Text; + JError: JsonToken; + begin + MaxRetries := 1; + RetryAttempt := 0; + + repeat + HasConcurrencyError := false; + IdempotencyKey := CreateGuid(); + GraphQLText := Format(JGraphQL); + GraphQLText := GraphQLText.Replace('{{IdempotencyKey}}', Format(IdempotencyKey, 0, 4).TrimStart('{').TrimEnd('}')); + + JResponse := ShopifyCommunicationMgt.ExecuteGraphQL(GraphQLText, ExpectedCost); + + if JsonHelper.GetJsonArray(JResponse, JUserErrors, 'data.inventorySetQuantities.userErrors') then + foreach JError in JUserErrors do begin + ErrorCode := JsonHelper.GetValueAsText(JError, 'code'); + if ErrorCode in ['IDEMPOTENCY_CONCURRENT_REQUEST', 'CHANGE_FROM_QUANTITY_STALE'] then begin + HasConcurrencyError := true; + break; + end; + end; + + RetryAttempt += 1; + until (not HasConcurrencyError) or (RetryAttempt > MaxRetries); + + if HasConcurrencyError then + LogSkippedInventoryUpdate(JGraphQL, ErrorCode); + end; + + local procedure LogSkippedInventoryUpdate(JGraphQL: JsonObject; ErrorCode: Text) + var + SkippedRecord: Codeunit "Shpfy Skipped Record"; + EmptyRecordId: RecordId; + JQuantities: JsonArray; + SkippedMsg: Label 'Inventory update skipped after retry due to %1 error', Comment = '%1 = Error code'; + begin + if JsonHelper.GetJsonArray(JGraphQL, JQuantities, 'variables.input.quantities') then + if JQuantities.Count > 0 then + SkippedRecord.LogSkippedRecord(EmptyRecordId, CopyStr(StrSubstNo(SkippedMsg, ErrorCode), 1, 250), ShopifyShop); + end; + + local procedure CalcStock(var ShopInventory: Record "Shpfy Shop Inventory") JQuantity: JsonObject var Item: Record Item; DelShopInventory: Record "Shpfy Shop Inventory"; ShopLocation: Record "Shpfy Shop Location"; ShopifyVariant: Record "Shpfy Variant"; IStockAvailable: Interface "Shpfy IStock Available"; + JNull: JsonValue; InventoryItemIdTxt: Label 'gid://shopify/InventoryItem/%1', Locked = true, Comment = '%1 = The inventory Item Id'; LocationIdTxt: Label 'gid://shopify/Location/%1', Locked = true, Comment = '%1 = The Location Id'; - begin ShopifyVariant.SetRange(Id, ShopInventory."Variant Id"); if ShopifyVariant.IsEmpty then begin @@ -196,12 +247,14 @@ codeunit 30195 "Shpfy Inventory API" if ShopLocation.Get(ShopInventory."Shop Code", ShopInventory."Location Id") then begin IStockAvailable := ShopLocation."Stock Calculation"; if IStockAvailable.CanHaveStock() then begin - JSetQuantity.Add('inventoryItemId', StrSubstNo(InventoryItemIdTxt, ShopInventory."Inventory Item Id")); - JSetQuantity.Add('locationId', StrSubstNo(LocationIdTxt, ShopLocation.Id)); + JQuantity.Add('inventoryItemId', StrSubstNo(InventoryItemIdTxt, ShopInventory."Inventory Item Id")); + JQuantity.Add('locationId', StrSubstNo(LocationIdTxt, ShopLocation.Id)); if ShopInventory.Stock < 0 then - JSetQuantity.Add('quantity', 0) + JQuantity.Add('quantity', 0) else - JSetQuantity.Add('quantity', ShopInventory.Stock); + JQuantity.Add('quantity', ShopInventory.Stock); + JNull.SetValueToNull(); + JQuantity.Add('changeFromQuantity', JNull); end; end; end; diff --git a/src/Apps/W1/Shopify/App/src/Metafields/Codeunits/IMetafieldType/ShpfyMtfldTypeArticleRef.Codeunit.al b/src/Apps/W1/Shopify/App/src/Metafields/Codeunits/IMetafieldType/ShpfyMtfldTypeArticleRef.Codeunit.al new file mode 100644 index 0000000000..2c769bb89a --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Metafields/Codeunits/IMetafieldType/ShpfyMtfldTypeArticleRef.Codeunit.al @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using System.Utilities; + +codeunit 30457 "Shpfy Mtfld Type Article Ref" implements "Shpfy IMetafield Type" +{ + procedure HasAssistEdit(): Boolean + begin + exit(false); + end; + + procedure IsValidValue(Value: Text): Boolean + var + Regex: Codeunit Regex; + begin + exit(Regex.IsMatch(Value, '^gid:\/\/shopify\/Article\/\d+$')); + end; + + procedure AssistEdit(var Value: Text[2048]): Boolean + begin + Value := Value; + exit(false); + end; + + procedure GetExampleValue(): Text + begin + exit('gid://shopify/Article/1234567890'); + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Metafields/Enums/ShpfyMetafieldType.Enum.al b/src/Apps/W1/Shopify/App/src/Metafields/Enums/ShpfyMetafieldType.Enum.al index 46856e0619..77dd4b1fde 100644 --- a/src/Apps/W1/Shopify/App/src/Metafields/Enums/ShpfyMetafieldType.Enum.al +++ b/src/Apps/W1/Shopify/App/src/Metafields/Enums/ShpfyMetafieldType.Enum.al @@ -172,4 +172,10 @@ enum 30159 "Shpfy Metafield Type" implements "Shpfy IMetafield Type" Caption = 'Company'; Implementation = "Shpfy IMetafield Type" = "Shpfy Mtfld Type Company Ref"; } + + value(27; article_reference) + { + Caption = 'Article'; + Implementation = "Shpfy IMetafield Type" = "Shpfy Mtfld Type Article Ref"; + } } \ No newline at end of file diff --git a/src/Apps/W1/Shopify/App/src/Order Returns/Codeunits/ShpfyReturnsAPI.Codeunit.al b/src/Apps/W1/Shopify/App/src/Order Returns/Codeunits/ShpfyReturnsAPI.Codeunit.al index 295927b8a3..64c59e097f 100644 --- a/src/Apps/W1/Shopify/App/src/Order Returns/Codeunits/ShpfyReturnsAPI.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Order Returns/Codeunits/ShpfyReturnsAPI.Codeunit.al @@ -204,7 +204,8 @@ codeunit 30250 "Shpfy Returns API" ReturnLine."Order Line Id" := CommunicationMgt.GetIdOfGId(JsonHelper.GetValueAsText(JLine, 'fulfillmentLineItem.lineItem.id')); ReturnLine.Insert(); end; - ReturnLine."Return Reason" := ReturnEnumConvertor.ConvertToReturnReason(JsonHelper.GetValueAsText(JLine, 'returnReason')); + ReturnLine."Return Reason Name" := CopyStr(JsonHelper.GetValueAsText(JLine, 'returnReasonDefinition.name'), 1, MaxStrLen(ReturnLine."Return Reason Name")); + ReturnLine."Return Reason Handle" := CopyStr(JsonHelper.GetValueAsText(JLine, 'returnReasonDefinition.handle'), 1, MaxStrLen(ReturnLine."Return Reason Handle")); // If item was restocked to multiple locations, we cannot determine the return location for the line if ReturnLocations.Get(ReturnLine."Order Line Id", ReturnLocation) then ReturnLine."Location Id" := ReturnLocation; @@ -239,7 +240,8 @@ codeunit 30250 "Shpfy Returns API" ReturnLine.Type := ReturnLine.Type::Unverified; ReturnLine.Insert(); end; - ReturnLine."Return Reason" := ReturnEnumConvertor.ConvertToReturnReason(JsonHelper.GetValueAsText(JLine, 'returnReason')); + ReturnLine."Return Reason Name" := CopyStr(JsonHelper.GetValueAsText(JLine, 'returnReasonDefinition.name'), 1, MaxStrLen(ReturnLine."Return Reason Name")); + ReturnLine."Return Reason Handle" := CopyStr(JsonHelper.GetValueAsText(JLine, 'returnReasonDefinition.handle'), 1, MaxStrLen(ReturnLine."Return Reason Handle")); ReturnLine.SetReturnReasonNote(JsonHelper.GetValueAsText(JLine, 'returnReasonNote')); ReturnLine.SetCustomerNote(JsonHelper.GetValueAsText(JLine, 'customerNote')); diff --git a/src/Apps/W1/Shopify/App/src/Order Returns/Pages/ShpfyReturnLines.Page.al b/src/Apps/W1/Shopify/App/src/Order Returns/Pages/ShpfyReturnLines.Page.al index c933401861..d6ee69df6b 100644 --- a/src/Apps/W1/Shopify/App/src/Order Returns/Pages/ShpfyReturnLines.Page.al +++ b/src/Apps/W1/Shopify/App/src/Order Returns/Pages/ShpfyReturnLines.Page.al @@ -42,7 +42,18 @@ page 30149 "Shpfy Return Lines" ApplicationArea = All; ToolTip = 'Specifies the quantity being returned.'; } +#if not CLEAN28 field("Return Reason"; Rec."Return Reason") + { + ApplicationArea = All; + ToolTip = 'Specifies the reason for returning the item.'; + Visible = false; + ObsoleteState = Pending; + ObsoleteReason = 'Shopify API 2025-10 deprecated returnReason. Use Return Reason Name field instead.'; + ObsoleteTag = '28.0'; + } +#endif + field("Return Reason Name"; Rec."Return Reason Name") { ApplicationArea = All; ToolTip = 'Specifies the reason for returning the item.'; diff --git a/src/Apps/W1/Shopify/App/src/Order Returns/Tables/ShpfyReturnLine.Table.al b/src/Apps/W1/Shopify/App/src/Order Returns/Tables/ShpfyReturnLine.Table.al index fce184e211..cbad38ff99 100644 --- a/src/Apps/W1/Shopify/App/src/Order Returns/Tables/ShpfyReturnLine.Table.al +++ b/src/Apps/W1/Shopify/App/src/Order Returns/Tables/ShpfyReturnLine.Table.al @@ -47,12 +47,22 @@ table 30141 "Shpfy Return Line" DataClassification = SystemMetadata; Editable = false; } +#if not CLEANSCHEMA31 field(6; "Return Reason"; Enum "Shpfy Return Reason") { Caption = 'Return Reason'; DataClassification = SystemMetadata; Editable = false; + ObsoleteReason = 'Replaced by Return Reason Name and Return Reason Handle fields. Shopify API 2026-01 deprecated returnReason in favor of returnReasonDefinition.'; +#if not CLEAN28 + ObsoleteState = Pending; + ObsoleteTag = '28.0'; +#else + ObsoleteState = Removed; + ObsoleteTag = '31.0'; +#endif } +#endif field(7; "Return Reason Note"; Blob) { Caption = 'Return Reason Note'; @@ -125,6 +135,18 @@ table 30141 "Shpfy Return Line" DataClassification = SystemMetadata; Editable = false; } + field(18; "Return Reason Name"; Text[100]) + { + Caption = 'Return Reason'; + DataClassification = SystemMetadata; + Editable = false; + } + field(19; "Return Reason Handle"; Text[100]) + { + Caption = 'Return Reason Handle'; + DataClassification = SystemMetadata; + Editable = false; + } field(101; "Item No."; Code[20]) { Caption = 'Item No.'; diff --git a/src/Apps/W1/Shopify/App/src/Payments/Codeunits/ShpfyPaymentsAPI.Codeunit.al b/src/Apps/W1/Shopify/App/src/Payments/Codeunits/ShpfyPaymentsAPI.Codeunit.al index 1fdf987b43..45a2af07e7 100644 --- a/src/Apps/W1/Shopify/App/src/Payments/Codeunits/ShpfyPaymentsAPI.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Payments/Codeunits/ShpfyPaymentsAPI.Codeunit.al @@ -115,7 +115,7 @@ codeunit 30385 "Shpfy Payments API" until not JsonHelper.GetValueAsBoolean(JResponse, 'data.shopifyPaymentsAccount.payouts.pageInfo.hasNextPage'); end; - local procedure ImportPayout(JPayout: JsonObject) + internal procedure ImportPayout(JPayout: JsonObject) var DataCapture: Record "Shpfy Data Capture"; Payout: Record "Shpfy Payout"; @@ -142,6 +142,7 @@ codeunit 30385 "Shpfy Payments API" JsonHelper.GetValueIntoField(JPayout, 'summary.reservedFundsGross.amount', RecordRef, Payout.FieldNo("Reserved Funds Gross Amount")); JsonHelper.GetValueIntoField(JPayout, 'summary.retriedPayoutsFee.amount', RecordRef, Payout.FieldNo("Retried Payouts Fee Amount")); JsonHelper.GetValueIntoField(JPayout, 'summary.retriedPayoutsGross.amount', RecordRef, Payout.FieldNo("Retried Payouts Gross Amount")); + JsonHelper.GetValueIntoField(JPayout, 'externalTraceId', RecordRef, Payout.FieldNo("External Trace Id")); RecordRef.SetTable(Payout); RecordRef.Close(); Payout.Id := Id; diff --git a/src/Apps/W1/Shopify/App/src/Payments/Tables/ShpfyPayout.Table.al b/src/Apps/W1/Shopify/App/src/Payments/Tables/ShpfyPayout.Table.al index 876cf78296..fae556b580 100644 --- a/src/Apps/W1/Shopify/App/src/Payments/Tables/ShpfyPayout.Table.al +++ b/src/Apps/W1/Shopify/App/src/Payments/Tables/ShpfyPayout.Table.al @@ -112,6 +112,11 @@ table 30125 "Shpfy Payout" AutoFormatType = 1; AutoFormatExpression = Currency; } + field(16; "External Trace Id"; Text[250]) + { + Caption = 'External Trace Id'; + DataClassification = CustomerContent; + } } keys { diff --git a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al index 92928afb81..6f1c2fa0c7 100644 --- a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al +++ b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al @@ -161,7 +161,6 @@ permissionset 30104 "Shpfy - Objects" codeunit "Shpfy GQL ApiKey" = X, codeunit "Shpfy GQL AssignedFFOrders" = X, codeunit "Shpfy GQL BulkOperation" = X, - codeunit "Shpfy GQL BulkOperations" = X, codeunit "Shpfy GQL BulkOpMutation" = X, codeunit "Shpfy GQL Catalog Markets" = X, codeunit "Shpfy GQL CatalogPrices" = X, diff --git a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateProduct.Codeunit.al b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateProduct.Codeunit.al index f690c24b19..5b1c372562 100644 --- a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateProduct.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateProduct.Codeunit.al @@ -101,7 +101,6 @@ codeunit 30174 "Shpfy Create Product" TempShopifyVariant.Title := ItemVariant.Description; TempShopifyVariant."Inventory Policy" := Shop."Default Inventory Policy"; TempShopifyVariant.SKU := GetVariantSKU(TempShopifyVariant.Barcode, Item."No.", ItemVariant.Code, Item."Vendor Item No."); - TempShopifyVariant."Tax Code" := Item."Tax Group Code"; TempShopifyVariant.Taxable := true; TempShopifyVariant.Weight := ItemUnitofMeasure."Qty. per Unit of Measure" > 0 ? Item."Gross Weight" * ItemUnitofMeasure."Qty. per Unit of Measure" : Item."Gross Weight"; TempShopifyVariant."Option 1 Name" := 'Variant'; @@ -124,7 +123,6 @@ codeunit 30174 "Shpfy Create Product" TempShopifyVariant.Title := ItemVariant.Description; TempShopifyVariant."Inventory Policy" := Shop."Default Inventory Policy"; TempShopifyVariant.SKU := GetVariantSKU(TempShopifyVariant.Barcode, Item."No.", ItemVariant.Code, GetVendorItemNo(Item."No.", ItemVariant.Code, Item."Sales Unit of Measure")); - TempShopifyVariant."Tax Code" := Item."Tax Group Code"; TempShopifyVariant.Taxable := true; TempShopifyVariant.Weight := Item."Gross Weight"; TempShopifyVariant."Option 1 Name" := 'Variant'; @@ -151,7 +149,6 @@ codeunit 30174 "Shpfy Create Product" TempShopifyVariant.Title := Item.Description; TempShopifyVariant."Inventory Policy" := Shop."Default Inventory Policy"; TempShopifyVariant.SKU := GetVariantSKU(TempShopifyVariant.Barcode, Item."No.", '', Item."Vendor Item No."); - TempShopifyVariant."Tax Code" := Item."Tax Group Code"; TempShopifyVariant.Taxable := true; TempShopifyVariant.Weight := ItemUnitofMeasure."Qty. per Unit of Measure" > 0 ? Item."Gross Weight" * ItemUnitofMeasure."Qty. per Unit of Measure" : Item."Gross Weight"; TempShopifyVariant."Option 1 Name" := Shop."Option Name for UoM"; @@ -232,7 +229,6 @@ codeunit 30174 "Shpfy Create Product" TempShopifyVariant.Title := ''; // Title will be assigned to "Default Title" in Shopify as no Options are set. TempShopifyVariant."Inventory Policy" := Shop."Default Inventory Policy"; TempShopifyVariant.SKU := GetVariantSKU(TempShopifyVariant.Barcode, Item."No.", '', Item."Vendor Item No."); - TempShopifyVariant."Tax Code" := Item."Tax Group Code"; TempShopifyVariant.Taxable := true; TempShopifyVariant.Weight := Item."Gross Weight"; TempShopifyVariant."Shop Code" := Shop.Code; diff --git a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al index 208621e2ac..4bc95e198e 100644 --- a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyProductExport.Codeunit.al @@ -358,7 +358,6 @@ codeunit 30178 "Shpfy Product Export" Shop."SKU Mapping"::"Vendor Item No.": ShopifyVariant.SKU := Item."Vendor Item No."; end; - ShopifyVariant."Tax Code" := Item."Tax Group Code"; ShopifyVariant.Taxable := true; ShopifyVariant.Weight := ItemUnitofMeasure."Qty. per Unit of Measure" > 0 ? Item."Gross Weight" * ItemUnitofMeasure."Qty. per Unit of Measure" : Item."Gross Weight"; ShopifyVariant."Option 1 Name" := Shop."Option Name for UoM"; @@ -415,7 +414,6 @@ codeunit 30178 "Shpfy Product Export" Shop."SKU Mapping"::"Vendor Item No.": ShopifyVariant.SKU := Item."Vendor Item No."; end; - ShopifyVariant."Tax Code" := Item."Tax Group Code"; ShopifyVariant.Taxable := true; ShopifyVariant.Weight := Item."Gross Weight"; if ShopifyVariant."Option 1 Name" = '' then @@ -470,7 +468,6 @@ codeunit 30178 "Shpfy Product Export" Shop."SKU Mapping"::"Vendor Item No.": ShopifyVariant.SKU := Item."Vendor Item No."; end; - ShopifyVariant."Tax Code" := Item."Tax Group Code"; ShopifyVariant.Taxable := true; ShopifyVariant.Weight := ItemUnitofMeasure."Qty. per Unit of Measure" > 0 ? Item."Gross Weight" * ItemUnitofMeasure."Qty. per Unit of Measure" : Item."Gross Weight"; ShopifyVariant."Option 1 Name" := 'Variant'; diff --git a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyVariantAPI.Codeunit.al b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyVariantAPI.Codeunit.al index 3c14ad5050..d8ced8f1e7 100644 --- a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyVariantAPI.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyVariantAPI.Codeunit.al @@ -99,8 +99,11 @@ codeunit 30189 "Shpfy Variant API" JResponse: JsonToken; JVariants: JsonArray; ReturnQuery: Text; + VariantsInBatch: Integer; + MaxVariantsPerBatch: Integer; begin ReturnQuery := ']) {productVariants {legacyResourceId, createdAt, updatedAt}, userErrors {field, message}}}"}'; + MaxVariantsPerBatch := GetMaxVariantsPerBatch(); if ShopifyVariant.FindSet() then begin InventoryQuantities := GetInventoryQuantities(); @@ -109,13 +112,16 @@ codeunit 30189 "Shpfy Variant API" GraphQuery.Append('\", strategy: '); GraphQuery.Append(Format(Strategy)); GraphQuery.Append(', variants: ['); + VariantsInBatch := 0; repeat ShopifyVariant."Product Id" := ProductId; VariantGraphQuery := GetVariantGraphQuery(ShopifyVariant, InventoryQuantities); - if GraphQuery.Length() + VariantGraphQuery.Length() + StrLen(ReturnQuery) < CommunicationMgt.GetGraphQueryLengthThreshold() then begin + if (GraphQuery.Length() + VariantGraphQuery.Length() + StrLen(ReturnQuery) < CommunicationMgt.GetGraphQueryLengthThreshold()) and + (VariantsInBatch < MaxVariantsPerBatch) then begin GraphQuery.Append(VariantGraphQuery.ToText() + ', '); TempNewShopifyVariant := ShopifyVariant; TempNewShopifyVariant.Insert(); + VariantsInBatch += 1; end else begin GraphQuery.Remove(GraphQuery.Length - 1, 2); GraphQuery.Append(ReturnQuery); @@ -133,6 +139,7 @@ codeunit 30189 "Shpfy Variant API" GraphQuery.Append(Format(Strategy)); GraphQuery.Append(', variants: ['); GraphQuery.Append(VariantGraphQuery.ToText() + ', '); + VariantsInBatch := 1; end; until ShopifyVariant.Next() = 0; GraphQuery.Remove(GraphQuery.Length - 1, 2); @@ -166,12 +173,6 @@ codeunit 30189 "Shpfy Variant API" end; if ShopifyVariant.Taxable then GraphQuery.Append(', taxable: true'); - if ShopifyVariant."Tax Code" <> xShopifyVariant."Tax Code" then begin - HasChange := true; - GraphQuery.Append(', taxCode: \"'); - GraphQuery.Append(ShopifyVariant."Tax Code"); - GraphQuery.Append('\"'); - end; if ShopifyVariant.Price <> xShopifyVariant.Price then begin HasChange := true; GraphQuery.Append(', price: \"'); @@ -236,11 +237,6 @@ codeunit 30189 "Shpfy Variant API" end; if ShopifyVariant.Taxable then GraphQuery.Append(', taxable: true'); - if ShopifyVariant."Tax Code" <> '' then begin - GraphQuery.Append(', taxCode: \"'); - GraphQuery.Append(ShopifyVariant."Tax Code"); - GraphQuery.Append('\"'); - end; if ShopifyVariant.Price > 0 then begin GraphQuery.Append(', price: \"'); GraphQuery.Append(Format(ShopifyVariant.Price, 0, 9)); @@ -323,6 +319,28 @@ codeunit 30189 "Shpfy Variant API" exit(GraphQuery.ToText()); end; + internal procedure GetDefaultLocationCount(): Integer + var + ShopLocation: Record "Shpfy Shop Location"; + begin + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.SetRange(Active, true); + ShopLocation.SetRange("Default Product Location", true); + exit(ShopLocation.Count()); + end; + + internal procedure GetMaxVariantsPerBatch(): Integer + var + LocationCount: Integer; + MaxInventoryQuantities: Integer; + begin + MaxInventoryQuantities := 50000; + LocationCount := GetDefaultLocationCount(); + if LocationCount = 0 then + exit(250); // Default batch size when no inventory quantities are added + exit(MaxInventoryQuantities div LocationCount); + end; + local procedure CreateNewVariant(JVariant: JsonToken; var ShopifyVariant: Record "Shpfy Variant"; ProductId: BigInteger): Boolean var NewShopifyVariant: Record "Shpfy Variant"; @@ -756,7 +774,6 @@ codeunit 30189 "Shpfy Variant API" #pragma warning disable AA0139 ShopifyVariant.Barcode := JsonHelper.GetValueAsText(JVariant, 'barcode', MaxStrLen(ShopifyVariant.Barcode)); ShopifyVariant."Display Name" := JsonHelper.GetValueAsText(JVariant, 'displayName', MaxStrLen(ShopifyVariant."Display Name")); - ShopifyVariant."Tax Code" := JsonHelper.GetValueAsText(JVariant, 'taxCode', MaxStrLen(ShopifyVariant."Tax Code")); ShopifyVariant.SKU := JsonHelper.GetValueAsText(JVariant, 'sku', MaxStrLen(ShopifyVariant.SKU)); ShopifyVariant.Title := JsonHelper.GetValueAsText(JVariant, 'title', MaxStrLen(ShopifyVariant.Title)); #pragma warning restore AA0139 diff --git a/src/Apps/W1/Shopify/App/src/Products/Pages/ShpfyVariants.Page.al b/src/Apps/W1/Shopify/App/src/Products/Pages/ShpfyVariants.Page.al index 32715fd204..be3b408cb0 100644 --- a/src/Apps/W1/Shopify/App/src/Products/Pages/ShpfyVariants.Page.al +++ b/src/Apps/W1/Shopify/App/src/Products/Pages/ShpfyVariants.Page.al @@ -144,11 +144,17 @@ page 30127 "Shpfy Variants" ApplicationArea = All; ToolTip = 'Specifies whether a tax is charged when the product variant is sold.'; } +#if not CLEAN28 field(TaxCode; Rec."Tax Code") { ApplicationArea = All; ToolTip = 'Specifies the Avalara tax code for the product variant. This parameter applies only to the stores that have the Avalara AvaTax app installed.'; + Visible = false; + ObsoleteState = Pending; + ObsoleteReason = 'Shopify API 2025-10 deprecated taxCode on ProductVariant. This field is no longer available in the API.'; + ObsoleteTag = '28.0'; } +#endif field(UnitCost; Rec."Unit Cost") { ApplicationArea = All; diff --git a/src/Apps/W1/Shopify/App/src/Products/Tables/ShpfyVariant.Table.al b/src/Apps/W1/Shopify/App/src/Products/Tables/ShpfyVariant.Table.al index 367d41ca53..9538e494cf 100644 --- a/src/Apps/W1/Shopify/App/src/Products/Tables/ShpfyVariant.Table.al +++ b/src/Apps/W1/Shopify/App/src/Products/Tables/ShpfyVariant.Table.al @@ -81,11 +81,21 @@ table 30129 "Shpfy Variant" Caption = 'SKU'; DataClassification = CustomerContent; } +#if not CLEANSCHEMA31 field(13; "Tax Code"; Code[20]) { Caption = 'Tax Code'; DataClassification = CustomerContent; + ObsoleteReason = 'Shopify API 2025-10 deprecated taxCode on ProductVariant. This field is no longer available in the API.'; +#if not CLEAN28 + ObsoleteState = Pending; + ObsoleteTag = '28.0'; +#else + ObsoleteState = Removed; + ObsoleteTag = '31.0'; +#endif } +#endif field(14; Taxable; Boolean) { Caption = 'Taxable'; diff --git a/src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationCompletedResult.txt b/src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationCompletedResult.txt deleted file mode 100644 index a248d0ccc0..0000000000 --- a/src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationCompletedResult.txt +++ /dev/null @@ -1 +0,0 @@ -{ "data": { "currentBulkOperation": { "id": "gid://shopify/BulkOperation/%1", "status": "COMPLETED", "errorCode": null, "createdAt": "2021-01-28T19:10:59Z", "completedAt": "2021-01-28T19:11:09Z", "objectCount": "16", "fileSize": "0", "url": "", "partialDataUrl": null } }, "extensions": { "cost": { "requestedQueryCost": 1, "actualQueryCost": 1 } } } \ No newline at end of file diff --git a/src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationRunningResult.txt b/src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationRunningResult.txt deleted file mode 100644 index 9f42cae022..0000000000 --- a/src/Apps/W1/Shopify/Test/.resources/Bulk Operations/CurrentBulkOperationRunningResult.txt +++ /dev/null @@ -1 +0,0 @@ -{ "data": { "currentBulkOperation": { "id": "gid://shopify/BulkOperation/%1", "status": "RUNNING", "errorCode": null, "createdAt": "2021-01-28T19:10:59Z", "completedAt": "2021-01-28T19:11:09Z", "objectCount": "16", "fileSize": "0", "url": "", "partialDataUrl": null } }, "extensions": { "cost": { "requestedQueryCost": 1, "actualQueryCost": 1 } } } \ No newline at end of file diff --git a/src/Apps/W1/Shopify/Test/Bulk Operations/Codeunits/ShpfyBulkOpSubscriber.Codeunit.al b/src/Apps/W1/Shopify/Test/Bulk Operations/Codeunits/ShpfyBulkOpSubscriber.Codeunit.al index ede01420a6..bd4253af09 100644 --- a/src/Apps/W1/Shopify/Test/Bulk Operations/Codeunits/ShpfyBulkOpSubscriber.Codeunit.al +++ b/src/Apps/W1/Shopify/Test/Bulk Operations/Codeunits/ShpfyBulkOpSubscriber.Codeunit.al @@ -59,7 +59,6 @@ codeunit 139615 "Shpfy Bulk Op. Subscriber" GraphQLQuery: Text; StagedUploadGQLTxt: Label '{"query": "mutation { stagedUploadsCreate(input', Locked = true; BulkMutationGQLTxt: Label '{"query": "mutation { bulkOperationRunMutation(mutation', Locked = true; - CurrentBulkOperationGQLTxt: Label '{"query": "query { currentBulkOperation(type', Locked = true; BulkOperationGQLTxt: Label '{"query": "query { node(id: \"gid://shopify/BulkOperation/', Locked = true; GraphQLCmdTxt: Label '/graphql.json', Locked = true; begin @@ -73,11 +72,6 @@ codeunit 139615 "Shpfy Bulk Op. Subscriber" HttpResponseMessage := GetStagedUplodResult(); if GraphQLQuery.StartsWith(BulkMutationGQLTxt) then HttpResponseMessage := GetBulkMutationResponse(); - if GraphQLQuery.StartsWith(CurrentBulkOperationGQLTxt) then - if BulkOperationRunning then - HttpResponseMessage := GetCurrentBulkOperationRunningResult() - else - HttpResponseMessage := GetCurrentBulkOperationCompletedResult(); if GraphQLQuery.StartsWith(BulkOperationGQLTxt) then HttpResponseMessage := GetBulkOperationCompletedResult(); end; @@ -115,30 +109,6 @@ codeunit 139615 "Shpfy Bulk Op. Subscriber" exit(HttpResponseMessage); end; - local procedure GetCurrentBulkOperationCompletedResult(): HttpResponseMessage - var - HttpResponseMessage: HttpResponseMessage; - Body: Text; - ResInStream: InStream; - begin - NavApp.GetResource('Bulk Operations/CurrentBulkOperationCompletedResult.txt', ResInStream, TextEncoding::UTF8); - ResInStream.ReadText(Body); - HttpResponseMessage.Content.WriteFrom(StrSubstNo(Body, Format(BulkOperationId))); - exit(HttpResponseMessage); - end; - - local procedure GetCurrentBulkOperationRunningResult(): HttpResponseMessage - var - HttpResponseMessage: HttpResponseMessage; - Body: Text; - ResInStream: InStream; - begin - NavApp.GetResource('Bulk Operations/CurrentBulkOperationRunningResult.txt', ResInStream, TextEncoding::UTF8); - ResInStream.ReadText(Body); - HttpResponseMessage.Content.WriteFrom(StrSubstNo(Body, Format(BulkOperationId))); - exit(HttpResponseMessage); - end; - local procedure GetJsonlUploadResult(): HttpResponseMessage var HttpResponseMessage: HttpResponseMessage; diff --git a/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryExportTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryExportTest.Codeunit.al new file mode 100644 index 0000000000..c043d9f3cc --- /dev/null +++ b/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryExportTest.Codeunit.al @@ -0,0 +1,347 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify.Test; + +using Microsoft.Integration.Shopify; +using Microsoft.Inventory.Item; +using Microsoft.Inventory.Journal; +using System.TestLibraries.Utilities; + +/// +/// Codeunit Shpfy Inventory Export Test (ID 139619). +/// Tests for inventory export functionality including idempotency and retry logic. +/// +codeunit 139619 "Shpfy Inventory Export Test" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + var + Any: Codeunit Any; + LibraryAssert: Codeunit "Library Assert"; + LibraryInventory: Codeunit "Library - Inventory"; + IsInitialized: Boolean; + + local procedure Initialize() + begin + if IsInitialized then + exit; + IsInitialized := true; + Codeunit.Run(Codeunit::"Shpfy Initialize Test"); + end; + + [Test] + procedure UnitTestExportStockSuccess() + var + Shop: Record "Shpfy Shop"; + ShopLocation: Record "Shpfy Shop Location"; + Item: Record Item; + ShopifyProduct: Record "Shpfy Product"; + ShopInventory: Record "Shpfy Shop Inventory"; + InventorySubscriber: Codeunit "Shpfy Inventory Subscriber"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + InventoryAPI: Codeunit "Shpfy Inventory API"; + StockCalculate: Enum "Shpfy Stock Calculation"; + begin + // [SCENARIO] Export stock successfully updates inventory in Shopify + // [GIVEN] A ShopInventory record with stock different from Shopify stock + Initialize(); + + Shop := CommunicationMgt.GetShopRecord(); + CreateShopLocation(ShopLocation, Shop.Code, StockCalculate::"Projected Available Balance Today"); + CreateItem(Item); + UpdateItemInventory(Item, 10); + CreateShpfyProduct(ShopifyProduct, ShopInventory, Item.SystemId, Shop.Code, ShopLocation.Id); + ShopInventory."Shopify Stock" := 5; // Different from calculated stock to trigger export + ShopInventory.Modify(); + + // [GIVEN] The inventory subscriber is configured to return success + BindSubscription(InventorySubscriber); + InventorySubscriber.SetRetryScenario(Enum::"Shpfy Inventory Retry Scenario"::Success); + InventoryAPI.SetShop(Shop.Code); + + // [WHEN] ExportStock is called + ShopInventory.SetRange("Shop Code", Shop.Code); + ShopInventory.SetRange("Variant Id", ShopInventory."Variant Id"); + InventoryAPI.ExportStock(ShopInventory); + + // [THEN] The mutation was executed successfully (verified by subscriber not throwing error) + UnbindSubscription(InventorySubscriber); + end; + + [Test] + procedure UnitTestExportStockRetryOnIdempotencyConcurrentRequest() + var + Shop: Record "Shpfy Shop"; + ShopLocation: Record "Shpfy Shop Location"; + Item: Record Item; + ShopifyProduct: Record "Shpfy Product"; + ShopInventory: Record "Shpfy Shop Inventory"; + InventorySubscriber: Codeunit "Shpfy Inventory Subscriber"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + InventoryAPI: Codeunit "Shpfy Inventory API"; + StockCalculate: Enum "Shpfy Stock Calculation"; + begin + // [SCENARIO] Export stock retries on IDEMPOTENCY_CONCURRENT_REQUEST error + // [GIVEN] A ShopInventory record with stock different from Shopify stock + Initialize(); + + Shop := CommunicationMgt.GetShopRecord(); + CreateShopLocation(ShopLocation, Shop.Code, StockCalculate::"Projected Available Balance Today"); + CreateItem(Item); + UpdateItemInventory(Item, 15); + CreateShpfyProduct(ShopifyProduct, ShopInventory, Item.SystemId, Shop.Code, ShopLocation.Id); + ShopInventory."Shopify Stock" := 5; + ShopInventory.Modify(); + + // [GIVEN] The inventory subscriber is configured to fail once with IDEMPOTENCY_CONCURRENT_REQUEST then succeed + BindSubscription(InventorySubscriber); + InventorySubscriber.SetRetryScenario(Enum::"Shpfy Inventory Retry Scenario"::FailOnceThenSucceed); + InventorySubscriber.SetErrorCode('IDEMPOTENCY_CONCURRENT_REQUEST'); + InventoryAPI.SetShop(Shop.Code); + + // [WHEN] ExportStock is called + ShopInventory.SetRange("Shop Code", Shop.Code); + ShopInventory.SetRange("Variant Id", ShopInventory."Variant Id"); + InventoryAPI.ExportStock(ShopInventory); + + // [THEN] The mutation was retried and succeeded (2 calls total) + LibraryAssert.AreEqual(2, InventorySubscriber.GetCallCount(), 'Expected 2 GraphQL calls (1 failure + 1 retry success)'); + + UnbindSubscription(InventorySubscriber); + end; + + [Test] + procedure UnitTestExportStockRetryOnChangeFromQuantityStale() + var + Shop: Record "Shpfy Shop"; + ShopLocation: Record "Shpfy Shop Location"; + Item: Record Item; + ShopifyProduct: Record "Shpfy Product"; + ShopInventory: Record "Shpfy Shop Inventory"; + InventorySubscriber: Codeunit "Shpfy Inventory Subscriber"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + InventoryAPI: Codeunit "Shpfy Inventory API"; + StockCalculate: Enum "Shpfy Stock Calculation"; + begin + // [SCENARIO] Export stock retries on CHANGE_FROM_QUANTITY_STALE error + // [GIVEN] A ShopInventory record with stock different from Shopify stock + Initialize(); + + Shop := CommunicationMgt.GetShopRecord(); + CreateShopLocation(ShopLocation, Shop.Code, StockCalculate::"Projected Available Balance Today"); + CreateItem(Item); + UpdateItemInventory(Item, 20); + CreateShpfyProduct(ShopifyProduct, ShopInventory, Item.SystemId, Shop.Code, ShopLocation.Id); + ShopInventory."Shopify Stock" := 10; + ShopInventory.Modify(); + + // [GIVEN] The inventory subscriber is configured to fail once with CHANGE_FROM_QUANTITY_STALE then succeed + BindSubscription(InventorySubscriber); + InventorySubscriber.SetRetryScenario(Enum::"Shpfy Inventory Retry Scenario"::FailOnceThenSucceed); + InventorySubscriber.SetErrorCode('CHANGE_FROM_QUANTITY_STALE'); + InventoryAPI.SetShop(Shop.Code); + + // [WHEN] ExportStock is called + ShopInventory.SetRange("Shop Code", Shop.Code); + ShopInventory.SetRange("Variant Id", ShopInventory."Variant Id"); + InventoryAPI.ExportStock(ShopInventory); + + // [THEN] The mutation was retried and succeeded (2 calls total) + LibraryAssert.AreEqual(2, InventorySubscriber.GetCallCount(), 'Expected 2 GraphQL calls (1 failure + 1 retry success)'); + + UnbindSubscription(InventorySubscriber); + end; + + [Test] + procedure UnitTestExportStockLogsSkippedRecordAfterMaxRetries() + var + Shop: Record "Shpfy Shop"; + ShopLocation: Record "Shpfy Shop Location"; + Item: Record Item; + ShopifyProduct: Record "Shpfy Product"; + ShopInventory: Record "Shpfy Shop Inventory"; + SkippedRecord: Record "Shpfy Skipped Record"; + InventorySubscriber: Codeunit "Shpfy Inventory Subscriber"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + InventoryAPI: Codeunit "Shpfy Inventory API"; + StockCalculate: Enum "Shpfy Stock Calculation"; + SkippedCountBefore: Integer; + SkippedCountAfter: Integer; + begin + // [SCENARIO] Export stock logs skipped record when max retries exceeded + // [GIVEN] A ShopInventory record with stock different from Shopify stock + Initialize(); + + Shop := CommunicationMgt.GetShopRecord(); + CreateShopLocation(ShopLocation, Shop.Code, StockCalculate::"Projected Available Balance Today"); + CreateItem(Item); + UpdateItemInventory(Item, 25); + CreateShpfyProduct(ShopifyProduct, ShopInventory, Item.SystemId, Shop.Code, ShopLocation.Id); + ShopInventory."Shopify Stock" := 15; + ShopInventory.Modify(); + + // [GIVEN] Count of skipped records before export + SkippedCountBefore := SkippedRecord.Count(); + + // [GIVEN] The inventory subscriber is configured to always fail with concurrency error + BindSubscription(InventorySubscriber); + InventorySubscriber.SetRetryScenario(Enum::"Shpfy Inventory Retry Scenario"::AlwaysFail); + InventorySubscriber.SetErrorCode('IDEMPOTENCY_CONCURRENT_REQUEST'); + InventoryAPI.SetShop(Shop.Code); + + // [WHEN] ExportStock is called + ShopInventory.SetRange("Shop Code", Shop.Code); + ShopInventory.SetRange("Variant Id", ShopInventory."Variant Id"); + InventoryAPI.ExportStock(ShopInventory); + + // [THEN] A skipped record was logged + SkippedCountAfter := SkippedRecord.Count(); + LibraryAssert.IsTrue(SkippedCountAfter > SkippedCountBefore, 'Expected a skipped record to be logged after max retries'); + + // [THEN] The mutation was retried max times (2 calls: 1 initial + 1 retry) + LibraryAssert.AreEqual(2, InventorySubscriber.GetCallCount(), 'Expected 2 GraphQL calls (1 initial + 1 retry)'); + + UnbindSubscription(InventorySubscriber); + end; + + [Test] + procedure UnitTestCalcStockIncludesChangeFromQuantityNull() + var + Shop: Record "Shpfy Shop"; + ShopLocation: Record "Shpfy Shop Location"; + Item: Record Item; + ShopifyProduct: Record "Shpfy Product"; + ShopInventory: Record "Shpfy Shop Inventory"; + InventorySubscriber: Codeunit "Shpfy Inventory Subscriber"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + InventoryAPI: Codeunit "Shpfy Inventory API"; + StockCalculate: Enum "Shpfy Stock Calculation"; + LastGraphQLRequest: Text; + begin + // [SCENARIO] CalcStock includes changeFromQuantity: null in the GraphQL mutation + // [GIVEN] A ShopInventory record with stock different from Shopify stock + Initialize(); + + Shop := CommunicationMgt.GetShopRecord(); + CreateShopLocation(ShopLocation, Shop.Code, StockCalculate::"Projected Available Balance Today"); + CreateItem(Item); + UpdateItemInventory(Item, 30); + CreateShpfyProduct(ShopifyProduct, ShopInventory, Item.SystemId, Shop.Code, ShopLocation.Id); + ShopInventory."Shopify Stock" := 20; + ShopInventory.Modify(); + + // [GIVEN] The inventory subscriber captures the GraphQL request + BindSubscription(InventorySubscriber); + InventorySubscriber.SetRetryScenario(Enum::"Shpfy Inventory Retry Scenario"::Success); + InventoryAPI.SetShop(Shop.Code); + + // [WHEN] ExportStock is called + ShopInventory.SetRange("Shop Code", Shop.Code); + ShopInventory.SetRange("Variant Id", ShopInventory."Variant Id"); + InventoryAPI.ExportStock(ShopInventory); + + // [THEN] The GraphQL request contains changeFromQuantity: null + LastGraphQLRequest := InventorySubscriber.GetLastGraphQLRequest(); + LibraryAssert.IsTrue(LastGraphQLRequest.Contains('"changeFromQuantity":null'), 'Expected changeFromQuantity: null in GraphQL request'); + + UnbindSubscription(InventorySubscriber); + end; + + [Test] + procedure UnitTestIdempotencyKeyIsGenerated() + var + Shop: Record "Shpfy Shop"; + ShopLocation: Record "Shpfy Shop Location"; + Item: Record Item; + ShopifyProduct: Record "Shpfy Product"; + ShopInventory: Record "Shpfy Shop Inventory"; + InventorySubscriber: Codeunit "Shpfy Inventory Subscriber"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + InventoryAPI: Codeunit "Shpfy Inventory API"; + StockCalculate: Enum "Shpfy Stock Calculation"; + LastGraphQLRequest: Text; + begin + // [SCENARIO] Idempotency key is generated and included in the GraphQL mutation + // [GIVEN] A ShopInventory record with stock different from Shopify stock + Initialize(); + + Shop := CommunicationMgt.GetShopRecord(); + CreateShopLocation(ShopLocation, Shop.Code, StockCalculate::"Projected Available Balance Today"); + CreateItem(Item); + UpdateItemInventory(Item, 35); + CreateShpfyProduct(ShopifyProduct, ShopInventory, Item.SystemId, Shop.Code, ShopLocation.Id); + ShopInventory."Shopify Stock" := 25; + ShopInventory.Modify(); + + // [GIVEN] The inventory subscriber captures the GraphQL request + BindSubscription(InventorySubscriber); + InventorySubscriber.SetRetryScenario(Enum::"Shpfy Inventory Retry Scenario"::Success); + InventoryAPI.SetShop(Shop.Code); + + // [WHEN] ExportStock is called + ShopInventory.SetRange("Shop Code", Shop.Code); + ShopInventory.SetRange("Variant Id", ShopInventory."Variant Id"); + InventoryAPI.ExportStock(ShopInventory); + + // [THEN] The GraphQL request contains @idempotent directive with a GUID key + LastGraphQLRequest := InventorySubscriber.GetLastGraphQLRequest(); + LibraryAssert.IsTrue(LastGraphQLRequest.Contains('@idempotent(key:'), 'Expected @idempotent directive in GraphQL request'); + + UnbindSubscription(InventorySubscriber); + end; + + local procedure CreateItem(var Item: Record Item) + begin + LibraryInventory.CreateItemWithoutVAT(Item); + end; + + local procedure CreateShpfyProduct(var ShopifyProduct: Record "Shpfy Product"; var ShopInventory: Record "Shpfy Shop Inventory"; ItemSystemId: Guid; ShopCode: Code[20]; ShopLocationId: BigInteger) + var + ShopifyVariant: Record "Shpfy Variant"; + begin + ShopifyProduct.Init(); + ShopifyProduct.Id := Any.IntegerInRange(10000, 999999); + ShopifyProduct."Item SystemId" := ItemSystemId; + ShopifyProduct."Shop Code" := ShopCode; + ShopifyProduct.Insert(); + + ShopifyVariant.Init(); + ShopifyVariant.Id := Any.IntegerInRange(10000, 999999); + ShopifyVariant."Product Id" := ShopifyProduct.Id; + ShopifyVariant."Item SystemId" := ItemSystemId; + ShopifyVariant."Shop Code" := ShopCode; + ShopifyVariant.Insert(); + + ShopInventory.Init(); + ShopInventory."Inventory Item Id" := Any.IntegerInRange(10000, 999999); + ShopInventory."Shop Code" := ShopCode; + ShopInventory."Location Id" := ShopLocationId; + ShopInventory."Product Id" := ShopifyProduct.Id; + ShopInventory."Variant Id" := ShopifyVariant.Id; + ShopInventory.Insert(); + end; + + local procedure CreateShopLocation(var ShopLocation: Record "Shpfy Shop Location"; ShopCode: Code[20]; StockCalculation: Enum "Shpfy Stock Calculation") + begin + ShopLocation.Init(); + ShopLocation."Shop Code" := ShopCode; + ShopLocation.Id := Any.IntegerInRange(10000, 999999); + ShopLocation."Stock Calculation" := StockCalculation; + ShopLocation.Active := true; + ShopLocation."Default Product Location" := true; + ShopLocation.Insert(); + end; + + local procedure UpdateItemInventory(Item: Record Item; Qty: Decimal) + var + ItemJournalLine: Record "Item Journal Line"; + begin + LibraryInventory.CreateItemJournalLineInItemTemplate(ItemJournalLine, Item."No.", '', '', Qty); + LibraryInventory.PostItemJournalLine(ItemJournalLine."Journal Template Name", ItemJournalLine."Journal Batch Name"); + end; +} diff --git a/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryRetryScenario.Enum.al b/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryRetryScenario.Enum.al new file mode 100644 index 0000000000..7bc3a3a128 --- /dev/null +++ b/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventoryRetryScenario.Enum.al @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify.Test; + +/// +/// Enum Shpfy Inventory Retry Scenario (ID 139617). +/// Scenarios for simulating inventory API retry behavior in tests. +/// +enum 139617 "Shpfy Inventory Retry Scenario" +{ + Extensible = false; + + value(0; Success) + { + Caption = 'Success'; + } + value(1; FailOnceThenSucceed) + { + Caption = 'Fail Once Then Succeed'; + } + value(2; AlwaysFail) + { + Caption = 'Always Fail'; + } +} diff --git a/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventorySubscriber.Codeunit.al b/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventorySubscriber.Codeunit.al new file mode 100644 index 0000000000..9ef65637a6 --- /dev/null +++ b/src/Apps/W1/Shopify/Test/Inventory/ShpfyInventorySubscriber.Codeunit.al @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify.Test; + +using Microsoft.Integration.Shopify; + +/// +/// Codeunit Shpfy Inventory Subscriber (ID 139618). +/// Mock subscriber for inventory API tests to simulate GraphQL responses. +/// +codeunit 139618 "Shpfy Inventory Subscriber" +{ + SingleInstance = true; + EventSubscriberInstance = Manual; + + var + RetryScenario: Enum "Shpfy Inventory Retry Scenario"; + ErrorCode: Text; + CallCount: Integer; + LastGraphQLRequest: Text; + + internal procedure SetRetryScenario(NewScenario: Enum "Shpfy Inventory Retry Scenario") + begin + RetryScenario := NewScenario; + CallCount := 0; + end; + + internal procedure SetErrorCode(NewErrorCode: Text) + begin + ErrorCode := NewErrorCode; + end; + + internal procedure GetCallCount(): Integer + begin + exit(CallCount); + end; + + internal procedure GetLastGraphQLRequest(): Text + begin + exit(LastGraphQLRequest); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Shpfy Communication Events", 'OnClientSend', '', true, false)] + local procedure OnClientSend(HttpRequestMessage: HttpRequestMessage; var HttpResponseMessage: HttpResponseMessage) + begin + MakeResponse(HttpRequestMessage, HttpResponseMessage); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Shpfy Communication Events", 'OnGetContent', '', true, false)] + local procedure OnGetContent(HttpResponseMessage: HttpResponseMessage; var Response: Text) + begin + HttpResponseMessage.Content.ReadAs(Response); + end; + + local procedure MakeResponse(HttpRequestMessage: HttpRequestMessage; var HttpResponseMessage: HttpResponseMessage) + var + Uri: Text; + GraphQLQuery: Text; + InventorySetQuantitiesGQLTxt: Label 'inventorySetQuantities', Locked = true; + GraphQLCmdTxt: Label '/graphql.json', Locked = true; + begin + case HttpRequestMessage.Method of + 'POST': + begin + Uri := HttpRequestMessage.GetRequestUri(); + if Uri.EndsWith(GraphQLCmdTxt) then + if HttpRequestMessage.Content.ReadAs(GraphQLQuery) then begin + LastGraphQLRequest := GraphQLQuery; + if GraphQLQuery.Contains(InventorySetQuantitiesGQLTxt) then begin + CallCount += 1; + HttpResponseMessage := GetInventoryResponse(); + end; + end; + end; + end; + end; + + local procedure GetInventoryResponse(): HttpResponseMessage + var + HttpResponseMessage: HttpResponseMessage; + ResponseJson: Text; + SuccessResponseTxt: Label '{"data":{"inventorySetQuantities":{"inventoryAdjustmentGroup":{"id":"gid://shopify/InventoryAdjustmentGroup/12345"},"userErrors":[]}}}', Locked = true; + ErrorResponseTxt: Label '{"data":{"inventorySetQuantities":{"inventoryAdjustmentGroup":null,"userErrors":[{"field":["input"],"message":"Concurrent request detected","code":"%1"}]}}}', Comment = '%1 = Error code', Locked = true; + begin + case RetryScenario of + RetryScenario::Success: + ResponseJson := SuccessResponseTxt; + RetryScenario::FailOnceThenSucceed: + if CallCount <= 1 then + ResponseJson := StrSubstNo(ErrorResponseTxt, ErrorCode) + else + ResponseJson := SuccessResponseTxt; + RetryScenario::AlwaysFail: + ResponseJson := StrSubstNo(ErrorResponseTxt, ErrorCode); + end; + + HttpResponseMessage.Content.WriteFrom(ResponseJson); + exit(HttpResponseMessage); + end; +} diff --git a/src/Apps/W1/Shopify/Test/Order Refunds/ShpfyOrderRefundsHelper.Codeunit.al b/src/Apps/W1/Shopify/Test/Order Refunds/ShpfyOrderRefundsHelper.Codeunit.al index 48fd0c0082..7f6e47be8a 100644 --- a/src/Apps/W1/Shopify/Test/Order Refunds/ShpfyOrderRefundsHelper.Codeunit.al +++ b/src/Apps/W1/Shopify/Test/Order Refunds/ShpfyOrderRefundsHelper.Codeunit.al @@ -186,17 +186,17 @@ codeunit 139564 "Shpfy Order Refunds Helper" exit(ReturnHeader."Return Id"); end; - internal procedure CreateReturnLine(ReturnOrderId: BigInteger; OrderLineId: BigInteger; ReturnReason: Text): BigInteger + internal procedure CreateReturnLine(ReturnOrderId: BigInteger; OrderLineId: BigInteger; ReturnReasonHandle: Text): BigInteger var ReturnLine: Record "Shpfy Return Line"; - ReturnEnumConvertor: Codeunit "Shpfy Return Enum Convertor"; begin ReturnLine."Return Line Id" := Any.IntegerInRange(100000, 999999); ReturnLine."Return Id" := ReturnOrderId; ReturnLine.Type := ReturnLine.Type::Default; ReturnLine."Fulfillment Line Id" := Any.IntegerInRange(100000, 999999); ReturnLine."Order Line Id" := OrderLineId; - ReturnLine."Return Reason" := ReturnEnumConvertor.ConvertToReturnReason(ReturnReason); + ReturnLine."Return Reason Handle" := CopyStr(ReturnReasonHandle, 1, MaxStrLen(ReturnLine."Return Reason Handle")); + ReturnLine."Return Reason Name" := CopyStr(GetReturnReasonNameFromHandle(ReturnReasonHandle), 1, MaxStrLen(ReturnLine."Return Reason Name")); ReturnLine.Quantity := 1; ReturnLine."Refundable Quantity" := 0; ReturnLine."Refunded Quantity" := 1; @@ -207,19 +207,48 @@ codeunit 139564 "Shpfy Order Refunds Helper" exit(ReturnLine."Return Line Id"); end; - internal procedure CreateUnverifiedReturnLine(ReturnId: BigInteger; ReturnReason: Text): BigInteger + internal procedure CreateUnverifiedReturnLine(ReturnId: BigInteger; ReturnReasonHandle: Text): BigInteger var ReturnLine: Record "Shpfy Return Line"; - ReturnEnumConvertor: Codeunit "Shpfy Return Enum Convertor"; begin ReturnLine."Return Line Id" := Any.IntegerInRange(100000, 999999); ReturnLine."Return Id" := ReturnId; ReturnLine.Type := ReturnLine.Type::Unverified; - ReturnLine."Return Reason" := ReturnEnumConvertor.ConvertToReturnReason(ReturnReason); + ReturnLine."Return Reason Handle" := CopyStr(ReturnReasonHandle, 1, MaxStrLen(ReturnLine."Return Reason Handle")); + ReturnLine."Return Reason Name" := CopyStr(GetReturnReasonNameFromHandle(ReturnReasonHandle), 1, MaxStrLen(ReturnLine."Return Reason Name")); ReturnLine.Quantity := 1; ReturnLine."Refundable Quantity" := 1; ReturnLine."Refunded Quantity" := 0; ReturnLine."Unit Price" := 156.38; + ReturnLine.Insert(); + exit(ReturnLine."Return Line Id"); + end; + + local procedure GetReturnReasonNameFromHandle(Handle: Text): Text + begin + // Map handle values to human-readable names (simulating Shopify's returnReasonDefinition) + case Handle of + 'DEFECTIVE': + exit('Defective'); + 'NOT_AS_DESCRIBED': + exit('Not as described'); + 'WRONG_ITEM': + exit('Wrong item'); + 'SIZE_TOO_SMALL': + exit('Size too small'); + 'SIZE_TOO_LARGE': + exit('Size too large'); + 'STYLE': + exit('Style'); + 'COLOR': + exit('Color'); + 'OTHER': + exit('Other'); + 'UNKNOWN': + exit('Unknown'); + else + exit(Handle); + end; end; internal procedure CreateRefundHeader(OrderId: BigInteger; ReturnId: BigInteger; Amount: Decimal): BigInteger diff --git a/src/Apps/W1/Shopify/Test/Payments/ShpfyPaymentsTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Payments/ShpfyPaymentsTest.Codeunit.al index 3e5271e343..860e54c1bf 100644 --- a/src/Apps/W1/Shopify/Test/Payments/ShpfyPaymentsTest.Codeunit.al +++ b/src/Apps/W1/Shopify/Test/Payments/ShpfyPaymentsTest.Codeunit.al @@ -17,6 +17,62 @@ codeunit 139566 "Shpfy Payments Test" Any: Codeunit Any; LibraryAssert: Codeunit "Library Assert"; + [Test] + procedure UnitTestImportPayoutWithExternalTraceId() + var + Payout: Record "Shpfy Payout"; + PaymentsAPI: Codeunit "Shpfy Payments API"; + Id: BigInteger; + ExpectedExternalTraceId: Text; + JPayout: JsonObject; + begin + // [SCENARIO] Import payout correctly imports the externalTraceId field (2026-01 API) + // [GIVEN] A random Generated Payout with externalTraceId + Id := Any.IntegerInRange(10000, 99999); + ExpectedExternalTraceId := Any.AlphanumericText(50); + JPayout := GetRandomPayout(Id, ExpectedExternalTraceId); + + // [WHEN] Invoke the function ImportPayout(JPayout) + PaymentsAPI.ImportPayout(JPayout); + + // [THEN] We must find the "Shpfy Payout" record with the correct externalTraceId + LibraryAssert.IsTrue(Payout.Get(Id), 'Get "Shpfy Payout" record'); + LibraryAssert.AreEqual(ExpectedExternalTraceId, Payout."External Trace Id", 'External Trace Id should match'); + end; + + local procedure GetRandomPayout(Id: BigInteger; ExternalTraceId: Text): JsonObject + var + JPayout: JsonObject; + JNet: JsonObject; + JSummary: JsonObject; + JAmount: JsonObject; + PayoutGidTxt: Label 'gid://shopify/ShopifyPaymentsPayout/%1', Comment = '%1 = id', Locked = true; + begin + JPayout.Add('id', StrSubstNo(PayoutGidTxt, Id)); + JPayout.Add('status', 'SCHEDULED'); + JPayout.Add('externalTraceId', ExternalTraceId); + JPayout.Add('issuedAt', Format(Today, 0, 9)); + JNet.Add('amount', Any.DecimalInRange(1000, 2)); + JNet.Add('currencyCode', 'USD'); + JPayout.Add('net', JNet); + + // Add summary with fee/gross amounts + JAmount.Add('amount', 0); + JSummary.Add('adjustmentsFee', JAmount); + JSummary.Add('adjustmentsGross', JAmount); + JSummary.Add('chargesFee', JAmount); + JSummary.Add('chargesGross', JAmount); + JSummary.Add('refundsFee', JAmount); + JSummary.Add('refundsFeeGross', JAmount); + JSummary.Add('reservedFundsFee', JAmount); + JSummary.Add('reservedFundsGross', JAmount); + JSummary.Add('retriedPayoutsFee', JAmount); + JSummary.Add('retriedPayoutsGross', JAmount); + JPayout.Add('summary', JSummary); + + exit(JPayout); + end; + [Test] procedure UnitTestImportPayment() var diff --git a/src/Apps/W1/Shopify/Test/Products/ShpfyVariantBatchingTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Products/ShpfyVariantBatchingTest.Codeunit.al new file mode 100644 index 0000000000..620254673d --- /dev/null +++ b/src/Apps/W1/Shopify/Test/Products/ShpfyVariantBatchingTest.Codeunit.al @@ -0,0 +1,196 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify.Test; + +using Microsoft.Integration.Shopify; +using System.TestLibraries.Utilities; + +/// +/// Codeunit Shpfy Variant Batching Test (ID 139620). +/// Tests for variant batch size calculations based on 50,000 inventory quantities limit. +/// +codeunit 139620 "Shpfy Variant Batching Test" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + var + Shop: Record "Shpfy Shop"; + Any: Codeunit Any; + LibraryAssert: Codeunit "Library Assert"; + ShpfyInitializeTest: Codeunit "Shpfy Initialize Test"; + IsInitialized: Boolean; + + local procedure Initialize() + var + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + begin + if IsInitialized then + exit; + IsInitialized := true; + Shop := ShpfyInitializeTest.CreateShop(); + CommunicationMgt.SetShop(Shop); + end; + + [Test] + procedure UnitTestGetMaxVariantsPerBatchNoLocations() + var + ShopLocation: Record "Shpfy Shop Location"; + VariantAPI: Codeunit "Shpfy Variant API"; + MaxVariants: Integer; + begin + // [SCENARIO] GetMaxVariantsPerBatch returns 250 when no active default product locations exist + // [GIVEN] No active default product locations for the shop + Initialize(); + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.DeleteAll(); + VariantAPI.SetShop(Shop); + + // [WHEN] GetMaxVariantsPerBatch is called + MaxVariants := VariantAPI.GetMaxVariantsPerBatch(); + + // [THEN] Returns 250 (default batch size) + LibraryAssert.AreEqual(250, MaxVariants, 'Expected 250 when no inventory locations exist'); + end; + + [Test] + procedure UnitTestGetMaxVariantsPerBatchSingleLocation() + var + ShopLocation: Record "Shpfy Shop Location"; + VariantAPI: Codeunit "Shpfy Variant API"; + MaxVariants: Integer; + begin + // [SCENARIO] GetMaxVariantsPerBatch returns 50000 when only 1 active default product location exists + // [GIVEN] 1 active default product location + Initialize(); + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.DeleteAll(); + CreateShopLocation(Shop.Code, true, true); + VariantAPI.SetShop(Shop); + + // [WHEN] GetMaxVariantsPerBatch is called + MaxVariants := VariantAPI.GetMaxVariantsPerBatch(); + + // [THEN] Returns 50000 (50000 div 1) + LibraryAssert.AreEqual(50000, MaxVariants, 'Expected 50000 when 1 location exists (50000 / 1)'); + end; + + [Test] + procedure UnitTestGetMaxVariantsPerBatchMultipleLocations() + var + ShopLocation: Record "Shpfy Shop Location"; + VariantAPI: Codeunit "Shpfy Variant API"; + MaxVariants: Integer; + begin + // [SCENARIO] GetMaxVariantsPerBatch returns 50000 div LocationCount when multiple locations exist + // [GIVEN] 100 active default product locations + Initialize(); + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.DeleteAll(); + CreateMultipleShopLocations(Shop.Code, 100); + VariantAPI.SetShop(Shop); + + // [WHEN] GetMaxVariantsPerBatch is called + MaxVariants := VariantAPI.GetMaxVariantsPerBatch(); + + // [THEN] Returns 500 (50000 div 100) + LibraryAssert.AreEqual(500, MaxVariants, 'Expected 500 when 100 locations exist (50000 / 100)'); + end; + + [Test] + procedure UnitTestGetMaxVariantsPerBatchHighLocationCount() + var + ShopLocation: Record "Shpfy Shop Location"; + VariantAPI: Codeunit "Shpfy Variant API"; + MaxVariants: Integer; + begin + // [SCENARIO] GetMaxVariantsPerBatch returns correct value for high location count + // [GIVEN] 500 active default product locations + Initialize(); + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.DeleteAll(); + CreateMultipleShopLocations(Shop.Code, 500); + VariantAPI.SetShop(Shop); + + // [WHEN] GetMaxVariantsPerBatch is called + MaxVariants := VariantAPI.GetMaxVariantsPerBatch(); + + // [THEN] Returns 100 (50000 div 500) + LibraryAssert.AreEqual(100, MaxVariants, 'Expected 100 when 500 locations exist (50000 / 500)'); + end; + + [Test] + procedure UnitTestGetDefaultLocationCountNoLocations() + var + ShopLocation: Record "Shpfy Shop Location"; + VariantAPI: Codeunit "Shpfy Variant API"; + LocationCount: Integer; + begin + // [SCENARIO] GetDefaultLocationCount returns 0 when no active default product locations exist + // [GIVEN] No active default product locations for the shop + Initialize(); + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.DeleteAll(); + VariantAPI.SetShop(Shop); + + // [WHEN] GetDefaultLocationCount is called + LocationCount := VariantAPI.GetDefaultLocationCount(); + + // [THEN] Returns 0 + LibraryAssert.AreEqual(0, LocationCount, 'Expected 0 when no default product locations exist'); + end; + + [Test] + procedure UnitTestGetDefaultLocationCountOnlyActiveDefaultLocations() + var + ShopLocation: Record "Shpfy Shop Location"; + VariantAPI: Codeunit "Shpfy Variant API"; + LocationCount: Integer; + begin + // [SCENARIO] GetDefaultLocationCount only counts active default product locations + // [GIVEN] Mix of active/inactive and default/non-default locations + Initialize(); + ShopLocation.SetRange("Shop Code", Shop.Code); + ShopLocation.DeleteAll(); + + // Active, default product location (should be counted) + CreateShopLocation(Shop.Code, true, true); + CreateShopLocation(Shop.Code, true, true); + // Active, but not default product location (should NOT be counted) + CreateShopLocation(Shop.Code, true, false); + // Inactive, default product location (should NOT be counted) + CreateShopLocation(Shop.Code, false, true); + + VariantAPI.SetShop(Shop); + + // [WHEN] GetDefaultLocationCount is called + LocationCount := VariantAPI.GetDefaultLocationCount(); + + // [THEN] Returns 2 (only active default product locations) + LibraryAssert.AreEqual(2, LocationCount, 'Expected 2 (only active default product locations)'); + end; + + local procedure CreateShopLocation(ShopCode: Code[20]; Active: Boolean; DefaultProductLocation: Boolean) + var + ShopLocation: Record "Shpfy Shop Location"; + begin + ShopLocation.Init(); + ShopLocation."Shop Code" := ShopCode; + ShopLocation.Id := Any.IntegerInRange(10000, 9999999); + ShopLocation.Active := Active; + ShopLocation."Default Product Location" := DefaultProductLocation; + ShopLocation.Insert(); + end; + + local procedure CreateMultipleShopLocations(ShopCode: Code[20]; Count: Integer) + var + i: Integer; + begin + for i := 1 to Count do + CreateShopLocation(ShopCode, true, true); + end; +}