Skip to content
Open
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"require": {
"ext-zip": "*",
"fruitcake/php-cors": "^1.2",
"myparcelnl/sdk": "^11.0.0-beta.30@beta",
"myparcelnl/sdk": "^11.0.0-beta.31@beta",
"php": ">=7.4.0",
"php-di/php-di": "^6.0.0",
"psr/log": "^1.0.0 || ^2.0.0 || ^3.0.0",
Expand Down Expand Up @@ -77,4 +77,4 @@
"pestphp/pest-plugin": true
}
}
}
}
4 changes: 2 additions & 2 deletions config/pdk-business-logic.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use MyParcelNL\Pdk\App\Options\Definition\SameDayDeliveryDefinition;
use MyParcelNL\Pdk\App\Options\Definition\SaturdayDeliveryDefinition;
use MyParcelNL\Pdk\App\Options\Definition\SignatureDefinition;
use MyParcelNL\Pdk\App\Options\Definition\TrackedDefinition;
use MyParcelNL\Pdk\App\Options\Definition\NoTrackingDefinition;
use MyParcelNL\Pdk\App\Order\Calculator\General\CapabilitiesDeliveryTypeCalculator;
use MyParcelNL\Pdk\App\Order\Calculator\General\CapabilitiesOptionCalculator;
use MyParcelNL\Pdk\App\Order\Calculator\General\CapabilitiesPackageTypeCalculator;
Expand Down Expand Up @@ -77,7 +77,7 @@
new SameDayDeliveryDefinition(),
new SaturdayDeliveryDefinition(),
new SignatureDefinition(),
new TrackedDefinition(),
new NoTrackingDefinition(),
new ReceiptCodeDefinition(),
new CollectDefinition(),
new ExcludeParcelLockersDefinition(),
Expand Down
8 changes: 8 additions & 0 deletions config/pdk-default.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
'printingApiUrl' => env('PDK_PRINTING_API_URL', 'https://printing.api.myparcel.nl'),
'addressesServiceUrl' => env('PDK_ADDRESSES_SERVICE_URL', 'https://address.api.myparcel.nl'),

/**
* Feature flags sent as `x-dmp-*` headers on every MyParcel API request. What a flag changes
* differs per flag, so check the overview before adding or removing one.
*
* @see https://myparcelnl.atlassian.net/wiki/spaces/MD/pages/12779590/API+feature+flags
*/
'apiFeatureFlags' => value(['x-dmp-no-tracking']),

/**
* Security settings for the proxy
*/
Expand Down
9 changes: 8 additions & 1 deletion src/Api/Service/MyParcelApiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MyParcelNL\Pdk\Api\Service;

use MyParcelNL\Pdk\Base\Support\ApiFeatureFlags;
use MyParcelNL\Pdk\Facade\Pdk;
use MyParcelNL\Pdk\Facade\Settings;
use MyParcelNL\Pdk\Settings\Model\AccountSettings;
Expand All @@ -16,14 +17,20 @@
class MyParcelApiService extends AbstractApiService
{
/**
* Headers sent on every legacy MyParcel API request.
*
* {@see AbstractApiService::doRequest()} merges these with the per-request headers, so adding the
* feature flags here covers the shipments and orders v1 endpoints in one place. That merge favours
* the per-request headers, so a request that sets the same header keeps its own value.
*
* @return array
*/
public function getHeaders(): array
{
return [
'Authorization' => $this->getAuthorizationHeader(),
'User-Agent' => $this->getUserAgentHeader(),
];
] + ApiFeatureFlags::getHeaders();
}

/**
Expand Down
13 changes: 12 additions & 1 deletion src/App/DeliveryOptions/Service/DeliveryOptionsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use MyParcelNL\Pdk\Base\Support\Collection;
use MyParcelNL\Pdk\Base\Support\SettingKey;
use MyParcelNL\Pdk\Base\Support\Utils;
use MyParcelNL\Pdk\Carrier\Collection\CarrierCollection;
use MyParcelNL\Pdk\Carrier\Contract\CarrierRepositoryInterface;
use MyParcelNL\Pdk\Carrier\Model\Carrier;
use MyParcelNL\Pdk\Carrier\Service\CapabilitiesValidationService;
Expand Down Expand Up @@ -229,8 +230,18 @@ private function getBaseSettings(CarrierSettings $carrierSettings, PdkCart $cart
*/
private function getValidCarrierOptions(PdkCart $cart): array
{
$carrierSettings = Settings::get(CarrierSettings::ID);

$carrierSettings = array_filter(
is_array($carrierSettings) ? $carrierSettings : [],
static fn($settings): bool => is_array($settings)
);

if (empty($carrierSettings)) {
return [DeliveryOptions::DEFAULT_PACKAGE_TYPE_NAME, new CarrierCollection()];
}

$allCarriers = $this->carrierRepository->all();
$carrierSettings = Settings::get(CarrierSettings::ID);
$shippingAddress = $cart->shippingMethod->shippingAddress;
$cc = $shippingAddress->cc ?? null;
$isBusiness = $shippingAddress->isBusiness;
Expand Down
11 changes: 5 additions & 6 deletions src/App/Endpoint/Resource/DeliveryOptionsV1Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use MyParcelNL\Pdk\App\Options\Definition\SameDayDeliveryDefinition;
use MyParcelNL\Pdk\App\Options\Definition\SaturdayDeliveryDefinition;
use MyParcelNL\Pdk\App\Options\Definition\SignatureDefinition;
use MyParcelNL\Pdk\App\Options\Definition\TrackedDefinition;
use MyParcelNL\Pdk\App\Options\Definition\NoTrackingDefinition;
use MyParcelNL\Pdk\Base\Model\Currency;
use MyParcelNL\Pdk\Carrier\Model\Carrier;
use MyParcelNL\Pdk\Facade\Logger;
Expand Down Expand Up @@ -85,13 +85,12 @@ private static function formatShipmentOptions(ShipmentOptions $shipmentOptions):
$orderApiShipmentOptions = ModelShipmentOptions::attributeMap();

/*
* Handle the inversion of tracked to no_tracking
* New tracking implementation: When tracked option is not present or explicitly enabled we do nothing (tracking is enabled by default in the order service)
* Only when tracking is explicitly disabled we include the "noTracking" option with an ADR-0013 compliant empty object as value
* Tracking is enabled by default in the order service, so the key is only sent when a merchant
* explicitly opted out. The value is an ADR-0013 compliant empty object.
*/
$trackedKey = (new TrackedDefinition())->getShipmentOptionsKey();
$noTrackingKey = (new NoTrackingDefinition())->getShipmentOptionsKey();

if ($shipmentOptions->{$trackedKey} === TriStateService::DISABLED) {
if ($shipmentOptions->{$noTrackingKey} === TriStateService::ENABLED) {
$formattedOptions[$orderApiShipmentOptions['no_tracking']] = new ArrayObject();
}

Expand Down
93 changes: 93 additions & 0 deletions src/App/Installer/Service/PagedMigrationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace MyParcelNL\Pdk\App\Installer\Service;

use MyParcelNL\Pdk\Base\Contract\CronServiceInterface;
use MyParcelNL\Pdk\Facade\Logger;

/**
* Schedules a migration across many records without loading them all at once.
*
* A migration that has to touch every order or every product cannot run inline: a large shop would
* time out. Instead the work is split into chunks, each handed to the cron service as a separate task,
* staggered so they do not all fire at the same moment.
*
* Finding the records stays with the caller, because the query is platform-specific — orders and
* products are fetched in completely different ways. Pass a fetcher that returns one page of ids at a
* time and this service takes care of paging until the fetcher runs dry, plus the scheduling and the
* logging.
*
* The scheduled action must be registered by the platform, because the callback has to survive into a
* later request. That is why a timestamped migration can schedule work here but cannot be the callback
* itself: it is an anonymous class and nothing can address it later.
*/
class PagedMigrationService
{
/**
* @var \MyParcelNL\Pdk\Base\Contract\CronServiceInterface
*/
private $cronService;

public function __construct(CronServiceInterface $cronService)
{
$this->cronService = $cronService;
}

/**
* Schedule $cronAction once per page of ids, until the fetcher returns an empty page.
*
* Each scheduled task receives one argument: an array holding the page's ids under "ids" and the
* 1-based chunk number under "chunk".
*
* @param string $cronAction Registered action the platform dispatches per chunk
* @param callable $fetchPage fn(int $page, int $pageSize): int[]
* @param int $pageSize How many records per chunk
* @param int $secondsBetweenChunks Delay added per chunk, so they do not all fire at once
*
* @return int The number of chunks scheduled
*/
public function schedulePages(
string $cronAction,
callable $fetchPage,
int $pageSize = 100,
int $secondsBetweenChunks = 5
): int {
$page = 1;
$chunks = 0;
$now = time();

while (true) {
$ids = $fetchPage($page, $pageSize);

if (empty($ids)) {
break;
}

$timestamp = $now + $chunks * $secondsBetweenChunks;

$this->cronService->schedule($cronAction, $timestamp, [
'ids' => $ids,
'chunk' => $chunks + 1,
]);

Logger::debug('Scheduled migration chunk', [
'action' => $cronAction,
'chunk' => $chunks + 1,
'records' => count($ids),
'timestamp' => $timestamp,
]);

$chunks++;
$page++;

// A page that came back short is the last one, so stop rather than asking for another.
if (count($ids) < $pageSize) {
break;
}
}

return $chunks;
}
}
44 changes: 44 additions & 0 deletions src/App/Options/Definition/NoTrackingDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace MyParcelNL\Pdk\App\Options\Definition;

use MyParcelNL\Sdk\Client\Generated\CoreApi\Model\RefCapabilitiesContractDefinitionsResponseOptionsOptionsV2;
use MyParcelNL\Sdk\Client\Generated\CoreApi\Model\RefShipmentShipmentOptions;
use MyParcelNL\Sdk\Support\Str;

/**
* Opting out of track & trace.
*
* Replaces the `tracked` option, which the API is retiring. Tracking is the default wherever it is
* available, so this option only ever expresses an explicit opt-out: it is sent to the API when a
* merchant switched it on, and omitted otherwise.
*
* Carrier- and product-level settings keys are deliberately inherited rather than overridden, so the
* option keeps the carrier default and the per-product override the old `tracked` setting had. There
* is no allow or price key: not tracking is cheaper, so a surcharge would need a negative price, and
* the consumer must not be able to switch tracking off in the checkout.
*/
final class NoTrackingDefinition extends AbstractOrderOptionDefinition
{
public function getShipmentOptionsKey(): ?string
{
return Str::camel(RefShipmentShipmentOptions::attributeMap()['no_tracking']);
}

public function getCapabilitiesOptionsKey(): ?string
{
return RefCapabilitiesContractDefinitionsResponseOptionsOptionsV2::attributeMap()['no_tracking'];
}

public function getAllowSettingsKey(): ?string
{
return null;
}

public function getPriceSettingsKey(): ?string
{
return null;
}
}
32 changes: 0 additions & 32 deletions src/App/Options/Definition/TrackedDefinition.php

This file was deleted.

41 changes: 41 additions & 0 deletions src/Base/Support/ApiFeatureFlags.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace MyParcelNL\Pdk\Base\Support;

use MyParcelNL\Pdk\Facade\Pdk;

/**
* Turns the configured API feature flags into request headers.
*
* The MyParcel APIs expose feature flags as `x-dmp-*` headers. What a flag changes differs per flag,
* so read up on one before switching it on. Which flags we send is a deployment decision rather than
* a merchant one, so it lives in config.
*
* Call this from any request layer that has to pass the flags along, so there is one list to change
* when a flag is added or removed.
*
* @see https://myparcelnl.atlassian.net/wiki/spaces/MD/pages/12779590/API+feature+flags
*/
final class ApiFeatureFlags
{
/**
* The headers to add to an outgoing MyParcel API request.
*
* Returns an empty array when nothing is configured, so callers can merge unconditionally.
*
* @return array<string, string>
*/
public static function getHeaders(): array
{
/** @var null|string[] $flags */
$flags = Pdk::get('apiFeatureFlags');

if (empty($flags)) {
return [];
}

return array_fill_keys($flags, 'true');
}
}
10 changes: 8 additions & 2 deletions src/Carrier/Repository/CarrierCapabilitiesRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ public function __construct(StorageInterface $storage, CapabilitiesService $apiS
*
* If a carrier name is provided, only return the contract definition for that carrier.
*
* Results are cached per carrier, because a single page can ask for them many times. Pass $fresh
* when the caller knows the cached copy is stale — an upgrade that changes the shape of the
* response, for example — so the API is called again and the cache is rewritten rather than
* merely bypassed.
*
* @param null|string $carrier Carrier name in v2 format (eg. "POSTNL")
* @param bool $fresh Skip the cached copy and fetch from the API
* @return CarrierCollection
*/
public function getContractDefinitions(?string $carrier = null): CarrierCollection
public function getContractDefinitions(?string $carrier = null, bool $fresh = false): CarrierCollection
{
$cacheKey = "contractDefinitions.$carrier";

Expand All @@ -52,7 +58,7 @@ public function getContractDefinitions(?string $carrier = null): CarrierCollecti

// Convert the array of contract definitions to a CarrierCollection of Carrier models
return new CarrierCollection($contractDefinitions);
});
}, $fresh);
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/Carrier/Service/CapabilitiesValidationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,16 @@ private function getEnabledCarrierNames(): array
{
$carrierSettings = Settings::get(CarrierSettings::ID) ?? [];

if (! is_array($carrierSettings)) {
return [];
}

return array_keys(
array_filter(
$carrierSettings,
static function ($settings): bool {
return ! empty($settings[CarrierSettings::DELIVERY_OPTIONS_ENABLED]);
return is_array($settings)
&& ! empty($settings[CarrierSettings::DELIVERY_OPTIONS_ENABLED]);
}
)
);
Expand Down
Loading
Loading