diff --git a/Algorithm/Clustering/AffinityPropagation.php b/Algorithm/Clustering/AffinityPropagation.php index b5ca7250d..a207374ce 100644 --- a/Algorithm/Clustering/AffinityPropagation.php +++ b/Algorithm/Clustering/AffinityPropagation.php @@ -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 []; + } } diff --git a/Algorithm/Clustering/AgglomerativeClustering.php b/Algorithm/Clustering/AgglomerativeClustering.php index 3f0ee601e..46d604c55 100644 --- a/Algorithm/Clustering/AgglomerativeClustering.php +++ b/Algorithm/Clustering/AgglomerativeClustering.php @@ -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); + } + + } diff --git a/Algorithm/Clustering/Birch.php b/Algorithm/Clustering/Birch.php index 57ba4522e..a941a91aa 100644 --- a/Algorithm/Clustering/Birch.php +++ b/Algorithm/Clustering/Birch.php @@ -25,6 +25,6 @@ namespace phpOMS\Algorithm\Clustering; * * @todo Implement */ -final class Birch +final class Birch implements ClusteringInterface { } diff --git a/Algorithm/Clustering/ClusteringInterface.php b/Algorithm/Clustering/ClusteringInterface.php new file mode 100644 index 000000000..0e8aab348 --- /dev/null +++ b/Algorithm/Clustering/ClusteringInterface.php @@ -0,0 +1,71 @@ + + * @var array * @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; + } } diff --git a/Algorithm/Clustering/Ward.php b/Algorithm/Clustering/DivisiveClustering.php similarity index 62% rename from Algorithm/Clustering/Ward.php rename to Algorithm/Clustering/DivisiveClustering.php index 6791356a2..83fe7eea2 100644 --- a/Algorithm/Clustering/Ward.php +++ b/Algorithm/Clustering/DivisiveClustering.php @@ -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 { } diff --git a/Algorithm/Clustering/Kmeans.php b/Algorithm/Clustering/Kmeans.php index 14e8f05dd..58e4691d9 100755 --- a/Algorithm/Clustering/Kmeans.php +++ b/Algorithm/Clustering/Kmeans.php @@ -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; + } } diff --git a/Algorithm/Clustering/MeanShift.php b/Algorithm/Clustering/MeanShift.php index 3db459b0c..b8346f686 100644 --- a/Algorithm/Clustering/MeanShift.php +++ b/Algorithm/Clustering/MeanShift.php @@ -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; + } } diff --git a/Algorithm/Clustering/SpectralClustering.php b/Algorithm/Clustering/SpectralClustering.php index 8ad8b6b2d..01ccd36d0 100644 --- a/Algorithm/Clustering/SpectralClustering.php +++ b/Algorithm/Clustering/SpectralClustering.php @@ -25,6 +25,6 @@ namespace phpOMS\Algorithm\Clustering; * * @todo Implement */ -final class SpectralClustering +final class SpectralClustering implements ClusteringInterface { } diff --git a/Algorithm/Optimization/SimulatedAnnealing.php b/Algorithm/Optimization/SimulatedAnnealing.php index b0813f20a..93136adcb 100644 --- a/Algorithm/Optimization/SimulatedAnnealing.php +++ b/Algorithm/Optimization/SimulatedAnnealing.php @@ -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 * diff --git a/Algorithm/Rating/Elo.php b/Algorithm/Rating/Elo.php index 881f788fc..127905c25 100644 --- a/Algorithm/Rating/Elo.php +++ b/Algorithm/Rating/Elo.php @@ -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 } } diff --git a/Api/Address/Validation.php b/Api/Address/Validation.php new file mode 100644 index 000000000..3f1481777 --- /dev/null +++ b/Api/Address/Validation.php @@ -0,0 +1,3 @@ +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 + { + + } +} \ No newline at end of file diff --git a/Api/Shipping/DHL/DHLParcelDEShipping.php b/Api/Shipping/DHL/DHLParcelDEShipping.php new file mode 100644 index 000000000..e53997cfb --- /dev/null +++ b/Api/Shipping/DHL/DHLParcelDEShipping.php @@ -0,0 +1,658 @@ +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; + } +} diff --git a/Api/Shipping/DHL/DHLeCommerceShipping.php b/Api/Shipping/DHL/DHLeCommerceShipping.php new file mode 100644 index 000000000..96ea2041c --- /dev/null +++ b/Api/Shipping/DHL/DHLeCommerceShipping.php @@ -0,0 +1,36 @@ +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; +} diff --git a/Api/Shipping/ShippingType.php b/Api/Shipping/ShippingType.php new file mode 100644 index 000000000..3c4596998 --- /dev/null +++ b/Api/Shipping/ShippingType.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/Api/Shipping/Usps/UspsShipping.php b/Api/Shipping/Usps/UspsShipping.php new file mode 100644 index 000000000..6627a04ac --- /dev/null +++ b/Api/Shipping/Usps/UspsShipping.php @@ -0,0 +1,34 @@ +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) { diff --git a/DataStorage/Database/Mapper/DataMapperFactory.php b/DataStorage/Database/Mapper/DataMapperFactory.php index d6fcf5613..47db33bbf 100755 --- a/DataStorage/Database/Mapper/DataMapperFactory.php +++ b/DataStorage/Database/Mapper/DataMapperFactory.php @@ -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') { diff --git a/DataStorage/Database/Mapper/ReadMapper.php b/DataStorage/Database/Mapper/ReadMapper.php index 298253864..a796c806d 100755 --- a/DataStorage/Database/Mapper/ReadMapper.php +++ b/DataStorage/Database/Mapper/ReadMapper.php @@ -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); diff --git a/DataStorage/Database/Mapper/UpdateMapper.php b/DataStorage/Database/Mapper/UpdateMapper.php index 881281cc8..954d4ce96 100755 --- a/DataStorage/Database/Mapper/UpdateMapper.php +++ b/DataStorage/Database/Mapper/UpdateMapper.php @@ -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); diff --git a/DataStorage/Database/Mapper/WriteMapper.php b/DataStorage/Database/Mapper/WriteMapper.php index bc7d5f068..17b47aae3 100755 --- a/DataStorage/Database/Mapper/WriteMapper.php +++ b/DataStorage/Database/Mapper/WriteMapper.php @@ -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; } diff --git a/DataStorage/Database/Query/Grammar/Grammar.php b/DataStorage/Database/Query/Grammar/Grammar.php index f60acbfa8..7980f650a 100755 --- a/DataStorage/Database/Query/Grammar/Grammar.php +++ b/DataStorage/Database/Query/Grammar/Grammar.php @@ -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 { diff --git a/Localization/BaseStringL11n.php b/Localization/BaseStringL11n.php index 78952c75c..5b371cf18 100755 --- a/Localization/BaseStringL11n.php +++ b/Localization/BaseStringL11n.php @@ -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) diff --git a/Math/Geometry/ConvexHull/GrahamScan.php b/Math/Geometry/ConvexHull/GrahamScan.php index fc4881080..681cbeeaa 100644 --- a/Math/Geometry/ConvexHull/GrahamScan.php +++ b/Math/Geometry/ConvexHull/GrahamScan.php @@ -69,7 +69,7 @@ final class GrahamScan /** @var array $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']); }); diff --git a/Math/Optimization/Simplex.php b/Math/Optimization/Simplex.php index a9ca0040c..eaca9d8c6 100644 --- a/Math/Optimization/Simplex.php +++ b/Math/Optimization/Simplex.php @@ -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)); diff --git a/Math/Solver/Root/Illinois.php b/Math/Solver/Root/Illinois.php index d4b9bf71e..09d8d50bf 100644 --- a/Math/Solver/Root/Illinois.php +++ b/Math/Solver/Root/Illinois.php @@ -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) diff --git a/Message/Http/HttpResponse.php b/Message/Http/HttpResponse.php index 6b72923f5..23608abe6 100755 --- a/Message/Http/HttpResponse.php +++ b/Message/Http/HttpResponse.php @@ -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); } diff --git a/Message/Http/Rest.php b/Message/Http/Rest.php index c72843525..8eea3b030 100755 --- a/Message/Http/Rest.php +++ b/Message/Http/Rest.php @@ -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); diff --git a/Module/ModuleAbstract.php b/Module/ModuleAbstract.php index 99b64a168..744d1a4ba 100755 --- a/Module/ModuleAbstract.php +++ b/Module/ModuleAbstract.php @@ -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 * diff --git a/Stdlib/Base/Address.php b/Stdlib/Base/Address.php index eeb7c1d63..abf939f18 100755 --- a/Stdlib/Base/Address.php +++ b/Stdlib/Base/Address.php @@ -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() + ); } } diff --git a/Stdlib/Base/Location.php b/Stdlib/Base/Location.php index d7140e4eb..2cc1f1467 100755 --- a/Stdlib/Base/Location.php +++ b/Stdlib/Base/Location.php @@ -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 * diff --git a/Stdlib/Base/NullAddress.php b/Stdlib/Base/NullAddress.php new file mode 100644 index 000000000..e2cd2f297 --- /dev/null +++ b/Stdlib/Base/NullAddress.php @@ -0,0 +1,27 @@ + * @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); } diff --git a/System/File/Ftp/File.php b/System/File/Ftp/File.php index 6f24f4a8c..6b4b80630 100755 --- a/System/File/Ftp/File.php +++ b/System/File/Ftp/File.php @@ -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); } } diff --git a/System/File/Ftp/FileAbstract.php b/System/File/Ftp/FileAbstract.php index 905808132..9652383b2 100755 --- a/System/File/Ftp/FileAbstract.php +++ b/System/File/Ftp/FileAbstract.php @@ -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; + } } diff --git a/System/File/Local/Directory.php b/System/File/Local/Directory.php index b51786246..ad2a8f97d 100755 --- a/System/File/Local/Directory.php +++ b/System/File/Local/Directory.php @@ -45,7 +45,7 @@ final class Directory extends FileAbstract implements DirectoryInterface * @var array * @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); } diff --git a/System/File/Local/File.php b/System/File/Local/File.php index 9f86c1bde..ce91410e7 100755 --- a/System/File/Local/File.php +++ b/System/File/Local/File.php @@ -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); } diff --git a/System/File/Local/FileAbstract.php b/System/File/Local/FileAbstract.php index 08f0e3f9f..87b03ec69 100755 --- a/System/File/Local/FileAbstract.php +++ b/System/File/Local/FileAbstract.php @@ -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; + } } diff --git a/Uri/HttpUri.php b/Uri/HttpUri.php index 6d4a7cd3a..7ecb83ebd 100755 --- a/Uri/HttpUri.php +++ b/Uri/HttpUri.php @@ -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} */ diff --git a/Utils/Barcode/QR.php b/Utils/Barcode/QR.php index 6dc3f5d10..1d3febd36 100755 --- a/Utils/Barcode/QR.php +++ b/Utils/Barcode/QR.php @@ -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); diff --git a/Utils/Barcode/TwoDAbstract.php b/Utils/Barcode/TwoDAbstract.php index 5b369f610..313e6a970 100755 --- a/Utils/Barcode/TwoDAbstract.php +++ b/Utils/Barcode/TwoDAbstract.php @@ -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; diff --git a/Utils/IO/Zip/Tar.php b/Utils/IO/Zip/Tar.php index 283398746..a9b7380f3 100755 --- a/Utils/IO/Zip/Tar.php +++ b/Utils/IO/Zip/Tar.php @@ -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 diff --git a/Utils/Parser/Markdown/Markdown.php b/Utils/Parser/Markdown/Markdown.php index 0aeda4ff5..62f95f9f2 100755 --- a/Utils/Parser/Markdown/Markdown.php +++ b/Utils/Parser/Markdown/Markdown.php @@ -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' => '', ] ], diff --git a/Utils/StringUtils.php b/Utils/StringUtils.php index 31d23d291..1b63a8df1 100755 --- a/Utils/StringUtils.php +++ b/Utils/StringUtils.php @@ -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 .= '' . $diff['values'][$i] . '' . $delim; - break; - case 1: - $result .= '' . $diff['values'][$i] . '' . $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 ? '' : '') + . $diff['values'][$i] + . ($nextMC === -1 ? '' : '') + . $delim; + + break; + case 1: + $result .= ($previousMC === 1 ? '' : '') + . $diff['values'][$i] + . ($nextMC === -1 ? '' : '') + . $delim; + + break; + default: + $result .= $diff['values'][$i] . $delim; } } $result = \rtrim($result, $delim); - switch ($mc) { - case -1: - $result .= ''; - break; - case 1: - $result .= ''; - break; - } - - // @todo: This should not be necessary but the algorithm above allows for weird combinations. - return \str_replace( - ['', '', '', '', '', '', ' ', ' '], - ['', '', '', '', '', '', '', ''], - $result - ); + return $result; } /** diff --git a/tests/DataStorage/Database/Query/BuilderTest.php b/tests/DataStorage/Database/Query/BuilderTest.php index bb71ec86c..bca777833 100755 --- a/tests/DataStorage/Database/Query/BuilderTest.php +++ b/tests/DataStorage/Database/Query/BuilderTest.php @@ -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()); } /** diff --git a/tests/DataStorage/Database/Schema/BuilderTest.php b/tests/DataStorage/Database/Schema/BuilderTest.php index b988c99c0..03c827f8c 100755 --- a/tests/DataStorage/Database/Schema/BuilderTest.php +++ b/tests/DataStorage/Database/Schema/BuilderTest.php @@ -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 = ''; diff --git a/tests/Stdlib/Graph/NodeTest.php b/tests/Stdlib/Graph/NodeTest.php index 953f19610..f2668ff70 100755 --- a/tests/Stdlib/Graph/NodeTest.php +++ b/tests/Stdlib/Graph/NodeTest.php @@ -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 {