diff --git a/common/config/main.php b/common/config/main.php index 1ebca83e..89bba831 100755 --- a/common/config/main.php +++ b/common/config/main.php @@ -78,7 +78,6 @@ 'baseUrl' => $params['coldco']['baseUrl'], 'clientId' => $params['coldco']['clientId'], 'secret' => $params['coldco']['secret'], - ] - + ], ], ]; diff --git a/common/config/params.php b/common/config/params.php index dc13a365..c83a9531 100755 --- a/common/config/params.php +++ b/common/config/params.php @@ -67,4 +67,8 @@ 'csvBoxS3Secret' => '', 'csvBoxS3Region' => '', 'csvBoxS3Bucket' => '', + + 'speedeeFtpHost' => '', + 'speedeeFtpUser' => 123, + 'speedeeFtpPass' => '', ]; diff --git a/common/models/SpeedeeManifest.php b/common/models/SpeedeeManifest.php new file mode 100644 index 00000000..c2db338f --- /dev/null +++ b/common/models/SpeedeeManifest.php @@ -0,0 +1,166 @@ + 6], + [['ship_from_name', 'ship_from_attention', 'ship_from_address_1', 'ship_from_address_2', 'ship_from_city', 'ship_from_country', 'ship_from_email', 'ship_from_phone', 'ship_to_import_field', 'ship_to_name', 'ship_to_attention', 'ship_to_address_1', 'ship_to_address_2', 'ship_to_city', 'ship_to_country', 'ship_to_email', 'ship_to_phone', 'reference_1', 'reference_2', 'reference_3', 'reference_4', 'barcode'], 'string', 'max' => 255], + [['customer_id'], 'exist', 'skipOnError' => true, 'targetClass' => Customer::class, 'targetAttribute' => ['customer_id' => 'id']], + [['order_id'], 'exist', 'skipOnError' => true, 'targetClass' => Order::class, 'targetAttribute' => ['order_id' => 'id']], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels() + { + return [ + 'id' => 'ID', + 'order_id' => 'Order ID', + 'customer_id' => 'Customer ID', + 'ship_from_shipper_number' => 'Ship From Shipper Number', + 'ship_from_name' => 'Ship From Name', + 'ship_from_attention' => 'Ship From Attention', + 'ship_from_address_1' => 'Ship From Address 1', + 'ship_from_address_2' => 'Ship From Address 2', + 'ship_from_city' => 'Ship From City', + 'ship_from_zip' => 'Ship From Zip', + 'ship_from_country' => 'Ship From Country', + 'ship_from_email' => 'Ship From Email', + 'ship_from_phone' => 'Ship From Phone', + 'ship_to_import_field' => 'Ship To Import Field', + 'ship_to_shipper_number' => 'Ship To Shipper Number', + 'ship_to_name' => 'Ship To Name', + 'ship_to_attention' => 'Ship To Attention', + 'ship_to_address_1' => 'Ship To Address 1', + 'ship_to_address_2' => 'Ship To Address 2', + 'ship_to_city' => 'Ship To City', + 'ship_to_country' => 'Ship To Country', + 'ship_to_email' => 'Ship To Email', + 'ship_to_phone' => 'Ship To Phone', + 'reference_1' => 'Reference 1', + 'reference_2' => 'Reference 2', + 'reference_3' => 'Reference 3', + 'reference_4' => 'Reference 4', + 'weight' => 'Weight', + 'length' => 'Length', + 'width' => 'Width', + 'height' => 'Height', + 'barcode' => 'Barcode', + 'oversized' => 'Oversized', + 'pickup_tag' => 'Pickup Tag', + 'aod' => 'Aod', + 'aod_option' => 'Aod Option', + 'cod' => 'Cod', + 'cod_value' => 'Cod Value', + 'declared_value' => 'Declared Value', + 'package_handling' => 'Package Handling', + 'apply_package_handling' => 'Apply Package Handling', + 'ship_date' => 'Ship Date', + 'bill_to_shipper_number' => 'Bill To Shipper Number', + 'unboxed' => 'Unboxed', + 'manifest_filename' => 'Manifest Filename', + 'is_manifest_sent' => 'Manifest is Sent', + 'checksum' => 'Checksum', + ]; + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getCustomer() + { + return $this->hasOne('common\models\Customer', ['id' => 'customer_id']); + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getOrder() + { + return $this->hasOne('common\models\Order', ['id' => 'order_id']); + } +} diff --git a/common/models/shipping/Carrier.php b/common/models/shipping/Carrier.php index fb2366df..4a49ddab 100755 --- a/common/models/shipping/Carrier.php +++ b/common/models/shipping/Carrier.php @@ -20,6 +20,7 @@ class Carrier extends BaseCarrier const DHL = 4; const AMAZON_UPS = 5; const AMAZON_FEDEX = 6; + const SPEEDEE = 9; const AMAZON_USPS = 11; /** @var array */ @@ -31,6 +32,7 @@ class Carrier extends BaseCarrier self::AMAZON_UPS => 'AMAZON_UPS', self::AMAZON_FEDEX => 'AMAZON_FEDEX', self::AMAZON_USPS => 'AMAZON_USPS', + self::SPEEDEE => 'SPEEDEE', ]; const REPRINT_BEHAVIOUR_CREATE_NEW = 1; @@ -49,6 +51,7 @@ class Carrier extends BaseCarrier self::AMAZON_UPS, self::AMAZON_FEDEX, self::AMAZON_USPS, + self::SPEEDEE, ]; /** diff --git a/common/models/shipping/extension/wsdl/SpeeDeePlugin.php b/common/models/shipping/extension/wsdl/SpeeDeePlugin.php new file mode 100644 index 00000000..00f7ed8a --- /dev/null +++ b/common/models/shipping/extension/wsdl/SpeeDeePlugin.php @@ -0,0 +1,124 @@ +where(['customer_id' => $customerId]) + ->andWhere(['key' => 'speedee_customer_number']) + ->one(); + + $this->customerNumber = $customerMeta->value; + } + + public function getPluginName() + { + return self::PLUGIN_NAME; + } + + /** + * + * @return mixed|void + */ + protected function ratePrepare() + { + return $this; + } + + protected function rateExecute() + { + return $this; + } + + protected function rateProcess() + { + return $this; + } + + protected function shipmentPrepare() + { + $manifest = new SpeedeeManifest(); + // Shipper Information + $manifest->ship_from_shipper_number = $this->customerNumber; + $manifest->ship_from_name = $this->shipment->sender_company; + $manifest->ship_from_address_1 = $this->shipment->sender_address1; + $manifest->ship_from_address_2 = $this->shipment->sender_address2; + $manifest->ship_from_city = $this->shipment->sender_city; + $manifest->ship_from_zip = $this->shipment->sender_postal_code; + $manifest->ship_from_country = $this->shipment->sender_country; + $manifest->ship_from_email = $this->shipment->sender_email; + $manifest->ship_from_phone = $this->shipment->sender_phone; + + // Recipient Information + $manifest->ship_to_import_field = ''; // Would be the speedee internal recipient ID if we had it. + $manifest->ship_to_shipper_number = ''; // Probably wouldn't have this. + $manifest->ship_to_name = $this->shipment->recipient_company ?? $this->shipment->recipient_contact; + $manifest->ship_to_attention = ! $this->shipment->recipient_is_residential ? substr($this->shipment->recipient_contact, 0, 35) : ''; + $manifest->ship_to_address_1 = $this->shipment->recipient_address1; + $manifest->ship_to_address_2 = $this->shipment->recipient_address2; + $manifest->ship_to_city = $this->shipment->recipient_city; + $manifest->ship_to_country = $this->shipment->recipient_country; + $manifest->ship_to_email = $this->shipment->recipient_email; + $manifest->ship_to_phone = $this->shipment->recipient_phone; + $manifest->reference_1 = $this->shipment->order->customer_reference; // Additional Reference Field (Usually Invoice Number). 2, 3, 4 are also available for use. + $manifest->weight = $this->shipment->getTotalWeight(); + + $package = $this->shipment->getPackages()[0]; + $manifest->height = $package->height; + $manifest->length = $package->length; + $manifest->width = $package->width; + + $manifest->oversized = false; // All package sizes are valid + $manifest->pickup_tag = ''; // TODO: find this? + $manifest->aod = false; // TODO: Find signature required + $manifest->aod_option = 0; // TODO: Map int values to option + $manifest->cod = false; // TODO: Find COD options + $manifest->cod_value = 0; + $manifest->package_handling = 0; // TODO: Package handling rate calc? + $manifest->apply_package_handling = false; + $manifest->ship_date = date("Y-m-d H:i:s"); + $manifest->bill_to_shipper_number = $this->customerNumber; + $manifest->unboxed = false; // If our cartonization forgets to send a box, re-evaluate our lives + + $manifest->save(); + + $this->currentManifest = $manifest; + + return $this; + } + + protected function shipmentExecute() + { + // Currently queing this up manually for bulk process +// Yii::$app->queue->push(new \SpeeDeeShipJob([ +// 'manifest' => $this->currentManifest, +// ])); + return $this; + } + + protected function shipmentProcess() + { + return $this; + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 9d9fd643..3c6adf18 100755 --- a/composer.json +++ b/composer.json @@ -25,7 +25,9 @@ "2amigos/2fa-library": "^2.0", "2amigos/qrcode-library": "^2.0", "frostealth/yii2-aws-s3": "~2.0", - "league/csv": "^9.8" + "league/csv": "^9.8", + "league/flysystem-ftp": "^3.0", + "nesbot/carbon": "^2.64" }, "require-dev": { "yiisoft/yii2-debug": "~2.1.0", diff --git a/console/jobs/speedee/SpeeDeeShipJob.php b/console/jobs/speedee/SpeeDeeShipJob.php new file mode 100644 index 00000000..9cefadc9 --- /dev/null +++ b/console/jobs/speedee/SpeeDeeShipJob.php @@ -0,0 +1,159 @@ +cache->getOrSet('speedee_manifest_index_' . $this->customer_number, function () { + return '0000'; + }); + } + + /** + * Advance the index to the next 4-digit combination. + * + * @return void + */ + private function bumpIndex() : void + { + // Retrieve current string value, coerce to int (under threat of violence), advance. + $current = intval(Yii::$app->cache->get('speedee_manifest_index_' . $this->customer_number)); + $current++; + // Set the 4-digit value. + Yii::$app->cache->set('speedee_manifest_index_' . $this->customer_number, sprintf('%04d', $current)); + } + + /** + * Update the given record with the sent manifest data + * + * @param SpeedeeManifest $manifest + * @param $filename + * @param $checksum + * @return void + */ + private function updateManifestEntry(SpeedeeManifest $manifest, $filename, $checksum): void + { + $manifest->manifest_filename = $filename; + $manifest->is_manifest_sent = true; + $manifest->checksum = $checksum; + $manifest->save(); + } + + public function execute($queue) + { + // I'm sure you made some nifty abstraction for this so just shoehorn it in here + $this->customer_number = CustomerMeta::find()->where(['customer_id' => $this->customer_id])->andWhere(['key' => 'speedee_customer_number'])->one()->value; + + // Roll up qualifying manifest entries as an array + $manifests = SpeedeeManifest::find() + ->where(['customer_id' => $this->customer_id]) + ->andWhere(['is_manifest_sent' => false]) + ->all(); + + if (count($manifests) == 0) { + return; + } + + $filename = $this->customer_number + . '.' + . Carbon::now()->format('Ymd') + . $this->getIndex() + . '.csv'; + + // Format and write to a single CSV + $csv = Writer::createFromString(); + foreach ($manifests as $manifest) { + $manifest = $manifest->toArray(); + $formattedManifest = []; + foreach ($manifest as $key => $value) { + $formattedManifest[] = $value; + } + $csv->insertOne($formattedManifest); + unset($formattedManifest); + } + + // Calculate a sha1 checksum + $checksum = sha1($csv->toString()); + + // Set up the connection + $adapter = new FtpAdapter( + FtpConnectionOptions::fromArray([ + 'host' => Yii::$app->params['speedeeFtpHost'], + 'root' => '/', + 'username' => Yii::$app->params['speedeeFtpUser'], + 'password' => Yii::$app->params['speedeeFtpPass'], + 'port' => 21, + 'ssl' => false, + 'timeout' => 90, + 'utf8' => false, + 'passive' => true, + 'transferMode' => FTP_BINARY, + ]) + ); + + $filesystem = new Filesystem($adapter); + + // Write to the remote server + try { + Yii::info('trying?'); + $filesystem->write($filename, $csv->toString()); + } catch (\League\Flysystem\FilesystemException $e) { + Yii::error('no working ' . $e); + die(); + } + + // Validate remote file + try { + $validate = sha1($filesystem->read($filename)); + if ($validate !== $checksum) { + throw new \Exception('Manifest ' . $filename . ' did not pass checksum.'); + } + } catch (\League\Flysystem\FilesystemException $e) { + Yii::error('no working ' . $e); + die(); + } catch (\Exception $e) { + Yii::error('no working ' . $e); + die(); + } + + // Update the manifest entries in the application database + foreach ($manifests as $manifest) { + $this->updateManifestEntry($manifest, $filename, $checksum); + } + + // Advance the current filename in the local cache. + $this->bumpIndex(); + } + + public function getTtr() + { + return 120; + } + + public function canRetry($attempt, $error) + { + return ($attempt < 3) && ($error instanceof \League\Flysystem\FilesystemException) || ($error instanceof Exception); + } +} \ No newline at end of file diff --git a/console/migrations/m221227_200815_create_speedee_manifests_table.php b/console/migrations/m221227_200815_create_speedee_manifests_table.php new file mode 100644 index 00000000..d06fc7fc --- /dev/null +++ b/console/migrations/m221227_200815_create_speedee_manifests_table.php @@ -0,0 +1,95 @@ +createTable('{{%speedee_manifests}}', [ + 'id' => $this->primaryKey(), + 'order_id' => $this->integer(), + 'customer_id' => $this->integer(), + 'ship_from_shipper_number' => $this->string(6), + 'ship_from_name' => $this->string(), + 'ship_from_attention' => $this->string()->null(), + 'ship_from_address_1' => $this->string(), + 'ship_from_address_2' => $this->string()->null(), + 'ship_from_city' => $this->string(), + 'ship_from_zip' => $this->integer(5), + 'ship_from_country' => $this->string()->null(), + 'ship_from_email' => $this->string()->null(), + 'ship_from_phone' => $this->string(), + 'ship_to_import_field' => $this->string(), // Destination Company Identification Number/String + 'ship_to_shipper_number' => $this->string(6)->null(), + 'ship_to_name' => $this->string(), + 'ship_to_attention' => $this->string()->null(), + 'ship_to_address_1' => $this->string(), + 'ship_to_address_2' => $this->string()->null(), + 'ship_to_city' => $this->string(), + 'ship_to_country' => $this->string(), + 'ship_to_email' => $this->string()->null(), + 'ship_to_phone' => $this->string()->null(), + 'reference_1' => $this->string()->null(), // Additional Reference Field (Usually Invoice Number) + 'reference_2' => $this->string()->null(), + 'reference_3' => $this->string()->null(), + 'reference_4' => $this->string()->null(), + 'weight' => $this->integer(), + 'length' => $this->integer()->null(), + 'width' => $this->integer()->null(), + 'height' => $this->integer()->null(), + 'barcode' => $this->string(), + 'oversized' => $this->boolean()->defaultValue(false), + 'pickup_tag' => $this->boolean()->defaultValue(false), + 'aod' => $this->boolean()->defaultValue(false), // Package Requires an Acknowledgement of Delivery + 'aod_option' => $this->integer()->defaultValue(0), // See SDS spreadsheet cell N11 + 'cod' => $this->boolean()->defaultValue(false), + 'cod_value' => $this->integer()->null(), // Amount to collect for COD (cents) + 'declared_value' => $this->integer(), // Declared value for insurance purposes (cents) + 'package_handling' => $this->integer(), // Package handling - flat amount + 'apply_package_handling' => $this->boolean()->defaultValue(false), + 'ship_date' => $this->date(), // Date package was picked up by driver; default is to use processing date + 'bill_to_shipper_number' => $this->string(6), // currently same as ship_to number + 'unboxed' => $this->boolean()->defaultValue(false), + + 'manifest_filename' => $this->string()->null(), + 'is_manifest_sent' => $this->boolean()->defaultValue(false), + 'checksum' => $this->string()->null(), + 'created_at' => $this->bigInteger(), + 'updated_at' => $this->bigInteger(), + ]); + + $this->addForeignKey( + 'fk-speedee-manifest-order-id', + 'speedee_manifests', + 'order_id', + 'orders', + 'id' + ); + + $this->addForeignKey( + 'fk-speedee-manifest-customer-id', + 'speedee_manifests', + 'customer_id', + 'customers', + 'id' + ); + } + + /** + * {@inheritdoc} + */ + public function safeDown() + { + $this->dropForeignKey('fk-speedee-manifest-order-id', 'speedee_manifests'); + $this->dropForeignKey('fk-speedee-manifest-customer-id', 'speedee_manifests'); + + $this->dropTable('{{%speedee_manifests}}'); + } +} diff --git a/frontend/controllers/ReportController.php b/frontend/controllers/ReportController.php index 40cb4a80..4a4246d5 100755 --- a/frontend/controllers/ReportController.php +++ b/frontend/controllers/ReportController.php @@ -2,9 +2,12 @@ namespace frontend\controllers; +use common\models\SpeedeeManifest; use console\jobs\CreateReportJob; +use console\jobs\speedee\SpeeDeeShipJob; use frontend\models\Customer; use frontend\models\forms\ReportForm; +use frontend\models\forms\SpeedeeManifestForm; use frontend\models\User; use Yii; @@ -31,6 +34,31 @@ public function behaviors() ]; } + public function actionManifestSpeedee() + { + $model = new SpeedeeManifestForm(); + + if (Yii::$app->request->post()) { + $request = Yii::$app->request->post(); + Yii::$app->queue->push(new SpeeDeeShipJob(['customer_id' => $request['SpeedeeManifestForm']['customer']])); + Yii::$app->session->setFlash('success', "Manifest queued for delivery."); + } + + return $this->render('speedee-manifest', [ + 'model' => $model, + 'customers' => Yii::$app->user->identity->isAdmin + ? Customer::getList() + : Yii::$app->user->identity->getCustomerList(), + ]); + } + + public function actionSpeedeeFetch($customerId) + { + Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; + + return SpeedeeManifest::find()->where(['customer_id' => $customerId])->andWhere(['is_manifest_sent' => false])->all(); + } + /** * Index for creating CSV report * diff --git a/frontend/models/forms/SpeedeeManifestForm.php b/frontend/models/forms/SpeedeeManifestForm.php new file mode 100644 index 00000000..a617bbd7 --- /dev/null +++ b/frontend/models/forms/SpeedeeManifestForm.php @@ -0,0 +1,35 @@ + array_keys( + Yii::$app->user->identity->isAdmin + ? Customer::getList() + : Yii::$app->user->identity->getCustomerList() + ), + ], + ]; + } +} \ No newline at end of file diff --git a/frontend/views/layouts/main.php b/frontend/views/layouts/main.php index 817d0dc2..8d1fde77 100755 --- a/frontend/views/layouts/main.php +++ b/frontend/views/layouts/main.php @@ -102,7 +102,23 @@ ['label' => 'Scheduling', 'url' => ['/order/scheduled']], ] ]; - $menuItems[] = ['label' => 'Reports', 'url' => ['/report']]; + $menuItems[] = [ + 'label' => 'Reports', + 'url' => ['/'], + 'items' => [ + ['label' => 'Orders', 'url' => ['/report']], + [ + 'label' => 'Manifest', 'url' => ['/'], + 'items' => [ + [ + 'label' => 'SpeeDee', + 'url' => ['/report/manifest-speedee'], + 'visible' => true, // @TODO: come back once we've implemented a "has speedee" type check + ] + ] + ] + ] + ]; if (Yii::$app->user->identity->isAdmin) { $menuItems[] = [ diff --git a/frontend/views/report/speedee-manifest.php b/frontend/views/report/speedee-manifest.php new file mode 100644 index 00000000..cc881f44 --- /dev/null +++ b/frontend/views/report/speedee-manifest.php @@ -0,0 +1,46 @@ + +
+
+

Generate SpeeDee Delivery Manifest

+

+ The following orders are pending for SpeeDee Delivery. Click the button below to generate a shipment manifest + and forward it to SpeeDee Delivery for processing. +

+
+ + field($model, 'customer') + ->dropdownList($customers, [ + 'prompt' => ' Select customer...', + ]); + ?> + 'btn btn-primary']) ?> + + + + + + + + + + +
Reference #Recipient
+
+
+
+ +registerJsFile( + '@web/js/speedee-ajax.js', + ['depends' => [\yii\web\JqueryAsset::class]] +); diff --git a/frontend/web/js/speedee-ajax.js b/frontend/web/js/speedee-ajax.js new file mode 100644 index 00000000..85f939c4 --- /dev/null +++ b/frontend/web/js/speedee-ajax.js @@ -0,0 +1,19 @@ +$('#speedeemanifestform-customer').change(function () { + let rowHtml; + $.get('/report/speedee-fetch?customerId=' + (this).value, function (data) { + console.log(data); + if (data.length === 0) { + $('#ordersTable tbody').html('

No pending orders found for SpeeDee.

'); + } else { + $.each(data, function (i, item) { + rowHtml += ` + + ${item.reference_1} + ${item.ship_to_name} + + ` + }) + $('#ordersTable tbody').html(rowHtml); + } + }); +}); \ No newline at end of file