bug fixes

This commit is contained in:
Dennis Eichhorn 2023-12-08 21:52:34 +00:00
parent b447c0772c
commit b5944503d8
57 changed files with 3339 additions and 179 deletions

View File

@ -22,9 +22,215 @@ namespace phpOMS\Algorithm\Clustering;
* @link https://jingga.app
* @see ./clustering_overview.png
* @since 1.0.0
*
* @todo Implement
*/
final class AffinityPropagation
final class AffinityPropagation implements ClusteringInterface
{
/**
* Points of the cluster centers
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $clusterCenters = [];
/**
* Cluster points
*
* Points in clusters (helper to avoid looping the cluster array)
*
* @var array
* @since 1.0.0
*/
private array $clusters = [];
private array $similarityMatrix = [];
private array $responsibilityMatrix = [];
private array $availabilityMatrix = [];
private array $points = [];
/**
* @param PointInterface[] $points
*/
private function createSimilarityMatrix(array $points)
{
$n = \count($points);
$coordinates = \count($points[0]->coordinates);
$similarityMatrix = \array_fill(0, $n, []);
$temp = [];
for ($i = 0; $i < $n - 1; ++$i) {
for ($j = $i + 1; $j < $n; ++$j) {
$sum = 0.0;
for ($c = 0; $c < $coordinates; ++$c) {
$sum += ($points[$i]->getCoordinate($c) - $points[$j]->getCoordinate($c)) * ($points[$i]->getCoordinate($c) - $points[$j]->getCoordinate($c));
}
$similarityMatrix[$i][$j] = -$sum;
$similarityMatrix[$j][$i] = -$sum;
$temp[] = $similarityMatrix[$i][$j];
}
}
\sort($temp);
$size = $n * ($n - 1) / 2;
$median = $size % 2 === 0
? ($temp[(int) ($size / 2)] + $temp[(int) ($size / 2 - 1)]) / 2
: $temp[(int) ($size / 2)];
for ($i = 0; $i < $n; ++$i) {
$similarityMatrix[$i][$i] = $median;
}
return $similarityMatrix;
}
public function generateClusters(array $points, int $iterations = 100) : void
{
$this->points = $points;
$n = \count($points);
$this->similarityMatrix = $this->createSimilarityMatrix($points);
$this->responsibilityMatrix = clone $this->similarityMatrix;
$this->availabilityMatrix = clone $this->similarityMatrix;
for ($c = 0; $c < $iterations; ++$c) {
for ($i = 0; $i < $n; ++$i) {
for ($k = 0; $k < $n; ++$k) {
$max = \PHP_INT_MIN;
for ($j = 0; $j < $k; ++$j) {
if (($temp = $this->similarityMatrix[$i][$j] + $this->availabilityMatrix[$i][$j]) > $max) {
$max = $temp;
}
}
for ($j = $k + 1; $j < $n; ++$j) {
if (($temp = $this->similarityMatrix[$i][$j] + $this->availabilityMatrix[$i][$j]) > $max) {
$max = $temp;
}
}
$this->responsibilityMatrix[$i][$k] = (1 - 0.9) * ($this->similarityMatrix[$i][$k] - $max) + 0.9 * $this->responsibilityMatrix[$i][$k];
}
}
for ($i = 0; $i < $n; ++$i) {
for ($k = 0; $k < $n; ++$k) {
$sum = 0.0;
if ($i === $k) {
for ($j = 0; $j < $i; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
for ($j = $j + 1; $j < $n; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
$this->availabilityMatrix[$i][$k] = (1 - 0.9) * $sum + 0.9 * $this->availabilityMatrix[$i][$k];
} else {
$max = \max($i, $k);
$min = \min($i, $k);
for ($j = 0; $j < $min; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
for ($j = $min + 1; $j < $max; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
for ($j = $max + 1; $j < $n; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
$this->availabilityMatrix[$i][$k] = (1 - 0.9) * \min(0.0, $this->responsibilityMatrix[$k][$k] + $sum) + 0.9 * $this->availabilityMatrix[$i][$k];
}
}
}
}
// find center points (exemplar)
for ($i = 0; $i < $n; ++$i) {
$temp = $this->responsibilityMatrix[$i][$i] + $this->availabilityMatrix[$i][$i];
if ($temp > 0) {
$this->clusterCenters[$i] = $this->points[$i];
}
}
}
private function findNearestGroup(array $similarityMatrix, int $point, int $clusterCount) : int
{
$maxSim = \PHP_INT_MIN;
$group = 0;
foreach ($this->clusterCenters as $c => $_) {
if ($similarityMatrix[$point][$c] > $maxSim) {
$maxSim = $similarityMatrix[$point][$c];
$group = $c;
}
}
return $group;
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
$points = clone $this->points;
$points[] = $point;
$similarityMatrix = $this->createSimilarityMatrix($points);
$c = $this->findNearestGroup(
$similarityMatrix,
\count($points) - 1,
\count($this->clusterCenters)
);
return $this->clusterCenters[$c];
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
if (!empty($this->clusters)) {
return $this->clusters;
}
$clusterCount = \count($this->clusterCenters);
$n = \count($this->points);
for ($i = 0; $i < $n; ++$i) {
$group = $this->findNearestGroup($this->points, $i, $clusterCount);
$this->clusters[$group] = $this->points[$i];
}
return $this->clusters;
}
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
}

View File

@ -14,17 +14,114 @@ declare(strict_types=1);
namespace phpOMS\Algorithm\Clustering;
use phpOMS\Math\Topology\MetricsND;
/**
* Clustering points
*
* The parent category of this clustering algorithm is hierarchical clustering.
*
* @package phpOMS\Algorithm\Clustering
* @license Base: MIT Copyright (c) 2020 Greene Laboratory
* @license OMS License 2.0
* @link https://jingga.app
* @see ./DivisiveClustering.php
* @see ./clustering_overview.png
* @see https://en.wikipedia.org/wiki/Hierarchical_clustering
* @see https://github.com/greenelab/hclust/blob/master/README.md
* @since 1.0.0
*
* @todo Implement
* @todo Implement missing linkage functions
*/
final class AgglomerativeClustering
final class AgglomerativeClustering implements ClusteringInterface
{
/**
* Metric to calculate the distance between two points
*
* @var \Closure
* @since 1.0.0
*/
private \Closure $metric;
/**
* Metric to calculate the distance between two points
*
* @var \Closure
* @since 1.0.0
*/
private \Closure $linkage;
/**
* Constructor
*
* @param null|\Closure $metric metric to use for the distance between two points
*
* @since 1.0.0
*/
public function __construct(\Closure $metric = null, \Closure $linkage = null)
{
$this->metric = $metric ?? function (PointInterface $a, PointInterface $b) {
$aCoordinates = $a->coordinates;
$bCoordinates = $b->coordinates;
return MetricsND::euclidean($aCoordinates, $bCoordinates);
};
$this->linkage = $linkage ?? function (array $a, array $b, array $distances) {
return AgglomerativeClustering::averageDistanceLinkage($a, $b, $distances);
};
}
/**
* Maximum/Complete-Linkage clustering
*
*/
public static function maximumDistanceLinkage(array $setA, array $setB, array $distances) : float
{
$max = \PHP_INT_MIN;
foreach ($setA as $a) {
foreach ($setB as $b) {
if ($distances[$a][$b] > $max) {
$max = $distances[$a][$b];
}
}
}
return $max;
}
/**
* Minimum/Single-Linkage clustering
*
*/
public static function minimumDistanceLinkage(array $setA, array $setB, array $distances) : float
{
$min = \PHP_INT_MAX;
foreach ($setA as $a) {
foreach ($setB as $b) {
if ($distances[$a][$b] < $min) {
$min = $distances[$a][$b];
}
}
}
return $min;
}
/**
* Unweighted average linkage clustering (UPGMA)
*
*/
public static function averageDistanceLinkage(array $setA, array $setB, array $distances) : float
{
$distance = 0;
foreach ($setA as $a) {
$distance += \array_sum($distances[$a]);
}
return $distance / \count($setA) / \count($setB);
}
}

View File

@ -25,6 +25,6 @@ namespace phpOMS\Algorithm\Clustering;
*
* @todo Implement
*/
final class Birch
final class Birch implements ClusteringInterface
{
}

View File

@ -0,0 +1,71 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Algorithm\Clustering
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Algorithm\Clustering;
/**
* Clustering interface.
*
* @package phpOMS\Algorithm\Clustering;
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
interface ClusteringInterface
{
/**
* Get cluster centroids
*
* @return PointInterface[]
*
* @since 1.0.0
*/
public function getCentroids() : array;
/**
* Get cluster assignments of the training data
*
* @return PointInterface[]
*
* @since 1.0.0
*/
public function getClusters() : array;
/**
* Cluster a single point
*
* This point doesn't have to be in the training data.
*
* @param PointInterface $point Point to cluster
*
* @return null|PointInterface
*
* @since 1.0.0
*/
public function cluster(PointInterface $point) : ?PointInterface;
/**
* Get noise data.
*
* Data points from the training data that are not part of a cluster.
*
* @return PointInterface[]
*
* @since 1.0.0
*/
public function getNoise() : array;
// Not possible to interface due to different implementations
// public function generateClusters(...) : void
}

View File

@ -29,7 +29,7 @@ use phpOMS\Math\Topology\MetricsND;
*
* @todo Expand to n dimensions
*/
final class DBSCAN
final class DBSCAN implements ClusteringInterface
{
/**
* Epsilon for float comparison.
@ -63,12 +63,20 @@ final class DBSCAN
*/
private array $points = [];
/**
* Points of the cluster centers
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $clusterCenters = [];
/**
* Clusters
*
* Array of points assigned to a cluster
*
* @var array<int, array>
* @var array<int, PointInterface[]>
* @since 1.0.0
*/
private array $clusters = [];
@ -215,15 +223,9 @@ final class DBSCAN
}
/**
* Find the cluster for a point
*
* @param PointInterface $point Point to find the cluster for
*
* @return int Cluster id
*
* @since 1.0.0
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : int
public function cluster(PointInterface $point) : ?PointInterface
{
if ($this->convexHulls === []) {
foreach ($this->clusters as $c => $cluster) {
@ -232,18 +234,18 @@ final class DBSCAN
$points[] = $p->coordinates;
}
// @todo: this is only good for 2D. Fix this for ND.
// @todo this is only good for 2D. Fix this for ND.
$this->convexHulls[$c] = MonotoneChain::createConvexHull($points);
}
}
foreach ($this->convexHulls as $c => $hull) {
if (Polygon::isPointInPolygon($point->coordinates, $hull) <= 0) {
return $c;
return $hull;
}
}
return -1;
return null;
}
/**
@ -282,4 +284,48 @@ final class DBSCAN
}
}
}
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
if (!empty($this->clusterCenters)) {
return $this->clusterCenters;
}
$dim = \count(\reset($this->points)->getCoordinates());
foreach ($this->clusters as $cluster) {
$middle = \array_fill(0, $dim, 0);
foreach ($cluster as $point) {
for ($i = 0; $i < $dim; ++$i) {
$middle[$i] += $point->getCoordinate($i);
}
}
for ($i = 0; $i < $dim; ++$i) {
$middle[$i] /= \count($cluster);
}
$this->clusterCenters = new Point($middle);
}
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return $this->noisePoints;
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return $this->clusters;
}
}

View File

@ -14,17 +14,23 @@ declare(strict_types=1);
namespace phpOMS\Algorithm\Clustering;
use phpOMS\Math\Topology\MetricsND;
/**
* Clustering points
*
* The parent category of this clustering algorithm is hierarchical clustering.
*
* @package phpOMS\Algorithm\Clustering
* @license OMS License 2.0
* @link https://jingga.app
* @see ./AgglomerativeClustering.php
* @see ./clustering_overview.png
* @see https://en.wikipedia.org/wiki/Hierarchical_clustering
* @since 1.0.0
*
* @todo Implement
*/
final class Ward
final class DivisiveClustering implements ClusteringInterface
{
}

View File

@ -25,7 +25,7 @@ use phpOMS\Math\Topology\MetricsND;
* @see ./clustering_overview.png
* @since 1.0.0
*/
final class Kmeans
final class Kmeans implements ClusteringInterface
{
/**
* Epsilon for float comparison.
@ -49,7 +49,23 @@ final class Kmeans
* @var PointInterface[]
* @since 1.0.0
*/
private $clusterCenters = [];
private array $clusterCenters = [];
/**
* Points of the clusters
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $clusters = [];
/**
* Points
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $points = [];
/**
* Constructor
@ -66,18 +82,10 @@ final class Kmeans
return MetricsND::euclidean($aCoordinates, $bCoordinates);
};
//$this->generateClusters($points, $clusters);
}
/**
* Find the cluster for a point
*
* @param PointInterface $point Point to find the cluster for
*
* @return null|PointInterface Cluster center point
*
* @since 1.0.0
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
@ -95,17 +103,21 @@ final class Kmeans
}
/**
* Get cluster centroids
*
* @return array
*
* @since 1.0.0
* {@inheritdoc}
*/
public function getCentroids() : array
{
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
/**
* Generate the clusters of the points
*
@ -118,6 +130,7 @@ final class Kmeans
*/
public function generateClusters(array $points, int $clusters) : void
{
$this->points = $points;
$n = \count($points);
$clusterCenters = $this->kpp($points, $clusters);
$coordinates = \count($points[0]->coordinates);
@ -245,4 +258,21 @@ final class Kmeans
return $clusters;
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
if (!empty($this->clusters)) {
return $this->clusters;
}
foreach ($this->points as $point) {
$c = $this->cluster($point);
$this->clusters[$c] = $point;
}
return $this->clusters;
}
}

View File

@ -25,8 +25,10 @@ use phpOMS\Math\Topology\MetricsND;
* @link https://jingga.app
* @see ./clustering_overview.png
* @since 1.0.0
*
* @todo Implement noise points
*/
final class MeanShift
final class MeanShift implements ClusteringInterface
{
/**
* Min distance for clustering
@ -80,7 +82,7 @@ final class MeanShift
* @var PointInterface[]
* @since 1.0.0
*/
private $clusterCenters = [];
private array $clusterCenters = [];
/**
* Max distance to cluster to be still considered part of cluster
@ -126,8 +128,9 @@ final class MeanShift
*/
public function generateClusters(array $points, array $bandwidth) : void
{
$shiftPoints = $points;
$maxMinDist = 1;
$this->points = $points;
$shiftPoints = $points;
$maxMinDist = 1;
$stillShifting = \array_fill(0, \count($points), true);
@ -292,13 +295,15 @@ final class MeanShift
}
/**
* Find the cluster for a point
*
* @param PointInterface $point Point to find the cluster for
*
* @return null|PointInterface Cluster center point
*
* @since 1.0.0
* {@inheritdoc}
*/
public function getCentroids() : array
{
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
@ -306,4 +311,20 @@ final class MeanShift
return $this->clusterCenters[$clusterId] ?? null;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return $this->noisePoints;
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return $this->clusters;
}
}

View File

@ -25,6 +25,6 @@ namespace phpOMS\Algorithm\Clustering;
*
* @todo Implement
*/
final class SpectralClustering
final class SpectralClustering implements ClusteringInterface
{
}

View File

@ -58,7 +58,7 @@ class SimulatedAnnealing
// Simulated Annealing algorithm
// @todo allow to create a solution space (currently all soluctions need to be in space)
// @todo: currently only replacing generations, not altering them
// @todo currently only replacing generations, not altering them
/**
* Perform optimization
*

View File

@ -80,16 +80,16 @@ final class Elo
*
* @param int $elo1 Elo of the player we want to calculate the win probability for
* @param int $elo2 Opponent elo
* @param bool $draw Is a draw possible?
* @param bool $canDraw Is a draw possible?
*
* @return float
*
* @since 1.0.0
*/
public function winProbability(int $elo1, int $elo2, bool $draw = false) : float
public function winProbability(int $elo1, int $elo2, bool $canDraw = false) : float
{
return $draw
? -1.0 // @todo: implement
return $canDraw
? -1.0 // @todo implement
: 1 / (1 + \pow(10, ($elo2 - $elo1) / 400));
}
@ -105,6 +105,6 @@ final class Elo
*/
public function drawProbability(int $elo1, int $elo2) : float
{
return -1.0; // @todo: implement
return -1.0; // @todo implement
}
}

View File

@ -0,0 +1,3 @@
<?php
// @todo implement address validation (google?)

View File

@ -0,0 +1,36 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Stdlib\Base\Enum;
/**
* Auth Status
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class AuthStatus extends Enum
{
public const OK = 0;
public const FAILED = -1;
public const BLOCKED = -2;
public const LIMIT_EXCEEDED = -3;
}

34
Api/Shipping/AuthType.php Normal file
View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Stdlib\Base\Enum;
/**
* Auth Type
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class AuthType extends Enum
{
public const AUTOMATIC_LOGIN = 2;
public const MANUAL_LOGIN = 4;
public const KEY_LOGIN = 8;
}

View File

@ -0,0 +1,388 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DHL
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DHL;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\AuthType;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment API.
*
* In the third party API the following definitions are important to know:
*
* 1. Order: A collection of shipments with the same service (standard, return, packet plus, packet tracked)
* 2. Item: A shipment/package
*
* @package phpOMS\Api\Shipping\DHL
* @license OMS License 2.0
* @link https://jingga.app
* @see General: https://developer.dhl.com/
* @see Special: https://developer.dhl.com/api-reference/deutsche-post-international-post-parcel-germany#get-started-section/
* @see Tracking: https://developer.dhl.com/api-reference/shipment-tracking#get-started-section/
* @since 1.0.0
*/
final class DHLInternationalShipping implements ShippingInterface
{
/**
* Api version
*
* @var string
* @since 1.0.0
*/
public const API_VERSION = 'v1';
/**
* API environment.
*
* @var string
* @since 1.0.0
*/
public static string $ENV = 'live';
/**
* API link to live/production version.
*
* @var string
* @since 1.0.0
*/
public const LIVE_URL = 'https://api-eu.dhl.com/dpi';
/**
* API link to test/sandbox version.
*
* @var string
* @since 1.0.0
*/
public const SANDBOX_URL = 'https://api-sandbox.dhl.com/dpi';
/**
* API link to test/sandbox version.
*
* This implementation uses different testing urls for the different endpoints
*
* @var string
* @since 1.0.0
*/
public const SANDBOX2_URL = 'https://api-test.dhl.com';
/**
* The type of authentication that is supported.
*
* @var int
* @since 1.0.0
*/
public const AUTH_TYPE = AuthType::AUTOMATIC_LOGIN | AuthType::KEY_LOGIN;
/**
* Minimum auth expiration time until re-auth.
*
* @var int
* @since 1.0.0
*/
public const TIME_DELTA = 10;
/**
* Client id
*
* @var string
* @since 1.0.0
*/
public string $client = '';
/**
* Login id
*
* @var string
* @since 1.0.0
*/
public string $login = '';
/**
* Password
*
* @var string
* @since 1.0.0
*/
public string $password = '';
/**
* Current auth token
*
* @var string
* @since 1.0.0
*/
public string $token = '';
/**
* Current auth refresh token in case the token expires
*
* @var string
* @since 1.0.0
*/
public string $refreshToken = '';
/**
* Api Key
*
* @var string
* @since 1.0.0
*/
public string $apiKey = '';
/**
* Token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $expire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $refreshExpire;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
}
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
string $client = null,
string $payload = null
) : int
{
$this->client = $client ?? $this->client;
$this->login = $login;
$this->password = $password;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/' . self::API_VERSION . '/auth/accesstoken';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Content-Type', 'application/json');
$request->header->set('Accept', '*/*');
$request->header->set('Authorization', 'Basic ' . \base64_encode($login . ':' . $password));
$this->expire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getData('access_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
}
public function cancel(string $shipment, array $packages = []) : bool
{
}
public function track(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX2_URL;
$uri = $base . '/track/shipments';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('trackingnumber', $shipment);
$httpUri->addQuery('limit', 10);
// @todo implement: express, parcel-de, ecommerce, dgf, parcel-uk, post-de, sameday, freight, parcel-nl, parcel-pl, dsc, ecommerce-europe, svb
//$httpUri->addQuery('service', '');
// @odo: implement
//$httpUri->addQuery('requesterCountryCode', '');
//$httpUri->addQuery('originCountryCode', '');
//$httpUri->addQuery('recipientPostalCode', '');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('accept', 'application/json');
$request->header->set('dhl-api-key', $this->apiKey);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$shipments = $response->getDataArray('shipments') ?? [];
$tracking = [];
// @todo add general shipment status (not just for individual packages)
foreach ($shipments as $shipment) {
$packages = [];
$package = $shipment;
$activities = [];
foreach ($package['events'] as $activity) {
$activities[] = [
'date' => new \DateTime($activity['timestamp']),
'description' => $activity['description'],
'location' => [
'address' => [
$activity['location']['address']['streetAddress'],
$activity['location']['address']['addressLocality'],
],
'city' => '',
'country' => '',
'country_code' => $activity['location']['address']['countryCode'],
'zip' => $activity['location']['address']['postalCode'],
'state' => '',
],
'status' => [
'code' => $activity['statusCode'],
'statusCode' => $activity['statusCode'],
'description' => $activity['status'],
]
];
}
$packages[] = [
'status' => [
'code' => $package['status']['statusCode'],
'statusCode' => $package['status']['statusCode'],
'description' => $package['status']['status'],
],
'deliveryDate' => new \DateTime($package['estimatedTimeOfDelivery']),
'count' => $package['details']['totalNumberOfPieces'],
'weight' => $package['details']['weight']['weight'],
'weight_unit' => 'g',
'activities' => $activities,
'received' => [
'by' => $package['details']['proofOfDelivery']['familyName'],
'signature' => $package['details']['proofOfDelivery']['signatureUrl'],
'location' => '',
'date' => $package['details']['proofOfDelivery']['timestamp']
]
];
$tracking[] = $packages;
}
return $tracking;
}
/**
* Get label information for shipment
*
* @param string $shipment Shipment id or token
*
* @return array
*
* @since 1.0.0
*/
public function label(string $shipment) : array
{
}
public function finalize(array $shipment = []) : bool
{
}
}

View File

@ -0,0 +1,658 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DHL
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DHL;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\AuthType;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Localization\ISO3166CharEnum;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment API.
*
* @package phpOMS\Api\Shipping\DHL
* @license OMS License 2.0
* @link https://jingga.app
* @see General: https://developer.dhl.com/
* @see Special: https://developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2#get-started-section/
* @see Tracking: https://developer.dhl.com/api-reference/shipment-tracking#get-started-section/
* @since 1.0.0
*/
final class DHLParcelDEShipping implements ShippingInterface
{
/**
* Api version
*
* @var string
* @since 1.0.0
*/
public const API_VERSION = 'v2';
/**
* API environment.
*
* @var string
* @since 1.0.0
*/
public static string $ENV = 'live';
/**
* API link to live/production version.
*
* @var string
* @since 1.0.0
*/
public const LIVE_URL = 'https://api-eu.dhl.com';
/**
* API link to test/sandbox version.
*
* @var string
* @since 1.0.0
*/
public const SANDBOX_URL = 'https://api-sandbox.dhl.com';
/**
* API link to test/sandbox version.
*
* This implementation uses different testing urls for the different endpoints
*
* @var string
* @since 1.0.0
*/
public const SANDBOX2_URL = 'https://api-test.dhl.com';
/**
* The type of authentication that is supported.
*
* @var int
* @since 1.0.0
*/
public const AUTH_TYPE = AuthType::AUTOMATIC_LOGIN | AuthType::KEY_LOGIN;
/**
* Minimum auth expiration time until re-auth.
*
* @var int
* @since 1.0.0
*/
public const TIME_DELTA = 10;
/**
* Client id
*
* @var string
* @since 1.0.0
*/
public string $client = '';
/**
* Login id
*
* @var string
* @since 1.0.0
*/
public string $login = '';
/**
* Password
*
* @var string
* @since 1.0.0
*/
public string $password = '';
/**
* Current auth token
*
* @var string
* @since 1.0.0
*/
public string $token = '';
/**
* Current auth refresh token in case the token expires
*
* @var string
* @since 1.0.0
*/
public string $refreshToken = '';
/**
* Api Key
*
* @var string
* @since 1.0.0
*/
public string $apiKey = '';
/**
* Token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $expire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $refreshExpire;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
}
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
string $client = null,
string $payload = null
) : int
{
$this->apiKey = $client ?? $this->client;
$this->login = $login;
$this->password = $password;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->header->set('dhl-api-key', $this->apiKey);
$this->expire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 500:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
$this->apiKey = $key;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Accept', 'application/json');
$request->header->set('dhl-api-key', $key);
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 500:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/orders';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('validate', 'true');
// @todo implement docFormat
$httpUri->addQuery('docFormat', 'PDF');
// @todo implement printFormat
// Available values : A4, 910-300-600, 910-300-610, 910-300-700, 910-300-700-oz, 910-300-710, 910-300-300, 910-300-300-oz, 910-300-400, 910-300-410, 100x70mm
// If not set, default specified in customer portal will be used
// @todo implement as class setting
//$request->setData('printFormat', '');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', 'application/json');
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->setData('STANDARD_GRUPPENPROFIL', 'PDF');
$shipments = [
[
'product' => 'V01PAK', // V53WPAK, V53WPAK
'billingNumber' => $data['costcenter'], // @todo maybe dhl number, check
'refNo' => $package['id'],
'shipper' => [
'name1' => $sender['name'],
'addressStreet' => $sender['address'],
'additionalAddressInformation1' => $sender['address_addition'],
'postalCode' => $sender['zip'],
'city' => $sender['city'],
'country' => ISO3166CharEnum::getBy2Code($sender['country_code']),
'email' => $sender['email'],
'phone' => $sender['phone'],
],
'consignee' => [
'name1' => $receiver['name'],
'addressStreet' => $receiver['address'],
'additionalAddressInformation1' => $receiver['address_addition'],
'postalCode' => $receiver['zip'],
'city' => $receiver['city'],
'country' => ISO3166CharEnum::getBy2Code($receiver['country_code']),
'email' => $receiver['email'],
'phone' => $receiver['phone'],
],
'details' => [
'dim' => [
'uom' => 'mm',
'height' => $package['height'],
'length' => $package['length'],
'width' => $package['width'],
],
'weight' => [
'uom' => 'g',
'value' => $package['weight'],
],
]
]
];
$request->setData('shipments', $shipments);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$result = $response->getDataArray('items') ?? [];
$labelUri = new HttpUri($result[0]['label']['url']);
$label = $this->label($labelUri->getQuery('token'));
return [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
],
'packages' => [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
]
]
];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/orders';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::DELETE);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->setData('profile', 'STANDARD_GRUPPENPROFIL');
$request->setData('shipment', $shipment);
$response = Rest::request($request);
return $response->header->status === 200;
}
/**
* Get shipment information (no tracking)
*
* This includes depending on service labels, shipping documents and general shipment information.
* For some services this function simply re-creates the data from ship().
*
* @param string $shipment Shipment id or token
*
* @return array
*
* @since 1.0.0
*/
public function info(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/orders';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('shipment', $shipment);
// @todo implement docFormat etc
$httpUri->addQuery('docFormat', 'PDF');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$result = $response->getDataArray('items') ?? [];
$labelUri = new HttpUri($result[0]['label']['url']);
$label = $this->label($labelUri->getQuery('token'));
return [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
],
'packages' => [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
]
]
];
}
/**
* Get label information for shipment
*
* @param string $shipment Shipment id or token
*
* @return array
*
* @since 1.0.0
*/
public function label(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/labels';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('token', $shipment);
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('Content-Type', 'application/pdf');
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
return [
'data' => $response->getData(),
];
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX2_URL;
$uri = $base . '/track/shipments';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('trackingnumber', $shipment);
$httpUri->addQuery('limit', 10);
// @todo implement: express, parcel-de, ecommerce, dgf, parcel-uk, post-de, sameday, freight, parcel-nl, parcel-pl, dsc, ecommerce-europe, svb
//$httpUri->addQuery('service', '');
// @odo: implement
//$httpUri->addQuery('requesterCountryCode', '');
//$httpUri->addQuery('originCountryCode', '');
//$httpUri->addQuery('recipientPostalCode', '');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('accept', 'application/json');
$request->header->set('dhl-api-key', $this->apiKey);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$shipments = $response->getDataArray('shipments') ?? [];
$tracking = [];
// @todo add general shipment status (not just for individual packages)
foreach ($shipments as $shipment) {
$packages = [];
$package = $shipment;
$activities = [];
foreach ($package['events'] as $activity) {
$activities[] = [
'date' => new \DateTime($activity['timestamp']),
'description' => $activity['description'],
'location' => [
'address' => [
$activity['location']['address']['streetAddress'],
$activity['location']['address']['addressLocality'],
],
'city' => '',
'country' => '',
'country_code' => $activity['location']['address']['countryCode'],
'zip' => $activity['location']['address']['postalCode'],
'state' => '',
],
'status' => [
'code' => $activity['statusCode'],
'statusCode' => $activity['statusCode'],
'description' => $activity['status'],
]
];
}
$packages[] = [
'status' => [
'code' => $package['status']['statusCode'],
'statusCode' => $package['status']['statusCode'],
'description' => $package['status']['status'],
],
'deliveryDate' => new \DateTime($package['estimatedTimeOfDelivery']),
'count' => $package['details']['totalNumberOfPieces'],
'weight' => $package['details']['weight']['weight'],
'weight_unit' => 'g',
'activities' => $activities,
'received' => [
'by' => $package['details']['proofOfDelivery']['familyName'],
'signature' => $package['details']['proofOfDelivery']['signatureUrl'],
'location' => '',
'date' => $package['details']['proofOfDelivery']['timestamp']
]
];
$tracking[] = $packages;
}
return $tracking;
}
/**
* Get daily manifest
*
* @param \DateTime $date Date of the manifest
*
* @return array
*
* @since 1.0.0
*/
public function getManifest(\DateTime $date = null) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/manifest';
$httpUri = new HttpUri($uri);
if ($date !== null) {
$httpUri->addQuery('date', $date->format('Y-m-d'));
}
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
return [
'date' => $response->getDataDateTime('manifestDate'),
'b64' => $response->getDataArray('manifest')['b64'],
'zpl2' => $response->getDataArray('manifest')['zpl2'],
'url' => $response->getDataArray('manifest')['url'],
'format' => $response->getDataArray('manifest')['printFormat'],
];
}
/**
* Finalize shipments.
*
* No further adjustments are possible.
*
* @param array $shipment Shipments to finalize. If empty = all
*
* @return bool
*
* @since 1.0.0
*/
public function finalize(array $shipment = []) : bool
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/manifest';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('all', empty($shipment) ? 'true' : 'false');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', 'application/json');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
if (!empty($shipment)) {
$request->setData('shipmentNumbers', $shipment);
}
$response = Rest::request($request);
return $response->header->status === 200;
}
}

View File

@ -0,0 +1,36 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DHL
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DHL;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\DHL
* @license OMS License 2.0
* @link https://jingga.app
* @see General: https://developer.dhl.com/
* @see Special: https://developer.dhl.com/api-reference/ecommerce-europe#get-started-section/
* @see Tracking: https://developer.dhl.com/api-reference/shipment-tracking#get-started-section/
* @since 1.0.0
*/
final class DHLParcelDEShipping implements ShippingInterface
{
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DPD
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DPD;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\DPD
* @license OMS License 2.0
* @link https://jingga.app
* @see https://api.dpd.ro/web-api.html#href-create-shipment-req
* @see https://www.dpd.com/de/en/entwickler-integration-in-ihre-versandloesung/
* @since 1.0.0
*/
final class DPDShipping implements ShippingInterface
{
}

View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\Fedex
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\Fedex;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\Fedex
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.fedex.com/api/en-us/home.html
* @since 1.0.0
*/
final class FedexShipping implements ShippingInterface
{
}

View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\RoyalMail
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\RoyalMail;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\RoyalMail
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.royalmail.net/
* @since 1.0.0
*/
final class RoyalMailShipping implements ShippingInterface
{
}

View File

@ -0,0 +1,59 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
/**
* Shipping factory.
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
final class ShippingFactory
{
/**
* Create shipping instance.
*
* @param int $type Shipping type
*
* @return ShippingInterface
*
* @throws \Exception This exception is thrown if the shipping type is not supported
*
* @since 1.0.0
*/
public static function create(int $type) : ShippingInterface
{
switch ($type) {
case ShippingType::DHL:
return new \phpOMS\Api\Shipping\DHL\DHLShipping();
case ShippingType::DPD:
return new \phpOMS\Api\Shipping\DPD\DPDShipping();
case ShippingType::FEDEX:
return new \phpOMS\Api\Shipping\Fedex\FedexShipping();
case ShippingType::ROYALMAIL:
return new \phpOMS\Api\Shipping\RoyalMail\RoyalMailShipping();
case ShippingType::TNT:
return new \phpOMS\Api\Shipping\TNT\TNTShipping();
case ShippingType::UPS:
return new \phpOMS\Api\Shipping\UPS\UPSShipping();
case ShippingType::USPS:
return new \phpOMS\Api\Shipping\Usps\UspsShipping();
default:
throw new \Exception('Unsupported shipping type.');
}
}
}

View File

@ -0,0 +1,172 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\HttpResponse;
/**
* Shipping interface.
*
* For authentication there are usually 3 options depending on the service
* 1. No user interaction: Store login+password in database or code and perform authentication via login+password and receive an access token
* 2. No user interaction: Store api key or secret token in database and perform authentication via key/secret and receive an access token
* 3. User interaction: Redirect to 3rd party login page. User performs manual login. 3rd party page redirects pack to own app after login incl. an access token
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @todo implement Sender, Receiver, Package, Transit, Tracking classes for better type hinting instead of arrays
*
* @property string $ENV ('live' = live environment, 'test' or 'sandbox' = test environment)
* @property string $client
* @property string $token
* @property string $refreshToken
* @property string $apiKey
* @property string $login
* @property string $password
* @property \DateTime $expire
* @property \DateTime $refreshExpire
*/
interface ShippingInterface
{
/**
* Create request for authentication using login and passowrd
*
* @param string $login Login name/email
* @param string $password Password
* @param string $client Client id
* @param array $payload Other payload data
*
* @return int Returns auth status
*
* @since 1.0.0
*/
public function authLogin(
string $login, string $password,
string $client = null,
string $payload = null
) : int;
/**
* Create request for manual (user has to use form on external website) authentication.
*
* Creates a request object that redirects to a login page where the user has to enter
* the login credentials. After login the external login page redirects back to the
* redirect url which will also have a parameter containing the authentication token.
*
* Use tokenFromRedirect() to parse the token from the redirect after successful login.
*
* @param string $client Client information (e.g. client id)
* @param null|string $redirect Redirect page after successfull login
* @param array $payload Other payload data
*
* @return HttpRequest Request which should be used to create the redirect (e.g. header("Location: $request->uri"))
*
* @see authLogin() for services that require login+password
* @see authApiKey() for services that require api key
*
* @since 1.0.0
*/
public function authRedirectLogin(
string $client,
string $redirect = null,
array $payload = []
) : HttpRequest;
/**
* Parses the redirect code after using authRedirectLogin() and creates a token from that code.
*
* @param string $login Login name/email
* @param string $password Password
* @param HttpRequest $redirect Redirect request after the user successfully logged in.
*
* @return int Returns auth status
*
* @see authRedirectLogin()
*
* @since 1.0.0
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int;
/**
* Connect to API
*
* @param string $key Api key/permanent token
*
* @return int Returns auth status
*
* @since 1.0.0
*/
public function authApiKey(string $key) : int;
/**
* Refreshes token using a refresh token
*
* @return int Returns auth status
*
* @since 1.0.0
*/
public function refreshToken() : int;
/**
* Create shipment.
*
* @param array $sender Sender
* @param array $shipFrom Ship from location (sometimes sender != pickup location)
* @param array $recevier Receiver
* @param array $package Package
* @param array $data Shipping data
*
* @return array
*
* @since 1.0.0
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array;
/**
* Cancel shipment.
*
* @param string $shipment Shipment id
* @param string[] $packages Packed ids (if a shipment consists of multiple packages)
*
* @return bool
*
* @since 1.0.0
*/
public function cancel(string $shipment, array $packages = []) : bool;
/**
* Track shipment.
*
* @param string $shipment Shipment id
*
* @return array
*
* @since 1.0.0
*/
public function track(string $shipment) : array;
}

View File

@ -0,0 +1,42 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Stdlib\Base\Enum;
/**
* Shipping Type
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class ShippingType extends Enum
{
public const DHL = 1;
public const DPD = 2;
public const FEDEX = 3;
public const ROYALMAIL = 4;
public const TNT = 5;
public const UPS = 6;
public const USPS = 7;
}

View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\TNT
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\TNT;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\TNT
* @license OMS License 2.0
* @link https://jingga.app
* @see https://express.tnt.com/expresswebservices-website/app/landing.html
* @since 1.0.0
*/
final class TNTShipping implements ShippingInterface
{
}

View File

@ -0,0 +1,783 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\UPS
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\UPS;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\AuthType;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment API.
*
* @package phpOMS\Api\Shipping\UPS
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.ups.com/api/reference/oauth/authorization-code?loc=en_US
* @see https://developer.ups.com/api/reference?loc=en_US
* @since 1.0.0
*/
final class UPSShipping implements ShippingInterface
{
/**
* Api version
*
* @var string
* @since 1.0.0
*/
public const API_VERSION = 'v1';
/**
* API environment.
*
* @var string
* @since 1.0.0
*/
public static string $ENV = 'live';
/**
* API link to live/production version.
*
* @var string
* @since 1.0.0
*/
public const LIVE_URL = 'https://onlinetools.ups.com';
/**
* API link to test/sandbox version.
*
* @var string
* @since 1.0.0
*/
public const SANDBOX_URL = 'https://wwwcie.ups.com';
/**
* The type of authentication that is supported.
*
* @var int
* @since 1.0.0
*/
public const AUTH_TYPE = AuthType::AUTOMATIC_LOGIN | AuthType::MANUAL_LOGIN;
/**
* Minimum auth expiration time until re-auth.
*
* @var int
* @since 1.0.0
*/
public const TIME_DELTA = 10;
/**
* Client id
*
* @var string
* @since 1.0.0
*/
public string $client = '';
/**
* Login id
*
* @var string
* @since 1.0.0
*/
public string $login = '';
/**
* Password
*
* @var string
* @since 1.0.0
*/
public string $password = '';
/**
* Current auth token
*
* @var string
* @since 1.0.0
*/
public string $token = '';
/**
* Current auth refresh token in case the token expires
*
* @var string
* @since 1.0.0
*/
public string $refreshToken = '';
/**
* Api Key
*
* @var string
* @since 1.0.0
*/
public string $apiKey = '';
/**
* Token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $expire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $refreshExpire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public function __construct()
{
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
}
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
string $client = null,
string $payload = null
) : int
{
$this->client = $client ?? $this->client;
$this->login = $login;
$this->password = $password;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/token';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->setData('grant_type', 'client_credentials');
$request->header->set('Content-Type', 'application/x-www-form-urlencoded');
$request->header->set('x-merchant-id', $client);
$request->header->set('Authorization', 'Basic ' . \base64_encode($login . ':' . $password));
$this->expire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getData('access_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
string $redirect = null,
array $payload = []
) : HttpRequest
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/authorize';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->setData('client_id', $client);
$request->setData('redirect_uri', $redirect);
$request->setData('response_type', 'code');
if (isset($payload['id'])) {
$request->setData('scope', $payload['id']);
}
return $request;
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
$code = $redirect->getData('code') ?? '';
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/token';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
// @remark: One api documentation part says redirect_uri is required another says it's not required
// Personally I don't see why a redirect is required or even helpful. Will try without it!
$request->setData('grant_type', 'authorization_code');
$request->setData('code', $code);
$request->header->set('Content-Type', 'application/x-www-form-urlencoded');
$request->header->set('Authorization', 'Basic ' . \base64_encode($login . ':' . $password));
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getData('access_token') ?? '';
$this->refreshToken = $response->getData('refresh_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$this->refreshExpire->setTimestamp($this->refreshExpire->getTimestamp() + ((int) $response->getData('refresh_token_expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
$now = new \DateTime('now');
if ($this->refreshExpire->getTimestamp() < $now->getTimestamp() - self::TIME_DELTA) {
return AuthStatus::FAILED;
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/refresh';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', 'application/x-www-form-urlencoded');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->setData('grant_type', 'refresh_token');
$request->setData('refresh_token', $this->refreshToken);
$this->expire = clone $now;
$this->refreshExpire = clone $now;
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getData('access_token') ?? '';
$this->refreshToken = $response->getData('refresh_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$this->refreshExpire->setTimestamp($this->refreshExpire->getTimestamp() + ((int) $response->getData('refresh_token_expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function timeInTransit(array $shipFrom, array $receiver, array $package, \DateTime $shipDate) : array
{
if (!$this->validateOrReconnectAuth()) {
return [];
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/shipments/' . self::API_VERSION . '/transittimes';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', 'application/json');
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
$request->setData('originCountryCode', $shipFrom['country_code']);
$request->setData('originStateProvince', \substr($shipFrom['state'], 0, 50));
$request->setData('originCityName', \substr($shipFrom['city'], 0, 50));
$request->setData('originPostalCode', \substr($shipFrom['zip'], 0, 10));
$request->setData('destinationCountryCode', $receiver['country_code']);
$request->setData('destinationStateProvince', \substr($receiver['state'], 0, 50));
$request->setData('destinationCityName', \substr($receiver['city'], 0, 50));
$request->setData('destinationPostalCode', \substr($receiver['zip'], 0, 10));
$request->setData('avvFlag', true);
$request->setData('billType', $package['type']);
$request->setData('weight', $package['weight']);
$request->setData('weightUnitOfMeasure', $package['weight_unit']); // LBS or KGS
$request->setData('shipmentContentsValue', $package['value']);
$request->setData('shipmentContentsCurrencyCode', $package['currency']); // 3 char ISO code
$request->setData('numberOfPackages', $package['count']);
$request->setData('shipDate', $shipDate->format('Y-m-d'));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$services = $response->getDataArray('services');
$transits = [];
foreach ($services as $service) {
$transits[] = [
'serviceLevel' => $service['serviceLevel'],
'deliveryDate' => new \DateTime($service['deliveryDaye']),
'deliveryDateFrom' => null,
'deliveryDateTo' => null,
];
}
return $transits;
}
/**
* {@inheritdoc}
*/
public function ship(array $sender, array $shipFrom, array $receiver, array $package, array $data) : array
{
if (!$this->validateOrReconnectAuth()) {
return [];
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/shipments/' . self::API_VERSION . '/ship';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
// @todo dangerous goods
// @todo implement printing standard (pdf-zpl/format and size)
$body = [
'Request' => [
'RequestOption' => 'validate',
'SubVersion' => '2205',
],
'Shipment' => [
'Description' => $package['description'],
'DocumentsOnlyIndicator' => '0',
'Shipper' => [
'Name' => \substr($sender['name'], 0, 35),
'AttentionName' => \substr($sender['fao'], 0, 35),
'CompanyDisplayableName' => \substr($sender['name'], 0, 35),
'TaxIdentificationNumber' => \substr($sender['taxid'], 0, 15),
'Phone' => [
'Number' => \substr($sender['phone'], 0, 15),
],
'ShipperNumber' => $sender['number'],
'EMailAddress' => \substr($sender['email'], 0, 50),
'Address' => [
'AddressLine' => \substr($sender['address'], 0, 35),
'City' => \substr($sender['city'], 0, 30),
'StateProvinceCode' => \substr($sender['state'], 0, 5),
'PostalCode' => \substr($sender['zip'], 0, 9),
'CountryCode' => $sender['country_code'],
],
],
'ShipTo' => [
'Name' => \substr($receiver['name'], 0, 35),
'AttentionName' => \substr($receiver['fao'], 0, 35),
'CompanyDisplayableName' => \substr($receiver['name'], 0, 35),
'TaxIdentificationNumber' => \substr($receiver['taxid'], 0, 15),
'Phone' => [
'Number' => \substr($receiver['phone'], 0, 15),
],
'ShipperNumber' => $receiver['number'],
'EMailAddress' => \substr($receiver['email'], 0, 50),
'Address' => [
'AddressLine' => \substr($receiver['address'], 0, 35),
'City' => \substr($receiver['city'], 0, 30),
'StateProvinceCode' => \substr($receiver['state'], 0, 5),
'PostalCode' => \substr($receiver['zip'], 0, 9),
'CountryCode' => $receiver['country_code'],
],
],
/* @todo only allowed for US -> US and PR -> PR shipments?
'ReferenceNumber' => [
'BarCodeIndicator' => '1',
'Code' => '',
'Value' => '',
],
*/
'Service' => [
'Code' => $data['service_code'],
'Description' => \substr($data['service_description'], 0, 35),
],
'InvoiceLineTotal' => [
'CurrencyCode' => $package['currency'],
'MonetaryValue' => $package['value'],
],
'NumOfPiecesInShipment' => $package['count'],
'CostCenter' => \substr($package['costcenter'], 0, 30),
'PackageID' => \substr($package['id'], 0, 30),
'PackageIDBarcodeIndicator' => '1',
'Package' => []
],
'LabelSpecification' => [
'LabelImageFormat' => [
'Code' => $data['label_code'],
'Description' => \substr($data['label_description'], 0, 35),
],
'LabelStockSize' => [
'Height' => $data['label_height'],
'Width' => $data['label_width'],
]
],
'ReceiptSpecification' => [
'ImageFormat' => [
'Code' => $data['receipt_code'],
'Description' => \substr($data['receipt_description'], 0, 35),
]
],
];
$packages = [];
foreach ($package['packages'] as $p) {
$packages[] = [
'Description' => \substr($p['description'], 0, 35),
'Packaging' => [
'Code' => $p['package_code'],
'Description' => $p['package_description']
],
'Dimensions' => [
'UnitOfMeasurement' => [
'Code' => $p['package_dim_unit'], // IN or CM or 00 or 01
'Description' => \substr($p['package_dim_unit_description'], 0, 35),
],
'Length' => $p['length'],
'Width' => $p['width'],
'Height' => $p['height'],
],
'DimWeight' => [
'UnitOfMeasurement' => [
'Code' => $p['package_dimweight_unit'],
'Description' => \substr($p['package_dimweight_unit_description'], 0, 35),
],
'Weight' => $p['weight'],
],
'PackageWeight' => [
'UnitOfMeasurement' => [
'Code' => $p['package_weight_unit'],
'Description' => \substr($p['package_weight_unit_description'], 0, 35),
],
'Weight' => $p['weight'],
]
];
}
$body['Shipment']['Package'] = $packages;
// Only required if shipper != shipFrom (e.g. pickup location != shipper)
if (!empty($shipFrom)) {
$body['Shipment']['ShipFrom'] = [
'Name' => \substr($shipFrom['name'], 0, 35),
'AttentionName' => \substr($shipFrom['fao'], 0, 35),
'CompanyDisplayableName' => \substr($shipFrom['name'], 0, 35),
'TaxIdentificationNumber' => \substr($shipFrom['taxid'], 0, 15),
'Phone' => [
'Number' => \substr($shipFrom['phone'], 0, 15),
],
'ShipperNumber' => $shipFrom['number'],
'EMailAddress' => \substr($shipFrom['email'], 0, 50),
'Address' => [
'AddressLine' => \substr($shipFrom['address'], 0, 35),
'City' => \substr($shipFrom['city'], 0, 30),
'StateProvinceCode' => \substr($shipFrom['state'], 0, 5),
'PostalCode' => \substr($shipFrom['zip'], 0, 9),
'CountryCode' => $shipFrom['country_code'],
],
];
}
$request->setData('ShipmentRequest', $body);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$result = $response->getDataArray('ShipmentResponse') ?? [];
$shipment = [
'id' => $result['ShipmentResults']['ShipmentIdentificationNumber'] ?? '',
'costs' => [
'service' => $result['ShipmentResults']['ShipmentCharges']['BaseServiceCharge']['MonetaryValue'] ?? null,
'transportation' => $result['ShipmentResults']['ShipmentCharges']['TransportationCharges']['MonetaryValue'] ?? null,
'options' => $result['ShipmentResults']['ShipmentCharges']['ServiceOptionsCharges']['MonetaryValue'] ?? null,
'subtotal' => $result['ShipmentResults']['ShipmentCharges']['TotalCharges']['MonetaryValue'] ?? null,
'taxes' => $result['ShipmentResults']['ShipmentCharges']['TaxCharges']['MonetaryValue'] ?? null,
'taxes_type' => $result['ShipmentResults']['ShipmentCharges']['TaxCharges']['Type'] ?? null,
'total' => $result['ShipmentResults']['ShipmentCharges']['TotalChargesWithTaxes']['MonetaryValue'] ?? null,
'currency' => $result['ShipmentResults']['ShipmentCharges']['TotalCharges']['CurrencyCode'] ?? null,
],
'packages' => [],
'label' => [
'code' => '',
'url' => $result['ShipmentResults']['LabelURL'] ?? '',
'barcode' => $result['ShipmentResults']['BarCodeImage'] ?? '',
'local' => $result['ShipmentResults']['LocalLanguageLabelURL'] ?? '',
'data' => '',
],
'receipt' => [
'code' => '',
'url' => $result['ShipmentResults']['ReceiptURL'] ?? '',
'local' => $result['ShipmentResults']['LocalLanguageReceiptURL'] ?? '',
'data' => '',
]
// @todo dangerous goods paper image
];
$packages = [];
foreach ($result['ShipmentResults']['Packages'] as $package) {
$packages[] = [
'id' => $package['TrackingNumber'],
'label' => [
'code' => $package['ShippingLabel']['ImageFormat']['Code'],
'url' => '',
'barcode' => $package['PDF417'],
'image' => $package['ShippingLabel']['GraphicImage'],
'browser' => $package['HTMLImage'],
'data' => '',
],
'receipt' => [
'code' => $package['ShippingReceipt']['ImageFormat']['Code'],
'image' => $package['ShippingReceipt']['ImageFormat']['GraphicImage'],
]
];
}
$shipment['packages'] = $packages;
return $shipment;
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
if (!$this->validateOrReconnectAuth()) {
return false;
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/shipments/' . self::API_VERSION . '/void/cancel/' . $shipment;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::DELETE);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
$request->setData('trackingnumber', empty($shipment) ? $shipment : \implode(',', $packages));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return false;
}
return ($response->getData('VoidShipmentResponse')['Response']['ResponseStatus']['Code'] ?? '0') === '1';
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
if (!$this->validateOrReconnectAuth()) {
return [];
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/track/v1/details/' . $shipment;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
$request->setData('locale', 'en_US');
$request->setData('returnSignature', 'false');
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$shipments = $response->getDataArray('trackResponse') ?? [];
$shipments = $shipments['shipment'] ?? [];
$tracking = [];
// @todo add general shipment status (not just for individual packages)
foreach ($shipments as $shipment) {
$packages = [];
foreach ($shipment['package'] as $package) {
$activities = [];
foreach ($package['activity'] as $activity) {
$activities[] = [
'date' => new \DateTime($activity['date'] . ' ' . $activity['time']),
'description' => '',
'location' => [
'address' => [
$activity['location']['address']['addressLine1'],
$activity['location']['address']['addressLine2'],
$activity['location']['address']['addressLine3'],
],
'city' => $activity['location']['address']['city'],
'country' => $activity['location']['address']['country'],
'country_code' => $activity['location']['address']['country_code'],
'zip' => $activity['location']['address']['postalCode'],
'state' => $activity['location']['address']['stateProvice'],
],
'status' => [
'code' => $activity['status']['code'],
'statusCode' => $activity['status']['statusCode'],
'description' => $activity['status']['description'],
]
];
}
$packages[] = [
'status' => [
'code' => $package['status']['code'],
'statusCode' => $package['status']['statusCode'],
'description' => $package['status']['description'],
],
'deliveryDate' => new \DateTime($package['deliveryDate'] . ' ' . $package['deliveryTime']['endTime']),
'count' => $package['packageCount'],
'weight' => $package['weight']['weight'],
'weight_unit' => $package['weight']['unitOfMeasurement'],
'activities' => $activities,
'received' => [
'by' => $package['deliveryInformation']['receivedBy'],
'signature' => $package['deliveryInformation']['signature'],
'location' => $package['deliveryInformation']['location'],
'date' => '',
]
];
}
$tracking[] = $packages;
}
return $tracking;
}
/**
* Validates the current authentication and tries to reconnect if the connection timed out
*
* @return bool
*
* @since 1.0.0
*/
private function validateOrReconnectAuth() : bool
{
$status = AuthStatus::OK;
$now = new \DateTime('now');
if ($this->expire->getTimestamp() < $now->getTimestamp() - self::TIME_DELTA) {
$status = AuthStatus::FAILED;
if ($this->refreshToken !== '') {
$status = $this->refreshToken();
} elseif ($this->login !== '' && $this->password !== '') {
$status = $this->authLogin($this->login, $this->password, $this->client);
}
}
return $status === AuthStatus::OK
&& $this->expire->getTimestamp() > $now->getTimestamp() - self::TIME_DELTA;
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\Usps
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\Usps;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\Uri\HttpUri;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\Usps
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.usps.com/apis
* @since 1.0.0
*/
final class UspsShipping implements ShippingInterface
{
}

View File

@ -198,6 +198,9 @@ abstract class GrammarAbstract
if (\is_string($element)) {
$expression .= $this->compileSystem($element)
. (\is_string($key) ? ' as ' . $key : '') . ', ';
} elseif (\is_int($element)) {
// example: select 1
$expression .= $element . ', ';
} elseif ($element instanceof \Closure) {
$expression .= $element() . (\is_string($key) ? ' as ' . $key : '') . ', ';
} elseif ($element instanceof BuilderAbstract) {

View File

@ -569,7 +569,7 @@ class DataMapperFactory
* @param int $pageLimit Limit result set
* @param string $sortBy Model member name to sort by
* @param string $sortOrder Sort order
* @param array $searchFields Fields to search in. ([] = all) @todo: maybe change to all which have autocomplete = true defined?
* @param array $searchFields Fields to search in. ([] = all) @todo maybe change to all which have autocomplete = true defined?
* @param array $filters Additional search filters applied ['type', 'value1', 'logic1', 'value2', 'logic2']
*
* @return array{hasPrevious:bool, hasNext:bool, data:object[]}
@ -636,7 +636,7 @@ class DataMapperFactory
}
}
// @todo: how to handle columns which are NOT members (columns which are manipulated)
// @todo how to handle columns which are NOT members (columns which are manipulated)
// Maybe pass callback array which can handle these cases?
if ($type === 'p') {

View File

@ -182,7 +182,6 @@ final class ReadMapper extends DataMapperAbstract
* @return self
*
* @since 1.0.0
* @todo: consider to accept properties instead and then check ::COLUMNS which contian the property and ADD that array into $this->columns. Maybe also consider a rename from columns() to property()
*/
public function columns(array $columns) : self
{
@ -191,6 +190,24 @@ final class ReadMapper extends DataMapperAbstract
return $this;
}
/**
* Define the properties to load
*
* @param array $properties Properties to load
*
* @return self
*
* @since 1.0.0
*/
public function properties(array $properties) : self
{
foreach ($properties as $property) {
$this->columns[] = $this->mapper::getColumnByMember($property);
}
return $this;
}
/**
* Execute mapper
*
@ -270,7 +287,7 @@ final class ReadMapper extends DataMapperAbstract
$ids[] = $value;
// @todo: This is too slow, since it creates a query for every $row x relation type.
// @todo This is too slow, since it creates a query for every $row x relation type.
// Pulling it out would be nice.
// The problem with solving this is that in a many-to-many relationship a relation table is used
// BUT the relation data is not available in the object itself meaning after retrieving the object
@ -332,18 +349,15 @@ final class ReadMapper extends DataMapperAbstract
public function executeGetRaw(Builder $query = null) : array
{
$query ??= $this->getQuery();
$results = false;
try {
$results = false;
$sth = $this->db->con->prepare($query->toSql());
if ($sth !== false) {
$sth->execute();
$results = $sth->fetchAll(\PDO::FETCH_ASSOC);
}
} catch (\Throwable $t) {
$results = false;
\phpOMS\Log\FileLogger::getInstance()->error(
\phpOMS\Log\FileLogger::MSG_FULL, [
'message' => $t->getMessage() . ':' . $query->toSql(),
@ -556,9 +570,9 @@ final class ReadMapper extends DataMapperAbstract
}
/* variable in model */
// @todo: join handling is extremely ugly, needs to be refactored
// @todo join handling is extremely ugly, needs to be refactored
foreach ($values as $join) {
// @todo: the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails
// @todo the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails
if ($join['child'] !== '') {
continue;
}
@ -630,7 +644,7 @@ final class ReadMapper extends DataMapperAbstract
/* variable in model */
$previous = null;
foreach ($values as $where) {
// @todo: the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails
// @todo the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails
if ($where['child'] !== '') {
continue;
}
@ -659,7 +673,7 @@ final class ReadMapper extends DataMapperAbstract
$where1->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, $comparison, $where['value'], 'and');
$where2 = new Builder($this->db);
$where2->select('1') // @todo: why is this in quotes?
$where2->select(1)
->from($this->mapper::TABLE . '_d' . $this->depth)
->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, 'in', $alt);
@ -703,7 +717,7 @@ final class ReadMapper extends DataMapperAbstract
} elseif (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column'])) {
// get HasManyQuery (but only for elements which have a 'column' defined)
// @todo: handle self and self === null
// @todo handle self and self === null
$query->leftJoin($rel['mapper']::TABLE, $rel['mapper']::TABLE . '_d' . ($this->depth + 1))
->on(
$this->mapper::TABLE . '_d' . $this->depth . '.' . ($rel['external'] ?? $this->mapper::PRIMARYFIELD), '=',
@ -832,7 +846,7 @@ final class ReadMapper extends DataMapperAbstract
}
if (empty($value)) {
// @todo: find better solution. this was because of a bug with the sales billing list query depth = 4. The address was set (from the client, referral or creator) but then somehow there was a second address element which was all null and null cannot be asigned to a string variable (e.g. country). The problem with this solution is that if the model expects an initialization (e.g. at lest set the elements to null, '', 0 etc.) this is now not done.
// @todo find better solution. this was because of a bug with the sales billing list query depth = 4. The address was set (from the client, referral or creator) but then somehow there was a second address element which was all null and null cannot be asigned to a string variable (e.g. country). The problem with this solution is that if the model expects an initialization (e.g. at lest set the elements to null, '', 0 etc.) this is now not done.
$value = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
}
} elseif (isset($this->mapper::BELONGS_TO[$def['internal']])) {
@ -896,7 +910,7 @@ final class ReadMapper extends DataMapperAbstract
}
}
// @todo: How is this allowed? at the bottom we set $obj->hasMany = value. A has many should be always an array?!
// @todo How is this allowed? at the bottom we set $obj->hasMany = value. A has many should be always an array?!
foreach ($this->mapper::HAS_MANY as $member => $def) {
$column = $def['mapper']::getColumnByMember($def['column'] ?? $member);
$alias = $column . '_d' . ($this->depth + 1);
@ -992,8 +1006,8 @@ final class ReadMapper extends DataMapperAbstract
*
* @return mixed
*
* @todo: in the future we could pass not only the $id ref but all of the data as a join!!! and save an additional select!!!
* @todo: parent and child elements however must be loaded because they are not loaded
* @todo in the future we could pass not only the $id ref but all of the data as a join!!! and save an additional select!!!
* @todo parent and child elements however must be loaded because they are not loaded
*
* @since 1.0.0
*/
@ -1036,8 +1050,8 @@ final class ReadMapper extends DataMapperAbstract
*
* @return mixed
*
* @todo: in the future we could pass not only the $id ref but all of the data as a join!!! and save an additional select!!!
* @todo: only the belongs to model gets populated the children of the belongsto model are always null models. either this function needs to call the get for the children, it should call get for the belongs to right away like the has many, or i find a way to recursevily load the data for all sub models and then populate that somehow recursively, probably too complex.
* @todo in the future we could pass not only the $id ref but all of the data as a join!!! and save an additional select!!!
* @todo only the belongs to model gets populated the children of the belongsto model are always null models. either this function needs to call the get for the children, it should call get for the belongs to right away like the has many, or i find a way to recursevily load the data for all sub models and then populate that somehow recursively, probably too complex.
*
* @since 1.0.0
*/
@ -1110,7 +1124,7 @@ final class ReadMapper extends DataMapperAbstract
$refClass = null;
// @todo: check if there are more cases where the relation is already loaded with joins etc.
// @todo check if there are more cases where the relation is already loaded with joins etc.
// there can be pseudo has many elements like localizations. They are has manies but these are already loaded with joins!
foreach ($this->with as $member => $withData) {
if (isset($this->mapper::HAS_MANY[$member])) {
@ -1122,7 +1136,7 @@ final class ReadMapper extends DataMapperAbstract
$isPrivate = $withData['private'] ?? false;
$objectMapper = $this->createRelationMapper($many['mapper']::get(db: $this->db), $member);
if ($many['external'] === null/* same as $many['table'] !== $many['mapper']::TABLE */) {
if ($many['external'] === null) {
$objectMapper->where($many['mapper']::COLUMNS[$many['self']]['internal'], $primaryKey);
} else {
$query = new Builder($this->db, true);
@ -1208,7 +1222,7 @@ final class ReadMapper extends DataMapperAbstract
$refClass = null;
// @todo: check if there are more cases where the relation is already loaded with joins etc.
// @todo check if there are more cases where the relation is already loaded with joins etc.
// there can be pseudo has many elements like localizations. They are has manies but these are already loaded with joins!
foreach ($this->with as $member => $withData) {
if (isset($this->mapper::HAS_MANY[$member])) {
@ -1217,7 +1231,7 @@ final class ReadMapper extends DataMapperAbstract
continue;
}
// @todo: withData doesn't store this directly, it is in [0]['private] ?!?!
// @todo withData doesn't store this directly, it is in [0]['private] ?!?!
$isPrivate = $withData['private'] ?? false;
$objectMapper = $this->createRelationMapper($many['mapper']::exists(db: $this->db), $member);

View File

@ -161,9 +161,6 @@ final class UpdateMapper extends DataMapperAbstract
}
}
// @todo:
// @bug: Sqlite doesn't allow table_name.column_name in set queries for whatver reason.
$sth = $this->db->con->prepare($query->toSql());
if ($sth !== false) {
$sth->execute();
@ -346,7 +343,7 @@ final class UpdateMapper extends DataMapperAbstract
$query = new Builder($this->db);
$src = $many['external'] ?? $many['mapper']::PRIMARYFIELD;
// @todo: what if a specific column name is defined instead of primaryField for the join? Fix, it should be stored in 'column'
// @todo what if a specific column name is defined instead of primaryField for the join? Fix, it should be stored in 'column'
$query->select($many['table'] . '.' . $src)
->from($many['table'])
->where($many['table'] . '.' . $many['self'], '=', $objId);

View File

@ -245,7 +245,7 @@ final class WriteMapper extends DataMapperAbstract
$mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper'];
$primaryKey = $mapper::getObjectId($obj);
// @todo: the $mapper::create() might cause a problem if 'by' is set. because we don't want to create this obj but the child obj.
// @todo the $mapper::create() might cause a problem if 'by' is set. because we don't want to create this obj but the child obj.
return empty($primaryKey) ? $mapper::create(db: $this->db)->execute($obj) : $primaryKey;
}
@ -290,7 +290,7 @@ final class WriteMapper extends DataMapperAbstract
? $mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['internal']
: 'ERROR';
// @todo: this or $isRelPrivate is wrong, don't know which one.
// @todo this or $isRelPrivate is wrong, don't know which one.
$isInternalPrivate =$mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['private'] ?? false;
if (\is_object($values)) {
@ -307,7 +307,7 @@ final class WriteMapper extends DataMapperAbstract
$mapper::create(db: $this->db)->execute($values);
continue;
} elseif (!\is_array($values)) {
// @todo: conditionals???
// @todo conditionals???
continue;
}

View File

@ -458,7 +458,7 @@ class Grammar extends GrammarAbstract
$expression .= '(' . \rtrim($this->compileWhereQuery($element['column']), ';') . ')';
}
// @todo: on doesn't allow values as value (only table column names). This is bad and needs to be fixed!
// @todo on doesn't allow values as value (only table column names). This is bad and needs to be fixed!
if (isset($element['value'])) {
$expression .= ' ' . \strtoupper($element['operator']) . ' ' . $this->compileSystem($element['value']);
} else {

View File

@ -40,7 +40,7 @@ class BaseStringL11n implements \JsonSerializable
*/
public string $name = '';
// @todo: this feels like $name and $type accomplish the same thing
// @todo this feels like $name and $type accomplish the same thing
// maybe we can always use $type and remove $name.
// This would require some smart mapper adjustment where the name is part of the l11n model,
// maybe use the path definition in the mapper which is used by arrays (e.g. type/name)

View File

@ -69,7 +69,7 @@ final class GrahamScan
/** @var array<int, array{x:int|float, y:int|float}> $subpoints */
$subpoints = \array_slice($points, 2, $n);
\usort($subpoints, function (array $a, array $b) use ($c) : int {
// @todo: Might be wrong order of comparison
// @todo Might be wrong order of comparison
return \atan2($a['y'] - $c['y'], $a['x'] - $c['x']) <=> \atan2($b['y'] - $c['y'], $b['x'] - $c['x']);
});

View File

@ -370,8 +370,8 @@ final class Simplex
$this->b = $b;
$this->c = $c;
// @todo: createSlackForm() required?
// @todo: create minimize
// @todo createSlackForm() required?
// @todo create minimize
$this->m = \count($A);
$this->n = \count(\reset($A));

View File

@ -76,7 +76,7 @@ final class Illinois
return $c;
}
// @todo: c might be wrong, could be that if and else must be switched
// @todo c might be wrong, could be that if and else must be switched
// @see https://en.wikipedia.org/wiki/Regula_falsi#The_Illinois_algorithm
if ($y * $fa < 0) {
$c = $sign === (int) ($y >= 0)

View File

@ -141,7 +141,7 @@ final class HttpResponse extends ResponseAbstract implements RenderableInterface
{
$render = '';
foreach ($this->data as $response) {
// @note: Api functions return void -> null, this is where the null value is "ignored"/rendered as ''
// @note Api functions return void -> null, this is where the null value is "ignored"/rendered as ''
$render .= StringUtils::stringify($response);
}

View File

@ -78,6 +78,8 @@ final class Rest
break;
}
// @todo how to implement GET request with $request->data (should it alter the uri or still get put into the body?)
// handle none-get
if ($request->getMethod() !== RequestMethod::GET && !empty($request->data)) {
// handle different content types
@ -93,7 +95,7 @@ final class Rest
/* @phpstan-ignore-next-line */
$data = self::createMultipartData($boundary, $request->data);
// @todo: Replace boundary/ with the correct boundary= in the future.
// @todo Replace boundary/ with the correct boundary= in the future.
// Currently this cannot be done due to a bug. If we do it now the server cannot correclty populate php://input
$headers['Content-Type'] = 'Content-Type: multipart/form-data; boundary/' . $boundary;
$headers['content-length'] = 'Content-Length: ' . \strlen($data);
@ -113,7 +115,7 @@ final class Rest
$response = new HttpResponse();
\curl_setopt($curl, \CURLOPT_HEADERFUNCTION,
function($curl, $header) use ($response, &$cHeaderString) {
function($_, $header) use ($response, &$cHeaderString) {
$cHeaderString .= $header;
$length = \strlen($header);

View File

@ -108,7 +108,7 @@ abstract class ModuleAbstract
/**
* Auditor for logging.
*
* @var null|ModuleAbstract
* @var null|\Modules\Auditor\Controller\ApiController
* @since 1.0.0
*/
public static ?ModuleAbstract $auditor = null;
@ -886,6 +886,33 @@ abstract class ModuleAbstract
$this->app->eventManager->triggerSimilar('POST:Module:' . $trigger, '', $data);
}
/**
* Soft delete a model (only marks model as deleted)
*
* 1. Execute pre DB interaction event
* 2. Set model status to deleted in DB
* 3. Execute post DB interaction event (e.g. generates an audit log)
*
* @param int $account Account id
* @param mixed $obj Response object
* @param string | \Closure $mapper Object mapper
* @param string $trigger Trigger for the event manager
* @param string $ip Ip
*
* @return void
*
* @feature Implement softDelete functionality.
* Models which have a soft delete cannot be used, read or modified unless a person has soft delete permissions
* In addition to DELETE permisstions we now need SOFTDELETE as well.
* There also needs to be an undo function for this soft delete
* In a backend environment a soft delete would be very helpful!!!
*
* @since 1.0.0
*/
protected function softDeleteModel(int $account, mixed $obj, string | \Closure $mapper, string $trigger, string $ip) : void
{
}
/**
* Create a model relation
*

View File

@ -22,15 +22,23 @@ namespace phpOMS\Stdlib\Base;
* @link https://jingga.app
* @since 1.0.0
*/
class Address implements \JsonSerializable
class Address extends Location
{
/**
* Model id.
*
* @var int
* @since 1.0.0
*/
public int $id = 0;
/**
* Name of the receiver.
*
* @var string
* @since 1.0.0
*/
public string $recipient = '';
public string $name = '';
/**
* Sub of the address.
@ -40,24 +48,6 @@ class Address implements \JsonSerializable
*/
public string $fao = '';
/**
* Location.
*
* @var Location
* @since 1.0.0
*/
public Location $location;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->location = new Location();
}
/**
* {@inheritdoc}
*/
@ -71,10 +61,12 @@ class Address implements \JsonSerializable
*/
public function toArray() : array
{
return [
'recipient' => $this->recipient,
'fao' => $this->fao,
'location' => $this->location->toArray(),
];
return \array_merge (
[
'name' => $this->name,
'fao' => $this->fao,
],
parent::toArray()
);
}
}

View File

@ -67,6 +67,16 @@ class Location implements \JsonSerializable, SerializableInterface
*/
public string $address = '';
/**
* Address addition.
*
* e.g. 2nd floor
*
* @var string
* @since 1.0.0
*/
public string $addressAddition = '';
/**
* Address type
*

View File

@ -0,0 +1,27 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Stdlib\Base
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Stdlib\Base;
/**
* Address class.
*
* @package phpOMS\Stdlib\Base
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
final class NullAddress extends Address
{
}

View File

@ -48,7 +48,7 @@ class Directory extends FileAbstract implements DirectoryInterface
* @var array<string, ContainerInterface>
* @since 1.0.0
*/
private array $nodes = [];
public array $nodes = [];
/**
* Create ftp connection.
@ -123,7 +123,11 @@ class Directory extends FileAbstract implements DirectoryInterface
$uri = clone $this->uri;
$uri->setPath($filename);
$file = \ftp_size($this->con, $filename) === -1 ? new self($uri, false, $this->con) : new File($uri, $this->con);
$file = \ftp_size($this->con, $filename) === -1
? new self($uri, false, $this->con)
: new File($uri, $this->con);
$file->parent = $this;
$this->addNode($file);
}
@ -693,7 +697,7 @@ class Directory extends FileAbstract implements DirectoryInterface
$uri = clone $this->uri;
$uri->setPath(self::parent($this->path));
return new self($uri, true, $this->con);
return $this->parent ?? new self($uri, true, $this->con);
}
/**
@ -705,7 +709,19 @@ class Directory extends FileAbstract implements DirectoryInterface
return false;
}
return self::copy($this->con, $this->path, $to, $overwrite);
$newParent = $this->findNode($to);
$state = self::copy($this->con, $this->path, $to, $overwrite);
/** @var null|Directory $newParent */
if ($newParent !== null) {
$uri = clone $this->uri;
$uri->setPath($to);
$newParent->addNode(new self($uri));
}
return $state;
}
/**
@ -717,7 +733,10 @@ class Directory extends FileAbstract implements DirectoryInterface
return false;
}
return self::move($this->con, $this->path, $to, $overwrite);
$state = $this->copyNode($to, $overwrite);
$state = $state && $this->deleteNode();
return $state;
}
/**
@ -729,7 +748,9 @@ class Directory extends FileAbstract implements DirectoryInterface
return false;
}
// @todo: update parent
if (isset($this->parent)) {
unset($this->parent->nodes[$this->getBasename()]);
}
return self::delete($this->con, $this->path);
}

View File

@ -482,7 +482,7 @@ class File extends FileAbstract implements FileInterface
$uri = clone $this->uri;
$uri->setPath(self::parent($this->path));
return new Directory($uri, true, $this->con);
return $this->parent ?? new Directory($uri, true, $this->con);
}
/**
@ -517,7 +517,19 @@ class File extends FileAbstract implements FileInterface
return false;
}
return self::copy($this->con, $this->path, $to, $overwrite);
$newParent = $this->findNode($to);
$state = self::copy($this->con, $this->path, $to, $overwrite);
/** @var null|Directory $newParent */
if ($newParent !== null) {
$uri = clone $this->uri;
$uri->setPath($to);
$newParent->addNode(new self($uri));
}
return $state;
}
/**
@ -536,7 +548,10 @@ class File extends FileAbstract implements FileInterface
return false;
}
return self::move($this->con, $this->path, $to, $overwrite);
$state = $this->copyNode($to, $overwrite);
$state = $state && $this->deleteNode();
return $state;
}
/**
@ -552,6 +567,10 @@ class File extends FileAbstract implements FileInterface
return false;
}
if (isset($this->parent)) {
unset($this->parent->nodes[$this->getBasename()]);
}
return self::delete($this->con, $this->path);
}
@ -692,6 +711,6 @@ class File extends FileAbstract implements FileInterface
$uri = clone $this->uri;
$uri->setPath(self::dirpath($this->path));
return new Directory($uri, true, $this->con);
return $this->parent ?? new Directory($uri, true, $this->con);
}
}

View File

@ -116,6 +116,14 @@ abstract class FileAbstract implements FtpContainerInterface
*/
protected bool $isInitialized = false;
/**
* Parent element
*
* @var null|Directory
* @since 1.0.0
*/
protected ?Directory $parent = null;
/**
* Constructor.
*
@ -224,4 +232,68 @@ abstract class FileAbstract implements FtpContainerInterface
$this->isInitialized = true;
}
/**
* Find an existing node in the node tree
*
* @param string $path Path of the node
*
* @return null|Directory
*
* @since 1.0.0
*/
public function findNode(string $path) : ?Directory
{
// Change parent element
$currentPath = \explode('/', \trim($this->path, '/'));
$newPath = \explode('/', \trim($path, '/'));
// Remove last element which is the current name
$currentName = \array_pop($currentPath);
$newName = \array_pop($newPath);
$currentParentName = \end($currentPath);
$newParentName = \end($newPath);
$currentLength = \count($currentPath);
$newLength = \count($newPath);
$max = \max($currentLength, $newLength);
$newParent = $this;
// Evaluate path similarity
for ($i = 0; $i < $max; ++$i) {
if (!isset($currentPath[$i]) || !isset($newPath[$i])
|| $currentPath[$i] !== $newPath[$i]
) {
break;
}
}
// Walk parent path
for ($j = $currentLength - $i; $j > 0; --$j) {
if ($newParent->parent === null) {
// No pwarent found
$newParent = null;
break;
}
$newParent = $newParent->parent;
}
// Walk child path if new path even is in child path
for ($j = $i; $i < $newLength; ++$j) {
if (!isset($newParent->nodes[$newPath[$j]])) {
// Path tree is not defined that deep -> no updating needed
$newParent = null;
break;
}
$newParent = $newParent->nodes[$newPath[$j]];
}
return $newParent;
}
}

View File

@ -45,7 +45,7 @@ final class Directory extends FileAbstract implements DirectoryInterface
* @var array<string, ContainerInterface>
* @since 1.0.0
*/
private array $nodes = [];
public array $nodes = [];
/**
* Constructor.
@ -172,7 +172,11 @@ final class Directory extends FileAbstract implements DirectoryInterface
foreach ($files as $filename) {
if (!StringUtils::endsWith(\trim($filename), '.')) {
$file = \is_dir($filename) ? new self($filename, '*', false) : new File($filename);
$file = \is_dir($filename)
? new self($filename, '*', false)
: new File($filename);
$file->parent = $this;
$this->addNode($file);
}
@ -630,7 +634,7 @@ final class Directory extends FileAbstract implements DirectoryInterface
*/
public function getParent() : ContainerInterface
{
return new self(self::parent($this->path));
return $this->parent ?? new self(self::parent($this->path));
}
/**
@ -638,7 +642,16 @@ final class Directory extends FileAbstract implements DirectoryInterface
*/
public function copyNode(string $to, bool $overwrite = false) : bool
{
return self::copy($this->path, $to, $overwrite);
$newParent = $this->findNode($to);
$state = self::copy($this->path, $to, $overwrite);
/** @var null|Directory $newParent */
if ($newParent !== null) {
$newParent->addNode(new self($to));
}
return $state;
}
/**
@ -646,7 +659,10 @@ final class Directory extends FileAbstract implements DirectoryInterface
*/
public function moveNode(string $to, bool $overwrite = false) : bool
{
return self::move($this->path, $to, $overwrite);
$state = $this->copyNode($to, $overwrite);
$state = $state && $this->deleteNode();
return $state;
}
/**
@ -654,7 +670,9 @@ final class Directory extends FileAbstract implements DirectoryInterface
*/
public function deleteNode() : bool
{
// @todo: update parent
if (isset($this->parent)) {
unset($this->parent->nodes[$this->getBasename()]);
}
return self::delete($this->path);
}

View File

@ -471,7 +471,7 @@ final class File extends FileAbstract implements FileInterface
*/
public function getParent() : ContainerInterface
{
return new Directory(self::parent($this->path));
return $this->parent ?? new Directory(self::parent($this->path));
}
/**
@ -483,7 +483,7 @@ final class File extends FileAbstract implements FileInterface
*/
public function getDirectory() : ContainerInterface
{
return new Directory(self::dirpath($this->path));
return $this->parent ?? new Directory(self::dirpath($this->path));
}
/**
@ -491,7 +491,16 @@ final class File extends FileAbstract implements FileInterface
*/
public function copyNode(string $to, bool $overwrite = false) : bool
{
return self::copy($this->path, $to, $overwrite);
$newParent = $this->findNode($to);
$state = self::copy($this->path, $to, $overwrite);
/** @var null|Directory $newParent */
if ($newParent !== null) {
$newParent->addNode(new self($to));
}
return $state;
}
/**
@ -499,7 +508,10 @@ final class File extends FileAbstract implements FileInterface
*/
public function moveNode(string $to, bool $overwrite = false) : bool
{
return self::move($this->path, $to, $overwrite);
$state = $this->copyNode($to, $overwrite);
$state = $state && $this->deleteNode();
return $state;
}
/**
@ -507,6 +519,10 @@ final class File extends FileAbstract implements FileInterface
*/
public function deleteNode() : bool
{
if (isset($this->parent)) {
unset($this->parent->nodes[$this->getBasename()]);
}
return self::delete($this->path);
}

View File

@ -100,6 +100,14 @@ abstract class FileAbstract implements LocalContainerInterface
*/
protected bool $isInitialized = false;
/**
* Parent element
*
* @var null|Directory
* @since 1.0.0
*/
protected ?Directory $parent = null;
/**
* Constructor.
*
@ -255,4 +263,68 @@ abstract class FileAbstract implements LocalContainerInterface
$this->isInitialized = true;
}
/**
* Find an existing node in the node tree
*
* @param string $path Path of the node
*
* @return null|Directory
*
* @since 1.0.0
*/
public function findNode(string $path) : ?Directory
{
// Change parent element
$currentPath = \explode('/', \trim($this->path, '/'));
$newPath = \explode('/', \trim($path, '/'));
// Remove last element which is the current name
$currentName = \array_pop($currentPath);
$newName = \array_pop($newPath);
$currentParentName = \end($currentPath);
$newParentName = \end($newPath);
$currentLength = \count($currentPath);
$newLength = \count($newPath);
$max = \max($currentLength, $newLength);
$newParent = $this;
// Evaluate path similarity
for ($i = 0; $i < $max; ++$i) {
if (!isset($currentPath[$i]) || !isset($newPath[$i])
|| $currentPath[$i] !== $newPath[$i]
) {
break;
}
}
// Walk parent path
for ($j = $currentLength - $i; $j > 0; --$j) {
if ($newParent->parent === null) {
// No pwarent found
$newParent = null;
break;
}
$newParent = $newParent->parent;
}
// Walk child path if new path even is in child path
for ($j = $i; $i < $newLength; ++$j) {
if (!isset($newParent->nodes[$newPath[$j]])) {
// Path tree is not defined that deep -> no updating needed
$newParent = null;
break;
}
$newParent = $newParent->nodes[$newPath[$j]];
}
return $newParent;
}
}

View File

@ -383,6 +383,21 @@ final class HttpUri implements UriInterface
$this->query = \array_change_key_case($this->query, \CASE_LOWER);
}
public function addQuery(string $key, mixed $value = null)
{
$key = \strtolower($key);
$this->query[$key] = $value;
$toAdd = (empty($this->queryString) ? '?' : '&')
. $key
. ($value === null ? '' : '=' . ((string) $value));
$this->queryString .= $toAdd;
// @todo handle existing string at the end of uri (e.g. #fragment)
$this->uri .= $toAdd;
}
/**
* {@inheritdoc}
*/

View File

@ -1090,7 +1090,7 @@ class QR extends TwoDAbstract
if (self::QR_FIND_FROM_RANDOM !== false) {
$howManuOut = 8 - (self::QR_FIND_FROM_RANDOM % 9);
for ($i = 0; $i < $howManuOut; ++$i) {
// @note: This is why the same content can result in different QR codes
// @note This is why the same content can result in different QR codes
$remPos = \array_rand($checked_masks, 1);
unset($checked_masks[$remPos]);
$checked_masks = \array_values($checked_masks);

View File

@ -80,7 +80,7 @@ abstract class TwoDAbstract extends CodeAbstract
$locationX = $this->margin;
// @todo: Allow manual dimensions
// @todo Allow manual dimensions
for ($posX = 0; $posX < $width; ++$posX) {
$locationY = $this->margin;

View File

@ -18,12 +18,13 @@ use phpOMS\System\File\FileUtils;
use phpOMS\System\File\Local\Directory;
/**
* Zip class for handling zip files.
* Tar class for handling zip files.
*
* Providing basic zip support
*
* IMPORTANT:
* PharData seems to cache created files, which means even if the previously created file is deleted, it cannot create a new file with the same destination.
* PharData seems to cache created files, which means even if the previously created file is deleted,
* it cannot create a new file with the same destination.
* bug? https://bugs.php.net/bug.php?id=75101
*
* @package phpOMS\Utils\IO\Zip

View File

@ -33,7 +33,7 @@ use phpOMS\Uri\UriFactory;
* @see https://github.com/doowzs/parsedown-extreme
* @since 1.0.0
*
* @todo: Add
* @todo Add
* 1. Calendar (own widget)
* 2. Event (own widget)
* 3. Tasks (own widget)
@ -2567,7 +2567,7 @@ class Markdown
return null;
}
// @todo: We are parsing the language here and further down. Shouldn't one time be enough?
// @todo We are parsing the language here and further down. Shouldn't one time be enough?
// Both variations seem to result in the same result?!
$language = \trim(\preg_replace('/^`{3}([^\s]+)(.+)?/s', '$1', $line['text']));
@ -2697,7 +2697,7 @@ class Markdown
'text' => $summary,
],
[
'name' => 'span', // @todo: check if without span possible
'name' => 'span', // @todo check if without span possible
'text' => '',
]
],

View File

@ -265,37 +265,32 @@ final class StringUtils
for ($i = 0; $i < $n; ++$i) {
$mc = $diff['mask'][$i];
if ($mc !== 0) {
switch ($mc) {
case -1:
$result .= '<del>' . $diff['values'][$i] . '</del>' . $delim;
break;
case 1:
$result .= '<ins>' . $diff['values'][$i] . '</ins>' . $delim;
break;
}
} else {
$result .= $diff['values'][$i] . $delim;
$previousMC = $diff['mask'][$i - 1] ?? 0;
$nextMC = $diff['mask'][$i + 1] ?? 0;
switch ($mc) {
case -1:
$result .= ($previousMC === -1 ? '' : '<del>')
. $diff['values'][$i]
. ($nextMC === -1 ? '' : '</del>')
. $delim;
break;
case 1:
$result .= ($previousMC === 1 ? '' : '<ins>')
. $diff['values'][$i]
. ($nextMC === -1 ? '' : '</ins>')
. $delim;
break;
default:
$result .= $diff['values'][$i] . $delim;
}
}
$result = \rtrim($result, $delim);
switch ($mc) {
case -1:
$result .= '</del>';
break;
case 1:
$result .= '</ins>';
break;
}
// @todo: This should not be necessary but the algorithm above allows for weird combinations.
return \str_replace(
['</del></del>', '</ins></ins>', '<ins></ins>', '<del></del>', '</ins><ins>', '</del><del>', '</ins> <del>', '</del> <ins>'],
['</del>', '</ins>', '', '', '', '', '</ins><del>', '</del><ins>'],
$result
);
return $result;
}
/**

View File

@ -535,19 +535,19 @@ final class BuilderTest extends \PHPUnit\Framework\TestCase
$iE = $con->getGrammar()->systemIdentifierEnd;
$query = new Builder($con);
$sql = 'UPDATE [a] SET [a].[test] = 1, [a].[test2] = 2 WHERE [a].[test] = 1;';
$sql = 'UPDATE [a] SET [test] = 1, [test2] = 2 WHERE [a].[test] = 1;';
$sql = \strtr($sql, '[]', $iS . $iE);
self::assertEquals($sql, $query->update('a')->set(['a.test' => 1])->set(['a.test2' => 2])->where('a.test', '=', 1)->toSql());
self::assertEquals($sql, $query->update('a')->set(['test' => 1])->set(['test2' => 2])->where('a.test', '=', 1)->toSql());
$query = new Builder($con);
$sql = 'UPDATE [a] SET [a].[test] = 1, [a].[test2] = 2 WHERE [a].[test] = 1;';
$sql = 'UPDATE [a] SET [test] = 1, [test2] = 2 WHERE [a].[test] = 1;';
$sql = \strtr($sql, '[]', $iS . $iE);
self::assertEquals($sql, $query->update('a')->sets('a.test', 1)->sets('a.test2', 2)->where('a.test', '=', 1)->toSql());
self::assertEquals($sql, $query->update('a')->sets('test', 1)->sets('test2', 2)->where('a.test', '=', 1)->toSql());
$query = new Builder($con);
$sql = 'UPDATE [a] SET [a].[test] = 1, [a].[test2] = :test2 WHERE [a].[test] = :test3;';
$sql = 'UPDATE [a] SET [test] = 1, [test2] = :test2 WHERE [a].[test] = :test3;';
$sql = \strtr($sql, '[]', $iS . $iE);
self::assertEquals($sql, $query->update('a')->set(['a.test' => 1])->set(['a.test2' => new Parameter('test2')])->where('a.test', '=', new Parameter('test3'))->toSql());
self::assertEquals($sql, $query->update('a')->set(['test' => 1])->set(['test2' => new Parameter('test2')])->where('a.test', '=', new Parameter('test3'))->toSql());
}
/**

View File

@ -170,7 +170,7 @@ final class BuilderTest extends \PHPUnit\Framework\TestCase
$iS = $con->getGrammar()->systemIdentifierStart;
$iE = $con->getGrammar()->systemIdentifierEnd;
// @todo: fix, this is not correct for sqlite
// @todo fix, this is not correct for sqlite
$query = new Builder($con);
$sql = '';

View File

@ -143,7 +143,7 @@ final class NodeTest extends \PHPUnit\Framework\TestCase
* @covers phpOMS\Stdlib\Graph\Node
* @group framework
*
* @todo: is there bug where directed graphs return invalid neighbors?
* @todo is there bug where directed graphs return invalid neighbors?
*/
public function testNeighborsInputOutput() : void
{