Passed
Push — master ( 0b78f0...996521 )
by Aimeos
03:47
created

Standard::run()   C

Complexity

Conditions 11
Paths 45

Size

Total Lines 328
Code Lines 60

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 60
nc 45
nop 0
dl 0
loc 328
rs 6.726
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * @license LGPLv3, http://opensource.org/licenses/LGPL-3.0
5
 * @copyright Aimeos (aimeos.org), 2015-2018
6
 * @package Controller
7
 * @subpackage Jobs
8
 */
9
10
11
namespace Aimeos\Controller\Jobs\Product\Import\Csv;
12
13
14
/**
15
 * Job controller for CSV product imports.
16
 *
17
 * @package Controller
18
 * @subpackage Jobs
19
 */
20
class Standard
21
	extends \Aimeos\Controller\Common\Product\Import\Csv\Base
22
	implements \Aimeos\Controller\Jobs\Iface
23
{
24
	private $types = [];
0 ignored issues
show
introduced by
The private property $types is not used, and could be removed.
Loading history...
25
26
27
	/**
28
	 * Returns the localized name of the job.
29
	 *
30
	 * @return string Name of the job
31
	 */
32
	public function getName()
33
	{
34
		return $this->getContext()->getI18n()->dt( 'controller/jobs', 'Product import CSV' );
35
	}
36
37
38
	/**
39
	 * Returns the localized description of the job.
40
	 *
41
	 * @return string Description of the job
42
	 */
43
	public function getDescription()
44
	{
45
		return $this->getContext()->getI18n()->dt( 'controller/jobs', 'Imports new and updates existing products from CSV files' );
46
	}
47
48
49
	/**
50
	 * Executes the job.
51
	 *
52
	 * @throws \Aimeos\Controller\Jobs\Exception If an error occurs
53
	 */
54
	public function run()
55
	{
56
		$total = $errors = 0;
57
		$context = $this->getContext();
58
		$config = $context->getConfig();
59
		$logger = $context->getLogger();
60
		$domains = array( 'attribute', 'media', 'price', 'product', 'product/property', 'text' );
61
		$mappings = $this->getDefaultMapping();
62
63
64
		/** controller/common/product/import/csv/domains
65
		 * List of item domain names that should be retrieved along with the product items
66
		 *
67
		 * For efficient processing, the items associated to the products can be
68
		 * fetched to, minimizing the number of database queries required. To be
69
		 * most effective, the list of item domain names should be used in the
70
		 * mapping configuration too, so the retrieved items will be used during
71
		 * the import.
72
		 *
73
		 * @param array Associative list of MShop item domain names
74
		 * @since 2015.05
75
		 * @category Developer
76
		 * @see controller/common/product/import/csv/mapping
77
		 * @see controller/common/product/import/csv/converter
78
		 * @see controller/common/product/import/csv/max-size
79
		 */
80
		$domains = $config->get( 'controller/common/product/import/csv/domains', $domains );
81
82
		/** controller/jobs/product/import/csv/domains
83
		 * List of item domain names that should be retrieved along with the product items
84
		 *
85
		 * This configuration setting overwrites the shared option
86
		 * "controller/common/product/import/csv/domains" if you need a
87
		 * specific setting for the job controller. Otherwise, you should
88
		 * use the shared option for consistency.
89
		 *
90
		 * @param array Associative list of MShop item domain names
91
		 * @since 2015.05
92
		 * @category Developer
93
		 * @see controller/jobs/product/import/csv/mapping
94
		 * @see controller/jobs/product/import/csv/skip-lines
95
		 * @see controller/jobs/product/import/csv/converter
96
		 * @see controller/jobs/product/import/csv/strict
97
		 * @see controller/jobs/product/import/csv/backup
98
		 * @see controller/common/product/import/csv/max-size
99
		 */
100
		$domains = $config->get( 'controller/jobs/product/import/csv/domains', $domains );
101
102
103
		/** controller/common/product/import/csv/mapping
104
		 * List of mappings between the position in the CSV file and item keys
105
		 *
106
		 * The importer have to know which data is at which position in the CSV
107
		 * file. Therefore, you need to specify a mapping between each position
108
		 * and the MShop domain item key (e.g. "product.code") it represents.
109
		 *
110
		 * You can use all domain item keys which are used in the fromArray()
111
		 * methods of the item classes.
112
		 *
113
		 * These mappings are grouped together by their processor names, which
114
		 * are responsible for importing the data, e.g. all mappings in "item"
115
		 * will be processed by the base product importer while the mappings in
116
		 * "text" will be imported by the text processor.
117
		 *
118
		 * @param array Associative list of processor names and lists of key/position pairs
119
		 * @since 2015.05
120
		 * @category Developer
121
		 * @see controller/common/product/import/csv/domains
122
		 * @see controller/common/product/import/csv/converter
123
		 * @see controller/common/product/import/csv/max-size
124
		 */
125
		$mappings = $config->get( 'controller/common/product/import/csv/mapping', $mappings );
126
127
		/** controller/jobs/product/import/csv/mapping
128
		 * List of mappings between the position in the CSV file and item keys
129
		 *
130
		 * This configuration setting overwrites the shared option
131
		 * "controller/common/product/import/csv/mapping" if you need a
132
		 * specific setting for the job controller. Otherwise, you should
133
		 * use the shared option for consistency.
134
		 *
135
		 * @param array Associative list of processor names and lists of key/position pairs
136
		 * @since 2015.05
137
		 * @category Developer
138
		 * @see controller/jobs/product/import/csv/domains
139
		 * @see controller/jobs/product/import/csv/skip-lines
140
		 * @see controller/jobs/product/import/csv/converter
141
		 * @see controller/jobs/product/import/csv/strict
142
		 * @see controller/jobs/product/import/csv/backup
143
		 * @see controller/common/product/import/csv/max-size
144
		 */
145
		$mappings = $config->get( 'controller/jobs/product/import/csv/mapping', $mappings );
146
147
148
		/** controller/common/product/import/csv/converter
149
		 * List of converter names for the values at the position in the CSV file
150
		 *
151
		 * Not all data in the CSV file is already in the required format. Maybe
152
		 * the text encoding isn't UTF-8, the date is not in ISO format or something
153
		 * similar. In order to convert the data before it's imported, you can
154
		 * specify a list of converter objects that should be applied to the data
155
		 * from the CSV file.
156
		 *
157
		 * To each field in the CSV file, you can apply one or more converters,
158
		 * e.g. to encode a Latin text to UTF8 for the second CSV field:
159
		 *
160
		 *  array( 1 => 'Text/LatinUTF8' )
161
		 *
162
		 * Similarly, you can also apply several converters at once to the same
163
		 * field:
164
		 *
165
		 *  array( 1 => array( 'Text/LatinUTF8', 'DateTime/EnglishISO' ) )
166
		 *
167
		 * It would convert the data of the second CSV field first to UTF-8 and
168
		 * afterwards try to translate it to an ISO date format.
169
		 *
170
		 * The available converter objects are named "\Aimeos\MW\Convert\<type>_<conversion>"
171
		 * where <type> is the data type and <conversion> the way of the conversion.
172
		 * In the configuration, the type and conversion must be separated by a
173
		 * slash (<type>/<conversion>).
174
		 *
175
		 * '''Note:''' Keep in mind that the position of the CSV fields start at
176
		 * zero (0). If you only need to convert a few fields, you don't have to
177
		 * configure all fields. Only specify the positions in the array you
178
		 * really need!
179
		 *
180
		 * @param array Associative list of position/converter name (or list of names) pairs
181
		 * @since 2015.05
182
		 * @category Developer
183
		 * @see controller/common/product/import/csv/domains
184
		 * @see controller/common/product/import/csv/mapping
185
		 * @see controller/common/product/import/csv/max-size
186
		 */
187
		$converters = $config->get( 'controller/common/product/import/csv/converter', [] );
188
189
		/** controller/jobs/product/import/csv/converter
190
		 * List of converter names for the values at the position in the CSV file
191
		 *
192
		 * This configuration setting overwrites the shared option
193
		 * "controller/common/product/import/csv/converter" if you need a
194
		 * specific setting for the job controller. Otherwise, you should
195
		 * use the shared option for consistency.
196
		 *
197
		 * @param array Associative list of position/converter name (or list of names) pairs
198
		 * @since 2015.05
199
		 * @category Developer
200
		 * @see controller/jobs/product/import/csv/domains
201
		 * @see controller/jobs/product/import/csv/mapping
202
		 * @see controller/jobs/product/import/csv/skip-lines
203
		 * @see controller/jobs/product/import/csv/strict
204
		 * @see controller/jobs/product/import/csv/backup
205
		 * @see controller/common/product/import/csv/max-size
206
		 */
207
		$converters = $config->get( 'controller/jobs/product/import/csv/converter', $converters );
208
209
210
		/** controller/common/product/import/csv/max-size
211
		 * Maximum number of CSV rows to import at once
212
		 *
213
		 * It's more efficient to read and import more than one row at a time
214
		 * to speed up the import. Usually, the bigger the chunk that is imported
215
		 * at once, the less time the importer will need. The downside is that
216
		 * the amount of memory required by the import process will increase as
217
		 * well. Therefore, it's a trade-off between memory consumption and
218
		 * import speed.
219
		 *
220
		 * @param integer Number of rows
221
		 * @since 2015.05
222
		 * @category Developer
223
		 * @see controller/common/product/import/csv/domains
224
		 * @see controller/common/product/import/csv/mapping
225
		 * @see controller/common/product/import/csv/converter
226
		 */
227
		$maxcnt = (int) $config->get( 'controller/common/product/import/csv/max-size', 1000 );
228
229
230
		/** controller/jobs/product/import/csv/skip-lines
231
		 * Number of rows skipped in front of each CSV files
232
		 *
233
		 * Some CSV files contain header information describing the content of
234
		 * the column values. These data is for informational purpose only and
235
		 * can't be imported into the database. Using this option, you can
236
		 * define the number of lines that should be left out before the import
237
		 * begins.
238
		 *
239
		 * @param integer Number of rows
240
		 * @since 2015.08
241
		 * @category Developer
242
		 * @see controller/jobs/product/import/csv/domains
243
		 * @see controller/jobs/product/import/csv/mapping
244
		 * @see controller/jobs/product/import/csv/converter
245
		 * @see controller/jobs/product/import/csv/strict
246
		 * @see controller/jobs/product/import/csv/backup
247
		 * @see controller/common/product/import/csv/max-size
248
		 */
249
		$skiplines = (int) $config->get( 'controller/jobs/product/import/csv/skip-lines', 0 );
250
251
252
		/** controller/jobs/product/import/csv/strict
253
		 * Log all columns from the file that are not mapped and therefore not imported
254
		 *
255
		 * Depending on the mapping, there can be more columns in the CSV file
256
		 * than those which will be imported. This can be by purpose if you want
257
		 * to import only selected columns or if you've missed to configure one
258
		 * or more columns. This configuration option will log all columns that
259
		 * have not been imported if set to true. Otherwise, the left over fields
260
		 * in the imported line will be silently ignored.
261
		 *
262
		 * @param boolen True if not imported columns should be logged, false if not
263
		 * @since 2015.08
264
		 * @category User
265
		 * @category Developer
266
		 * @see controller/jobs/product/import/csv/domains
267
		 * @see controller/jobs/product/import/csv/mapping
268
		 * @see controller/jobs/product/import/csv/skip-lines
269
		 * @see controller/jobs/product/import/csv/converter
270
		 * @see controller/jobs/product/import/csv/backup
271
		 * @see controller/common/product/import/csv/max-size
272
		 */
273
		$strict = (bool) $config->get( 'controller/jobs/product/import/csv/strict', true );
274
275
276
		/** controller/jobs/product/import/csv/backup
277
		 * Name of the backup for sucessfully imported files
278
		 *
279
		 * After a CSV file was imported successfully, you can move it to another
280
		 * location, so it won't be imported again and isn't overwritten by the
281
		 * next file that is stored at the same location in the file system.
282
		 *
283
		 * You should use an absolute path to be sure but can be relative path
284
		 * if you absolutely know from where the job will be executed from. The
285
		 * name of the new backup location can contain placeholders understood
286
		 * by the PHP strftime() function to create dynamic paths, e.g. "backup/%Y-%m-%d"
287
		 * which would create "backup/2000-01-01". For more information about the
288
		 * strftime() placeholders, please have a look into the PHP documentation of
289
		 * the {@link http://php.net/manual/en/function.strftime.php strftime() function}.
290
		 *
291
		 * '''Note:''' If no backup name is configured, the file or directory
292
		 * won't be moved away. Please make also sure that the parent directory
293
		 * and the new directory are writable so the file or directory could be
294
		 * moved.
295
		 *
296
		 * @param integer Name of the backup file, optionally with date/time placeholders
297
		 * @since 2015.05
298
		 * @category Developer
299
		 * @see controller/jobs/product/import/csv/domains
300
		 * @see controller/jobs/product/import/csv/mapping
301
		 * @see controller/jobs/product/import/csv/skip-lines
302
		 * @see controller/jobs/product/import/csv/converter
303
		 * @see controller/jobs/product/import/csv/strict
304
		 * @see controller/common/product/import/csv/max-size
305
		 */
306
		$backup = $config->get( 'controller/jobs/product/import/csv/backup' );
307
308
309
		if( !isset( $mappings['item'] ) || !is_array( $mappings['item'] ) )
310
		{
311
			$msg = sprintf( 'Required mapping key "%1$s" is missing or contains no array', 'item' );
312
			throw new \Aimeos\Controller\Jobs\Exception( $msg );
313
		}
314
315
		try
316
		{
317
			$types = [];
318
			$procMappings = $mappings;
319
			unset( $procMappings['item'] );
320
321
			$manager = \Aimeos\MShop::create( $context, 'product/type' );
322
			$search = $manager->createSearch()->setSlice( 0, 0x7fffffff );
323
324
			foreach( $manager->searchItems( $search ) as $item ) {
325
				$types[$item->getCode()] = $item->getCode();
326
			}
327
328
			$codePos = $this->getCodePosition( $mappings['item'] );
329
			$convlist = $this->getConverterList( $converters );
330
			$processor = $this->getProcessors( $procMappings );
331
			$container = $this->getContainer();
332
			$path = $container->getName();
333
334
			$msg = sprintf( 'Started product import from "%1$s" (%2$s)', $path, __CLASS__ );
335
			$logger->log( $msg, \Aimeos\MW\Logger\Base::NOTICE );
336
337
			foreach( $container as $content )
338
			{
339
				$name = $content->getName();
340
341
				for( $i = 0; $i < $skiplines; $i++ ) {
342
					$content->next();
343
				}
344
345
				while( ( $data = $this->getData( $content, $maxcnt, $codePos ) ) !== [] )
346
				{
347
					$data = $this->convertData( $convlist, $data );
348
					$products = $this->getProducts( array_keys( $data ), $domains );
349
					$errcnt = $this->import( $products, $data, $mappings['item'], $types, $processor, $strict );
350
					$chunkcnt = count( $data );
351
352
					$msg = 'Imported product lines from "%1$s": %2$d/%3$d (%4$s)';
353
					$logger->log( sprintf( $msg, $name, $chunkcnt - $errcnt, $chunkcnt, __CLASS__ ), \Aimeos\MW\Logger\Base::NOTICE );
354
355
					$errors += $errcnt;
356
					$total += $chunkcnt;
357
					unset( $products, $data );
358
				}
359
			}
360
361
			$container->close();
362
		}
363
		catch( \Exception $e )
364
		{
365
			$logger->log( 'Product import error: ' . $e->getMessage() );
366
			$logger->log( $e->getTraceAsString() );
367
368
			throw new \Aimeos\Controller\Jobs\Exception( $e->getMessage() );
369
		}
370
371
		$msg = 'Finished product import from "%1$s": %2$d successful, %3$s errors, %4$s total (%5$s)';
372
		$logger->log( sprintf( $msg, $path, $total - $errors, $errors, $total, __CLASS__ ), \Aimeos\MW\Logger\Base::NOTICE );
373
374
		if( $errors > 0 )
375
		{
376
			$msg = sprintf( 'Invalid product lines in "%1$s": %2$d/%3$d', $path, $errors, $total );
377
			throw new \Aimeos\Controller\Jobs\Exception( $msg );
378
		}
379
380
		if( !empty( $backup ) && @rename( $path, strftime( $backup ) ) === false ) {
381
			throw new \Aimeos\Controller\Jobs\Exception( sprintf( 'Unable to move imported file' ) );
382
		}
383
	}
384
385
386
	/**
387
	 * Returns the position of the "product.code" column from the product item mapping
388
	 *
389
	 * @param array $mapping Mapping of the "item" columns with position as key and code as value
390
	 * @return integer Position of the "product.code" column
391
	 * @throws \Aimeos\Controller\Jobs\Exception If no mapping for "product.code" is found
392
	 */
393
	protected function getCodePosition( array $mapping )
394
	{
395
		foreach( $mapping as $pos => $key )
396
		{
397
			if( $key === 'product.code' ) {
398
				return $pos;
399
			}
400
		}
401
402
		throw new \Aimeos\Controller\Jobs\Exception( sprintf( 'No "product.code" column in CSV mapping found' ) );
403
	}
404
405
406
	/**
407
	 * Opens and returns the container which includes the product data
408
	 *
409
	 * @return \Aimeos\MW\Container\Iface Container object
410
	 */
411
	protected function getContainer()
412
	{
413
		$config = $this->getContext()->getConfig();
414
415
		/** controller/jobs/product/import/csv/location
416
		 * File or directory where the content is stored which should be imported
417
		 *
418
		 * You need to configure the file or directory that acts as container
419
		 * for the CSV files that should be imported. It should be an absolute
420
		 * path to be sure but can be relative path if you absolutely know from
421
		 * where the job will be executed from.
422
		 *
423
		 * The path can point to any supported container format as long as the
424
		 * content is in CSV format, e.g.
425
		 * * Directory container / CSV file
426
		 * * Zip container / compressed CSV file
427
		 * * PHPExcel container / PHPExcel sheet
428
		 *
429
		 * @param string Absolute file or directory path
430
		 * @since 2015.05
431
		 * @category Developer
432
		 * @category User
433
		 * @see controller/jobs/product/import/csv/container/type
434
		 * @see controller/jobs/product/import/csv/container/content
435
		 * @see controller/jobs/product/import/csv/container/options
436
		 */
437
		$location = $config->get( 'controller/jobs/product/import/csv/location' );
438
439
		/** controller/jobs/product/import/csv/container/type
440
		 * Nave of the container type to read the data from
441
		 *
442
		 * The container type tells the importer how it should retrieve the data.
443
		 * There are currently three container types that support the necessary
444
		 * CSV content:
445
		 * * Directory
446
		 * * Zip
447
		 * * PHPExcel
448
		 *
449
		 * '''Note:''' For the PHPExcel container, you need to install the
450
		 * "ai-container" extension.
451
		 *
452
		 * @param string Container type name
453
		 * @since 2015.05
454
		 * @category Developer
455
		 * @category User
456
		 * @see controller/jobs/product/import/csv/location
457
		 * @see controller/jobs/product/import/csv/container/content
458
		 * @see controller/jobs/product/import/csv/container/options
459
		 */
460
		$container = $config->get( 'controller/jobs/product/import/csv/container/type', 'Directory' );
461
462
		/** controller/jobs/product/import/csv/container/content
463
		 * Name of the content type inside the container to read the data from
464
		 *
465
		 * The content type must always be a CSV-like format and there are
466
		 * currently two format types that are supported:
467
		 * * CSV
468
		 * * PHPExcel
469
		 *
470
		 * '''Note:''' for the PHPExcel content type, you need to install the
471
		 * "ai-container" extension.
472
		 *
473
		 * @param array Content type name
474
		 * @since 2015.05
475
		 * @category Developer
476
		 * @category User
477
		 * @see controller/jobs/product/import/csv/location
478
		 * @see controller/jobs/product/import/csv/container/type
479
		 * @see controller/jobs/product/import/csv/container/options
480
		 */
481
		$content = $config->get( 'controller/jobs/product/import/csv/container/content', 'CSV' );
482
483
		/** controller/jobs/product/import/csv/container/options
484
		 * List of file container options for the product import files
485
		 *
486
		 * Some container/content type allow you to hand over additional settings
487
		 * for configuration. Please have a look at the article about
488
		 * {@link http://aimeos.org/docs/Developers/Utility/Create_and_read_files container/content files}
489
		 * for more information.
490
		 *
491
		 * @param array Associative list of option name/value pairs
492
		 * @since 2015.05
493
		 * @category Developer
494
		 * @category User
495
		 * @see controller/jobs/product/import/csv/location
496
		 * @see controller/jobs/product/import/csv/container/content
497
		 * @see controller/jobs/product/import/csv/container/type
498
		 */
499
		$options = $config->get( 'controller/jobs/product/import/csv/container/options', [] );
500
501
		if( $location === null )
502
		{
503
			$msg = sprintf( 'Required configuration for "%1$s" is missing', 'controller/jobs/product/import/csv/location' );
504
			throw new \Aimeos\Controller\Jobs\Exception( $msg );
505
		}
506
507
		return \Aimeos\MW\Container\Factory::getContainer( $location, $container, $content, $options );
508
	}
509
510
511
	/**
512
	 * Imports the CSV data and creates new products or updates existing ones
513
	 *
514
	 * @param array $products List of products items implementing \Aimeos\MShop\Product\Item\Iface
515
	 * @param array $data Associative list of import data as index/value pairs
516
	 * @param array $mapping Associative list of positions and domain item keys
517
	 * @param array $types List of allowed product type codes
518
	 * @param \Aimeos\Controller\Common\Product\Import\Csv\Processor\Iface $processor Processor object
519
	 * @param boolean $strict Log columns not mapped or silently ignore them
520
	 * @return integer Number of products that couldn't be imported
521
	 * @throws \Aimeos\Controller\Jobs\Exception
522
	 */
523
	protected function import( array $products, array $data, array $mapping, array $types,
524
		\Aimeos\Controller\Common\Product\Import\Csv\Processor\Iface $processor, $strict )
525
	{
526
		$items = [];
527
		$errors = 0;
528
		$context = $this->getContext();
529
		$manager = \Aimeos\MShop::create( $context, 'product' );
530
		$indexManager = \Aimeos\MShop::create( $context, 'index' );
531
532
		foreach( $data as $code => $list )
533
		{
534
			$manager->begin();
535
536
			try
537
			{
538
				$code = trim( $code );
539
540
				if( isset( $products[$code] ) ) {
541
					$product = $products[$code];
542
				} else {
543
					$product = $manager->createItem();
544
				}
545
546
				$map = $this->getMappedChunk( $list, $mapping );
547
548
				if( isset( $map[0] ) )
549
				{
550
					$map = $map[0]; // there can only be one chunk for the base product data
551
					$map['product.type'] = $this->getValue( $map, 'product.type', 'default' );
552
553
					if( !in_array( $map['product.type'], $types ) )
554
					{
555
						$msg = sprintf( 'Invalid product type "%1$s"', $map['product.type'] );
556
						throw new \Aimeos\Controller\Jobs\Exception( $msg );
557
					}
558
559
					$product = $manager->saveItem( $product->fromArray( $map ) );
560
561
					$list = $processor->process( $product, $list );
562
563
					$product = $manager->saveItem( $product );
564
					$items[$product->getId()] = $product;
565
				}
566
567
				$manager->commit();
568
			}
569
			catch( \Exception $e )
570
			{
571
				$manager->rollback();
572
573
				$msg = sprintf( 'Unable to import product with code "%1$s": %2$s', $code, $e->getMessage() );
574
				$context->getLogger()->log( $msg );
575
576
				$errors++;
577
			}
578
579
			if( $strict && !empty( $list ) ) {
580
				$context->getLogger()->log( 'Not imported: ' . print_r( $list, true ) );
581
			}
582
		}
583
584
		$indexManager->rebuildIndex( $items );
585
586
		return $errors;
587
	}
588
}
589