CsvImportHandler.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\TaxImportExport\Model\Rate;
  7. /**
  8. * Tax Rate CSV Import Handler
  9. *
  10. * @api
  11. * @since 100.0.2
  12. */
  13. class CsvImportHandler
  14. {
  15. /**
  16. * Collection of publicly available stores
  17. *
  18. * @var \Magento\Store\Model\ResourceModel\Store\Collection
  19. */
  20. protected $_publicStores;
  21. /**
  22. * Region collection prototype
  23. *
  24. * The instance is used to retrieve regions based on country code
  25. *
  26. * @var \Magento\Directory\Model\ResourceModel\Region\Collection
  27. */
  28. protected $_regionCollection;
  29. /**
  30. * Country factory
  31. *
  32. * @var \Magento\Directory\Model\CountryFactory
  33. */
  34. protected $_countryFactory;
  35. /**
  36. * Tax rate factory
  37. *
  38. * @var \Magento\Tax\Model\Calculation\RateFactory
  39. */
  40. protected $_taxRateFactory;
  41. /**
  42. * CSV Processor
  43. *
  44. * @var \Magento\Framework\File\Csv
  45. */
  46. protected $csvProcessor;
  47. /**
  48. * @param \Magento\Store\Model\ResourceModel\Store\Collection $storeCollection
  49. * @param \Magento\Directory\Model\ResourceModel\Region\Collection $regionCollection
  50. * @param \Magento\Directory\Model\CountryFactory $countryFactory
  51. * @param \Magento\Tax\Model\Calculation\RateFactory $taxRateFactory
  52. * @param \Magento\Framework\File\Csv $csvProcessor
  53. */
  54. public function __construct(
  55. \Magento\Store\Model\ResourceModel\Store\Collection $storeCollection,
  56. \Magento\Directory\Model\ResourceModel\Region\Collection $regionCollection,
  57. \Magento\Directory\Model\CountryFactory $countryFactory,
  58. \Magento\Tax\Model\Calculation\RateFactory $taxRateFactory,
  59. \Magento\Framework\File\Csv $csvProcessor
  60. ) {
  61. // prevent admin store from loading
  62. $this->_publicStores = $storeCollection->setLoadDefault(false);
  63. $this->_regionCollection = $regionCollection;
  64. $this->_countryFactory = $countryFactory;
  65. $this->_taxRateFactory = $taxRateFactory;
  66. $this->csvProcessor = $csvProcessor;
  67. }
  68. /**
  69. * Retrieve a list of fields required for CSV file (order is important!)
  70. *
  71. * @return array
  72. */
  73. public function getRequiredCsvFields()
  74. {
  75. // indexes are specified for clarity, they are used during import
  76. return [
  77. 0 => __('Code'),
  78. 1 => __('Country'),
  79. 2 => __('State'),
  80. 3 => __('Zip/Post Code'),
  81. 4 => __('Rate'),
  82. 5 => __('Zip/Post is Range'),
  83. 6 => __('Range From'),
  84. 7 => __('Range To')
  85. ];
  86. }
  87. /**
  88. * Import Tax Rates from CSV file
  89. *
  90. * @param array $file file info retrieved from $_FILES array
  91. * @return void
  92. * @throws \Magento\Framework\Exception\LocalizedException
  93. */
  94. public function importFromCsvFile($file)
  95. {
  96. if (!isset($file['tmp_name'])) {
  97. throw new \Magento\Framework\Exception\LocalizedException(__('Invalid file upload attempt.'));
  98. }
  99. $ratesRawData = $this->csvProcessor->getData($file['tmp_name']);
  100. // first row of file represents headers
  101. $fileFields = $ratesRawData[0];
  102. $validFields = $this->_filterFileFields($fileFields);
  103. $invalidFields = array_diff_key($fileFields, $validFields);
  104. $ratesData = $this->_filterRateData($ratesRawData, $invalidFields, $validFields);
  105. // store cache array is used to quickly retrieve store ID when handling locale-specific tax rate titles
  106. $storesCache = $this->_composeStoreCache($validFields);
  107. $regionsCache = [];
  108. foreach ($ratesData as $rowIndex => $dataRow) {
  109. // skip headers
  110. if ($rowIndex == 0) {
  111. continue;
  112. }
  113. $regionsCache = $this->_importRate($dataRow, $regionsCache, $storesCache);
  114. }
  115. }
  116. /**
  117. * Filter file fields (i.e. unset invalid fields)
  118. *
  119. * @param array $fileFields
  120. * @return string[] filtered fields
  121. */
  122. protected function _filterFileFields(array $fileFields)
  123. {
  124. $filteredFields = $this->getRequiredCsvFields();
  125. $requiredFieldsNum = count($this->getRequiredCsvFields());
  126. $fileFieldsNum = count($fileFields);
  127. // process title-related fields that are located right after required fields with store code as field name)
  128. for ($index = $requiredFieldsNum; $index < $fileFieldsNum; $index++) {
  129. $titleFieldName = $fileFields[$index];
  130. if ($this->_isStoreCodeValid($titleFieldName)) {
  131. // if store is still valid, append this field to valid file fields
  132. $filteredFields[$index] = $titleFieldName;
  133. }
  134. }
  135. return $filteredFields;
  136. }
  137. /**
  138. * Filter rates data (i.e. unset all invalid fields and check consistency)
  139. *
  140. * @param array $rateRawData
  141. * @param array $invalidFields assoc array of invalid file fields
  142. * @param array $validFields assoc array of valid file fields
  143. * @return array
  144. * @throws \Magento\Framework\Exception\LocalizedException
  145. * @SuppressWarnings(PHPMD.UnusedLocalVariable)
  146. */
  147. protected function _filterRateData(array $rateRawData, array $invalidFields, array $validFields)
  148. {
  149. $validFieldsNum = count($validFields);
  150. foreach ($rateRawData as $rowIndex => $dataRow) {
  151. // skip empty rows
  152. if (count($dataRow) <= 1) {
  153. unset($rateRawData[$rowIndex]);
  154. continue;
  155. }
  156. // unset invalid fields from data row
  157. foreach ($dataRow as $fieldIndex => $fieldValue) {
  158. if (isset($invalidFields[$fieldIndex])) {
  159. unset($rateRawData[$rowIndex][$fieldIndex]);
  160. }
  161. }
  162. // check if number of fields in row match with number of valid fields
  163. if (count($rateRawData[$rowIndex]) != $validFieldsNum) {
  164. throw new \Magento\Framework\Exception\LocalizedException(__('Invalid file format.'));
  165. }
  166. }
  167. return $rateRawData;
  168. }
  169. /**
  170. * Compose stores cache
  171. *
  172. * This cache is used to quickly retrieve store ID when handling locale-specific tax rate titles
  173. *
  174. * @param string[] $validFields list of valid CSV file fields
  175. * @return array
  176. */
  177. protected function _composeStoreCache($validFields)
  178. {
  179. $storesCache = [];
  180. $requiredFieldsNum = count($this->getRequiredCsvFields());
  181. $validFieldsNum = count($validFields);
  182. // title related fields located right after required fields
  183. for ($index = $requiredFieldsNum; $index < $validFieldsNum; $index++) {
  184. foreach ($this->_publicStores as $store) {
  185. $storeCode = $validFields[$index];
  186. if ($storeCode === $store->getCode()) {
  187. $storesCache[$index] = $store->getId();
  188. }
  189. }
  190. }
  191. return $storesCache;
  192. }
  193. /**
  194. * Check if public store with specified code still exists
  195. *
  196. * @param string $storeCode
  197. * @return boolean
  198. */
  199. protected function _isStoreCodeValid($storeCode)
  200. {
  201. $isStoreCodeValid = false;
  202. foreach ($this->_publicStores as $store) {
  203. if ($storeCode === $store->getCode()) {
  204. $isStoreCodeValid = true;
  205. break;
  206. }
  207. }
  208. return $isStoreCodeValid;
  209. }
  210. /**
  211. * Import single rate
  212. *
  213. * @param array $rateData
  214. * @param array $regionsCache cache of regions of already used countries (is used to optimize performance)
  215. * @param array $storesCache cache of stores related to tax rate titles
  216. * @return array regions cache populated with regions related to country of imported tax rate
  217. * @throws \Magento\Framework\Exception\LocalizedException
  218. */
  219. protected function _importRate(array $rateData, array $regionsCache, array $storesCache)
  220. {
  221. // data with index 1 must represent country code
  222. $countryCode = $rateData[1];
  223. $country = $this->_countryFactory->create()->loadByCode($countryCode, 'iso2_code');
  224. if (!$country->getId()) {
  225. throw new \Magento\Framework\Exception\LocalizedException(__('Country code is invalid: %1', $countryCode));
  226. }
  227. $regionsCache = $this->_addCountryRegionsToCache($countryCode, $regionsCache);
  228. // data with index 2 must represent region code
  229. $regionCode = $rateData[2];
  230. if (!empty($regionsCache[$countryCode][$regionCode])) {
  231. $regionId = $regionsCache[$countryCode][$regionCode] == '*' ? 0 : $regionsCache[$countryCode][$regionCode];
  232. // data with index 3 must represent postcode
  233. $postCode = empty($rateData[3]) ? null : $rateData[3];
  234. $modelData = [
  235. 'code' => $rateData[0],
  236. 'tax_country_id' => $rateData[1],
  237. 'tax_region_id' => $regionId,
  238. 'tax_postcode' => $postCode,
  239. 'rate' => $rateData[4],
  240. 'zip_is_range' => $rateData[5],
  241. 'zip_from' => $rateData[6],
  242. 'zip_to' => $rateData[7],
  243. ];
  244. // try to load existing rate
  245. /** @var $rateModel \Magento\Tax\Model\Calculation\Rate */
  246. $rateModel = $this->_taxRateFactory->create()->loadByCode($modelData['code']);
  247. $rateModel->addData($modelData);
  248. // compose titles list
  249. $rateTitles = [];
  250. foreach ($storesCache as $fileFieldIndex => $storeId) {
  251. $rateTitles[$storeId] = $rateData[$fileFieldIndex];
  252. }
  253. $rateModel->setTitle($rateTitles);
  254. $rateModel->save();
  255. }
  256. return $regionsCache;
  257. }
  258. /**
  259. * Add regions of the given country to regions cache
  260. *
  261. * @param string $countryCode
  262. * @param array $regionsCache
  263. * @return array
  264. */
  265. protected function _addCountryRegionsToCache($countryCode, array $regionsCache)
  266. {
  267. if (!isset($regionsCache[$countryCode])) {
  268. $regionsCache[$countryCode] = [];
  269. // add 'All Regions' to the list
  270. $regionsCache[$countryCode]['*'] = '*';
  271. $regionCollection = clone $this->_regionCollection;
  272. $regionCollection->addCountryFilter($countryCode);
  273. if ($regionCollection->getSize()) {
  274. foreach ($regionCollection as $region) {
  275. $regionsCache[$countryCode][$region->getCode()] = $region->getRegionId();
  276. }
  277. }
  278. }
  279. return $regionsCache;
  280. }
  281. }