/home/sylamedg/www/wp-content/plugins/woo-usps-simple-shipping/src/Calc.php
<?php /** @noinspection PhpMultipleClassesDeclarationsInOneFile */
declare(strict_types=1);
namespace Dgm\UspsSimple;
use Dgm\UspsSimple\Calc\Cache;
use Dgm\UspsSimple\Calc\Dim;
use Dgm\UspsSimple\Calc\Error\Error;
use Dgm\UspsSimple\Calc\Error\TransportError;
use Dgm\UspsSimple\Calc\Number;
use Dgm\UspsSimple\Calc\Package;
use Dgm\UspsSimple\Calc\Pair;
use Dgm\UspsSimple\Calc\PairMember;
use Dgm\UspsSimple\Calc\Request;
use Dgm\UspsSimple\Model\ServiceFamily;
use Dgm\UspsSimple\Model\Services;
use Exception;
use SimpleXMLElement;
use SplObjectStorage;
class Calc
{
public function __construct(string $apiEndpoint, Cache $cache)
{
$this->apiEndpoint = $apiEndpoint;
$this->cache = $cache;
}
/**
* @throws Exception
*/
public function calc(Request $request, Debug $debug): array
{
$pkg = $request->package;
if (
!$pkg->dest->isDomestic() ||
$pkg->dest->zipCode === '' ||
$pkg->empty() ||
$request->services->empty()
) {
return [];
}
$cacheKey = $request->cacheKey();
$rates = $this->cache->get($cacheKey);
if ($rates !== null) {
return $rates;
}
$subRequests = self::buildSubRequests($pkg, $request->groupByWeight, $request->services->retailGroundEnabled);
if ($subRequests === null) {
return [];
}
$requests = $subRequests->map(function(array $parts) use ($request): string {
return self::buildRequest($parts, $request->apiUserId);
});
$debug->recordRequests($requests);
/** @noinspection PhpUnusedLocalVariableInspection */
$response = null;
{
$responses = self::call($requests, $this->apiEndpoint);
$debug->recordResponses($responses);
$response = self::combine($responses);
$debug->recordCombinedResponse($response);
}
$rates = self::processResponse($response, $request->services, $request->commercialRates);
$this->cache->set($cacheKey, $rates);
return $rates;
}
/**
* @var string
*/
private $apiEndpoint;
/**
* @var Cache
*/
private $cache;
/**
* @return Pair<array<string>>|null
*/
private static function buildSubRequests(Package $package, bool $groupByWeight, bool $retailGround): ?Pair
{
$requestPairs = [];
$origZip = $package->orig->zipCode;
$destZip = $package->dest->zipCode;
$groupWeight = 0;
foreach ($package->items as $id => $item) {
$product = $item->product;
$weight = $product->weight;
if (!$weight) {
$weight = 1; // fallback to 1 lb
}
$dim = $product->dim;
$qty = $item->quantity;
$large = $dim->max() > 12;
if ($groupByWeight && !$large) {
$groupWeight += $weight * $qty;
continue;
}
$requestPairs[] = self::buildPackageRequestPair(
$retailGround,
self::buildExtPackageId($id, $qty, $dim, $weight),
$origZip, $destZip,
$dim, $weight
);
}
$packageWeights = [];
if ($groupWeight > 0) {
$maxPackageWeight = 70;
$fullPackages = floor($groupWeight / $maxPackageWeight);
while ($fullPackages--) {
$packageWeights[] = $maxPackageWeight;
}
if ($remainder = fmod($groupWeight, $maxPackageWeight)) {
$packageWeights[] = $remainder;
}
}
foreach ($packageWeights as $key => $weight) {
$requestPairs[] = self::buildPackageRequestPair(
$retailGround,
self::buildExtPackageId('group_by_weight_'.$key, 1, Dim::$ZERO, 0),
$origZip, $destZip,
Dim::$ZERO,
$weight
);
}
if (!$requestPairs) {
return null;
}
$online = [];
$standard = null;
foreach ($requestPairs as $p) {
$online[] = $p->online;
if (isset($p->standard)) {
$standard[] = $p->standard;
}
}
return new Pair($online, $standard);
}
/**
* @param Pair<string> $requests
* @return Pair<string>
* @throws TransportError
* @throws Error
*/
private static function call(Pair $requests, string $apiEndpoint): Pair
{
return $requests->map(function(string $request, PairMember $service) use ($apiEndpoint): string {
$resp = null;
$success = function() use (&$resp): bool {
return is_array($resp) && (int)$resp['response']['code'] === 200;
};
$tries = 3;
while ($tries--) {
$resp = wp_remote_post($apiEndpoint, [
'timeout' => 15,
'sslverify' => true,
'body' => $request,
]);
if ($success()) {
break;
}
sleep(1);
}
$errctx = [
"service" => $service->uspsServiceName,
"response_type" => gettype($resp),
"response" => $resp,
];
if (!is_array($resp)) {
throw new TransportError("API request transport error", $errctx);
}
if (!$success()) {
throw new Error("API request HTTP error", $errctx);
}
$resp = (string)$resp["body"];
if ($resp === '') {
throw new Error("API response is empty", $errctx);
}
return $resp;
});
}
/**
* @return Pair<string>
*/
private static function buildPackageRequestPair(bool $retailGroundEnabled, string $extPackageId, string $origZip, string $destZip, Dim $dims, $weight): Pair
{
return new Pair(
self::buildPackageRequest($extPackageId, $origZip, $destZip, $dims, $weight, Pair::$ONLINE),
$retailGroundEnabled ? self::buildPackageRequest($extPackageId, $origZip, $destZip, $dims, $weight, Pair::$STANDARD) : null
);
}
private static function buildPackageRequest(string $extPackageId, string $origZip, string $destZip, Dim $dim, $weight, PairMember $service): string
{
$weight = Number::intOrFloat($weight);
// The USPS API fails with 'The entered cubic feet value must be greater than 0' if any dimension is less than
// 0.25 in. Clamping should not affect results for regular-size items since Width/Length/Height/Girth are only
// used for "large" items to calculate weight- or volumetric weight-based rates according the USPS API doc. It
// might affect "large" items having a dimension less than 0.25 in, but we don't have an alternative.
if (!$dim->isZero() && $dim->min() < 0.25) {
$dim = new Dim(max($dim->length, 0.25), max($dim->width, 0.25), max($dim->height, 0.25));
}
$fields = [
'Service' => $service->uspsServiceName,
'ZipOrigination' => substr($origZip, 0, 5),
'ZipDestination' => substr($destZip, 0, 5),
'Pounds' => floor($weight),
'Ounces' => number_format(($weight - floor($weight)) * 16, 2),
'Container' => '',
'Width' => $dim->width,
'Length' => $dim->length,
'Height' => $dim->height,
'Girth' => $dim->girth(),
'GroundOnly' => $service === Pair::$STANDARD ? 'true' : null,
'Machinable' => 'true',
'ShipDate' => date("d-M-Y", strtotime('tomorrow')),
];
$esc = static function($s) {
return htmlspecialchars((string)$s, ENT_XML1 | ENT_QUOTES);
};
$xmls = ["<Package ID=\"{$esc($extPackageId)}\">"];
foreach ($fields as $key => $val) {
if (!isset($val)) {
continue;
}
if ($val === '') {
$xmls[] = "<$key />";
continue;
}
$xmls[] = "<$key>{$esc($val)}</$key>";
}
$xmls[] = '</Package>'."\n";
return join("\n", $xmls);
}
/**
* @param int|string $id
* @param int|float $qty
* @param int|float $weight
*/
private static function buildExtPackageId($id, $qty, Dim $dims, $weight): string
{
return join(':', [$id, $qty, $dims->length, $dims->width, $dims->height, $weight]);
}
/**
* @return array<array{
* id: string,
* label: string,
* cost: float,
* }>
* @throws Exception
*/
private static function processResponse(string $xml, Services $services, bool $commercialRates): array
{
if (strpos($xml, '<Error>') !== false) {
throw new Error("API reports an error", ['response' => $xml]);
}
// null | SplObjectStorage: ServiceFamily => price
$rates = null;
$resp = self::parseXml($xml);
foreach ($resp->{'Package'} as $uspsPackage) {
$cartItemQty = null;
$dim = null;
{
$extPkgId = (string)$uspsPackage->attributes()->ID;
[, $cartItemQty, $packageLength, $packageWidth, $packageHeight] = explode(':', $extPkgId);
if ($packageLength && $packageWidth && $packageHeight) {
$dim = Dim::of($packageLength, $packageWidth, $packageHeight);
}
}
// SplObjectStorage: ServiceFamily => price
$pkgRates = new SplObjectStorage();
foreach ($uspsPackage->{'Postage'} as $uspsPostage) {
$service = null;
{
$code = (string)$uspsPostage->attributes()->CLASSID;
$title = strip_tags(htmlspecialchars_decode((string)$uspsPostage->{'MailService'}));
$service = $services->find($code, $title);
if (!isset($service)) {
continue;
}
}
if ($dim && !$service->fits($dim)) {
continue;
}
$effective = null;
{
$prices = new Arr();
$prices->add($uspsPostage->{'Rate'});
if ($service->alwaysUseCommercialRate || $commercialRates) {
$prices->add($uspsPostage->{'CommercialRate'});
}
$prices = $prices
->map(function($x) { return max(0, (float)$x); })
->filter(function($x) { return $x > 0; });
if ($prices->empty()) {
continue;
}
$effective = $prices->min();
$effective *= $cartItemQty;
}
$pkgRates[$service->family] = min($pkgRates[$service->family] ?? PHP_INT_MAX, $effective);
}
if (!isset($rates)) {
$rates = $pkgRates;
continue;
}
// a new object is required since SplObjectStorage iteration breaks on current item removal
$newRates = new SplObjectStorage();
foreach ($rates as $f) {
$price = $pkgRates[$f] ?? null;
if (!isset($price)) {
continue; // keep only rates applicable to all packages
}
$newRates[$f] = $rates[$f] + $price;
}
$rates = $newRates;
}
$fams = iterator_to_array($rates, false);
usort($fams, function(ServiceFamily $a, ServiceFamily $b): int {
return $a->sort - $b->sort;
});
$extRates = [];
foreach ($fams as $f) {
$extRates[$f->id] = [
'id' => $f->id,
'label' => $f->title,
'cost' => $rates[$f],
];
}
return $extRates;
}
private static function buildRequest(array $subRequests, string $userId): string
{
return 'API=RateV4&XML='.str_replace(["\n", "\r"], '', join('', [
'<RateV4Request USERID="'.$userId.'">',
'<Revision>2</Revision>',
join('', $subRequests),
'</RateV4Request>',
]));
}
/**
* @param Pair<string> $responses
* @throws Exception
*/
private static function combine(Pair $responses): string
{
$responses = $responses->map(function($x) { return self::parseXml($x); });
$combined = isset($responses->standard) ? self::transplantRetailGround($responses) : $responses->online;
$xml = $combined->asXML();
if (!is_string($xml)) {
$type = gettype($xml);
throw new Exception("string expected from \$combined->asXML(), $type given");
}
return $xml;
}
/**
* @param Pair<SimpleXMLElement> $responses
*/
private static function transplantRetailGround(Pair $responses): SimpleXMLElement
{
$attr = function(SimpleXMLElement $node, string $aname): string {
return (string)$node->attributes()->{$aname};
};
foreach ($responses->online->{'Package'} as $onlinePackage) {
// skip the standard-post response if the online response already has a retail ground rate
foreach ($onlinePackage->{'Postage'} as $postage) {
if ($attr($postage, 'CLASSID') === Services::RETAIL_GROUND_CODE) {
continue 2;
}
}
$onlinePackageId = $attr($onlinePackage, 'ID');
foreach ($responses->standard->{'Package'} as $standardPackage) {
$standardPackageId = $attr($standardPackage, 'ID');
if ($standardPackageId !== $onlinePackageId) {
continue;
}
foreach ($standardPackage->{'Postage'} as $postage) {
if ($attr($postage, 'CLASSID') !== Services::RETAIL_GROUND_CODE) {
continue;
}
$postageCopy = $onlinePackage->addChild('Postage');
$postageCopy->addAttribute('CLASSID', Services::RETAIL_GROUND_CODE);
$postageCopy->addChild('MailService', (string)$postage->{'MailService'});
$postageCopy->addChild('Rate', (string)$postage->{'Rate'});
}
}
}
return $responses->online;
}
/**
* @throws Exception
*/
private static function parseXml(string $xml): SimpleXMLElement
{
libxml_use_internal_errors(true);
$r = simplexml_load_string($xml);
if ($r === false) {
throw new Error("failed to parse response", [
'libxml_last_error' => libxml_get_last_error(),
'xml' => $xml,
]);
}
/** @noinspection IsEmptyFunctionUsageInspection */
if (empty($r)) {
throw new Error("parsed response is empty", [
'xml' => $xml,
]);
}
return $r;
}
}
/**
* @template T
*/
class Arr
{
/**
* @param array<T> $a
*/
public function __construct(array $a = [])
{
$this->a = $a;
}
/**
* @param T $item
*/
public function add($item): self
{
$this->a[] = $item;
return $this;
}
public function map(callable $f): self
{
return new self(array_map($f, $this->a));
}
public function filter(callable $f): self
{
return new self(array_filter($this->a, $f));
}
/**
* @return T
*/
public function min()
{
return min($this->a);
}
public function empty(): bool
{
return empty($this->a);
}
public static function wrap(array $a): self
{
return new self($a);
}
public function unwrap(): array
{
return $this->a;
}
private $a;
}
// Regional rate boxes need additonal checks to deal with USPS's complex API
/*case "47" :
if ( ( $packageLength > 10 || $packageWidth > 7 || $packageHeight > 4.75 ) && ( $packageLength > 12.875 || $packageWidth > 10.9375 || $packageHeight > 2.365 ) ) {
continue 2;
} else {
// Valid
break;
}
break;
case "49" :
if ( ( $packageLength > 12 || $packageWidth > 10.25 || $packageHeight > 5 ) && ( $packageLength > 15.875 || $packageWidth > 14.375 || $packageHeight > 2.875 ) ) {
continue 2;
} else {
// Valid
break;
}
break;
case "58" :
if ( $packageLength > 14.75 || $packageWidth > 11.75 || $packageHeight > 11.5 ) {
continue 2;
} else {
// Valid
break;
}
break;*/