ConsignmentService   B
last analyzed

Complexity

Total Complexity 41

Size/Duplication

Total Lines 417
Duplicated Lines 4.32 %

Coupling/Cohesion

Components 1
Dependencies 14

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
wmc 41
lcom 1
cbo 14
dl 18
loc 417
ccs 0
cts 220
cp 0
rs 8.2769
c 0
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 3
C generateReport() 0 50 8
A generateReportFile() 0 17 2
B deliverReport() 0 36 4
C queryBuilder() 0 46 7
A getProductQueryBuilder() 0 17 2
A setLogger() 0 6 1
A setManufacturer() 0 6 1
A getIncludedProductIds() 0 15 2
A getExcludedProductIds() 0 4 1
A getProductIdsFromProductNumbers() 0 14 1
A validateBarCodes() 9 20 3
A validateVendorNumbers() 9 23 3
A getFile() 0 8 2
A configureOptions() 0 17 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like ConsignmentService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ConsignmentService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Loevgaard\DandomainConsignmentBundle\ConsignmentService;
6
7
use Doctrine\Common\Persistence\ManagerRegistry;
8
use Doctrine\ORM\EntityManager;
9
use Doctrine\ORM\OptimisticLockException;
10
use Doctrine\ORM\ORMException;
11
use Doctrine\ORM\QueryBuilder;
12
use Loevgaard\DandomainConsignment\Entity\Generated\ReportInterface;
13
use Loevgaard\DandomainConsignment\Entity\Report;
14
use Loevgaard\DandomainConsignment\Repository\ReportRepository;
15
use Loevgaard\DandomainConsignmentBundle\Event\ReportNotDeliveredEvent;
16
use Loevgaard\DandomainConsignmentBundle\Exception\InvalidBarCodeException;
17
use Loevgaard\DandomainConsignmentBundle\Exception\InvalidVendorNumberException;
18
use Loevgaard\DandomainFoundation\Entity\Generated\ManufacturerInterface;
19
use Loevgaard\DandomainStock\Entity\Generated\StockMovementInterface;
20
use Loevgaard\DandomainStock\Entity\StockMovement;
21
use Psr\Log\LoggerInterface;
22
use Psr\Log\NullLogger;
23
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
24
use Symfony\Component\OptionsResolver\OptionsResolver;
25
26
abstract class ConsignmentService implements ConsignmentServiceInterface
27
{
28
    /**
29
     * @var EntityManager
30
     */
31
    protected $entityManager;
32
33
    /**
34
     * @var ReportRepository
35
     */
36
    protected $reportRepository;
37
38
    /**
39
     * @var EventDispatcherInterface
40
     */
41
    protected $eventDispatcher;
42
43
    /**
44
     * The directory where report files will be saved.
45
     *
46
     * @var string
47
     */
48
    protected $reportDir;
49
50
    /**
51
     * @var LoggerInterface
52
     */
53
    protected $logger;
54
55
    /**
56
     * @var ManufacturerInterface
57
     */
58
    protected $manufacturer;
59
60
    /**
61
     * Contains the included product ids.
62
     *
63
     * @var array
64
     */
65
    protected $includedProductIds;
66
67
    /**
68
     * Contains the excluded product ids.
69
     *
70
     * @var array
71
     */
72
    protected $excludedProductIds;
73
74
    public function __construct(ManagerRegistry $managerRegistry, ReportRepository $reportRepository, EventDispatcherInterface $eventDispatcher, string $reportDir)
75
    {
76
        $this->entityManager = $managerRegistry->getManager();
77
        $this->reportRepository = $reportRepository;
78
        $this->eventDispatcher = $eventDispatcher;
79
        $this->reportDir = rtrim($reportDir, '/');
80
        $this->logger = new NullLogger();
81
82
        if (!is_dir($this->reportDir)) {
83
            throw new \InvalidArgumentException('The report dir given is not a directory');
84
        }
85
86
        if (!is_writable($this->reportDir)) {
87
            throw new \InvalidArgumentException('The report dir given is not writable');
88
        }
89
    }
90
91
    /**
92
     * @param array $options
93
     *
94
     * @return ReportInterface
95
     *
96
     * @throws ORMException
97
     */
98
    public function generateReport(array $options = []): ReportInterface
99
    {
100
        // resolve options
101
        $resolver = new OptionsResolver();
102
        $this->configureOptions($resolver);
103
        $options = $resolver->resolve($options);
104
105
        $report = new Report();
106
        $report->setManufacturer($this->manufacturer);
107
108
        $this->reportRepository->persist($report);
109
110
        try {
111
            if ($options['valid_bar_codes']) {
112
                $this->validateBarCodes();
113
            }
114
115
            if ($options['valid_vendor_numbers']) {
116
                $this->validateVendorNumbers();
117
            }
118
119
            $qb = $this->queryBuilder($options);
120
121
            /** @var StockMovementInterface[] $stockMovements */
122
            $stockMovements = $qb->getQuery()->getResult();
123
124
            if (!count($stockMovements)) {
125
                throw new \Exception('No stock movements applicable for this report');
126
            }
127
128
            $lastStockMovement = null;
129
            foreach ($stockMovements as $stockMovement) {
130
                $report->addStockMovement($stockMovement);
131
132
                $lastStockMovement = $stockMovement;
133
            }
134
135
            if ($lastStockMovement && $options['update_last_stock_movement']) {
136
                $this->manufacturer->setConsignmentLastStockMovement($lastStockMovement);
137
            }
138
139
            $report->markAsSuccess();
140
        } catch (\Exception $e) {
141
            $report->markAsError($e->getMessage());
142
        }
143
144
        $this->reportRepository->flush();
145
146
        return $report;
147
    }
148
149
    /**
150
     * @param ReportInterface $report
151
     * @param array           $options
152
     *
153
     * @return \SplFileObject
154
     *
155
     * @throws ORMException
156
     * @throws OptimisticLockException
157
     */
158
    public function generateReportFile(ReportInterface $report, array $options = []): \SplFileObject
159
    {
160
        $file = $this->getFile();
161
162
        foreach ($report->getStockMovements() as $stockMovement) {
163
            $file->fputcsv([
164
                $stockMovement->getQuantity(),
165
                $stockMovement->getProduct()->getBarCodeNumber(),
166
            ]);
167
        }
168
169
        $report->setFile($file);
170
171
        $this->reportRepository->flush();
172
173
        return $file;
174
    }
175
176
    /**
177
     * @param ReportInterface $report
178
     * @param array           $options
179
     *
180
     * @return bool
181
     *
182
     * @throws ORMException
183
     * @throws OptimisticLockException
184
     */
185
    public function deliverReport(ReportInterface $report, array $options = []): bool
186
    {
187
        if (!$report->isDeliverable()) {
188
            $this->eventDispatcher->dispatch(ReportNotDeliveredEvent::NAME, new ReportNotDeliveredEvent($report));
189
190
            return false;
191
        }
192
193
        $file = $report->getFile();
194
        if (!$file || !$file->isFile()) {
195
            $file = $this->generateReportFile($report);
196
        }
197
198
        $this->logger->info('File has been delivered to: '.$file->getPathname());
199
200
        /*
0 ignored issues
show
Unused Code Comprehensibility introduced by
55% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
201
        Example of mailing the report
202
        -------
203
        $recipients = ['[email protected]'];
204
205
        $attachment = \Swift_Attachment::fromPath($file->getPathname());
206
        $attachment->setFilename('report-'.$report->getId().'.csv');
207
208
        $message = \Swift_Message::newInstance()
209
            ->attach($attachment)
210
            ->setSubject('Consignment report (id: '.$report->getId().')')
211
            ->setFrom('[email protected]', 'Your Business')
212
            ->setTo($recipients)
213
            ->setBody('See the attached file.', 'text/plain')
214
        ;
215
216
        $this->mailer->send($message);
217
        */
218
219
        return true;
220
    }
221
222
    public function queryBuilder(array $options = [], string $alias = 's'): QueryBuilder
223
    {
224
        // resolve options
225
        $resolver = new OptionsResolver();
226
        $this->configureOptions($resolver);
227
        $options = $resolver->resolve($options);
228
229
        $includedProductIds = $this->getIncludedProductIds();
230
231
        if (!count($includedProductIds)) {
232
            throw new \RuntimeException('No included product ids. Something is wrong');
233
        }
234
235
        $qb = $this->entityManager->createQueryBuilder();
236
        $qb->select('s, p')
237
            ->from('Loevgaard\DandomainStock\Entity\StockMovement', 's')
238
            ->join('s.product', 'p')
239
            ->andWhere($qb->expr()->in('p.id', ':includedProductIds'))
240
            ->andWhere($qb->expr()->in('s.type', ':stockMovementTypes'))
241
            ->addOrderBy('s.id', 'asc')
242
            ->setParameters([
243
                'includedProductIds' => $includedProductIds,
244
                'stockMovementTypes' => $options['stock_movement_types'],
245
            ]);
246
247
        if (!$options['include_complaints']) {
248
            $qb->andWhere('s.complaint = 0');
249
        }
250
251
        if ($options['use_last_stock_movement'] && $this->manufacturer->getConsignmentLastStockMovement()) {
252
            $qb->andWhere($qb->expr()->gt('s.id', ':lastStockMovementId'))
253
                ->setParameter('lastStockMovementId', $this->manufacturer->getConsignmentLastStockMovement()->getId());
254
        }
255
256
        if ($options['start_date']) {
257
            $qb->andWhere($qb->expr()->gte('s.createdAt', ':startDate'))
258
                ->setParameter('startDate', $options['start_date']);
259
        }
260
261
        if ($options['end_date']) {
262
            $qb->andWhere($qb->expr()->lte('s.createdAt', ':endDate'))
263
                ->setParameter('endDate', $options['end_date']);
264
        }
265
266
        return $qb;
267
    }
268
269
    public function getProductQueryBuilder(string $alias = 'p'): QueryBuilder
270
    {
271
        $excludedProductIds = $this->getExcludedProductIds();
272
273
        $qb = $this->entityManager->createQueryBuilder();
274
        $qb->select($alias)
275
            ->from('Loevgaard\DandomainFoundation\Entity\Product', $alias)
276
            ->where($qb->expr()->isMemberOf(':manufacturer', $alias.'.manufacturers'))
277
            ->setParameter('manufacturer', $this->manufacturer);
278
279
        if (!empty($excludedProductIds)) {
280
            $qb->andWhere($qb->expr()->notIn($alias.'.id', ':excluded'))
281
                ->setParameter('excluded', $excludedProductIds);
282
        }
283
284
        return $qb;
285
    }
286
287
    /**
288
     * @param LoggerInterface $logger
289
     *
290
     * @return ConsignmentServiceInterface
291
     */
292
    public function setLogger(LoggerInterface $logger): ConsignmentServiceInterface
293
    {
294
        $this->logger = $logger;
295
296
        return $this;
297
    }
298
299
    /**
300
     * @param ManufacturerInterface $manufacturer
301
     *
302
     * @return ConsignmentServiceInterface
303
     */
304
    public function setManufacturer(ManufacturerInterface $manufacturer): ConsignmentServiceInterface
305
    {
306
        $this->manufacturer = $manufacturer;
307
308
        return $this;
309
    }
310
311
    /**
312
     * This method should return an array of included product ids
313
     * It excludes the excluded product ids, by using the getProductQueryBuilder method.
314
     *
315
     * @return array
316
     */
317
    protected function getIncludedProductIds(): array
318
    {
319
        if (!$this->includedProductIds) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->includedProductIds of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
320
            $qb = $this->getProductQueryBuilder();
321
            $qb->select('p.id');
322
323
            $res = $qb->getQuery()->getArrayResult();
324
325
            $this->includedProductIds = array_map(function ($elm) {
326
                return array_values($elm)[0];
327
            }, $res);
328
        }
329
330
        return $this->includedProductIds;
331
    }
332
333
    /**
334
     * This method should return an array of excluded product ids.
335
     *
336
     * @return array
337
     */
338
    protected function getExcludedProductIds(): array
339
    {
340
        return [];
341
    }
342
343
    /**
344
     * This is a helper method which takes an array of product numbers and returns their respective product ids.
345
     *
346
     * @param array $numbers
347
     *
348
     * @return array
349
     */
350
    protected function getProductIdsFromProductNumbers(array $numbers): array
351
    {
352
        $qb = $this->entityManager->createQueryBuilder();
353
        $qb->select('p.id')
354
            ->from('Loevgaard\DandomainFoundation\Entity\Product', 'p')
355
            ->where($qb->expr()->in('p.number', ':numbers'))
356
            ->setParameter('numbers', $numbers);
357
358
        $res = $qb->getQuery()->getArrayResult();
359
360
        return array_map(function ($elm) {
361
            return array_values($elm)[0];
362
        }, $res);
363
    }
364
365
    /**
366
     * @throws InvalidBarCodeException
367
     */
368
    protected function validateBarCodes(): void
369
    {
370
        $qb = $this->queryBuilder();
371
        $qb->andWhere('p.validBarCode = 0');
372
373
        /** @var StockMovementInterface[] $stockMovements */
374
        $stockMovements = $qb->getQuery()->getResult();
375
376
        $c = count($stockMovements);
377
378 View Code Duplication
        if ($c) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
379
            $this->logger->emergency('There are '.$c.' stock movements with invalid bar codes');
380
            $productNumbers = [];
381
            foreach ($stockMovements as $stockMovement) {
382
                $productNumbers[] = $stockMovement->getProduct()->getNumber();
383
            }
384
385
            throw new InvalidBarCodeException('Products with invalid bar codes: '.join(', ', $productNumbers), $productNumbers);
386
        }
387
    }
388
389
    /**
390
     * @throws InvalidVendorNumberException
391
     */
392
    protected function validateVendorNumbers(): void
393
    {
394
        $qb = $this->queryBuilder();
395
        $qb->andWhere($qb->expr()->orX(
396
            $qb->expr()->eq('p.vendorNumber', ':empty'),
397
            $qb->expr()->isNull('p.vendorNumber')
398
        ))->setParameter(':empty', '');
399
400
        /** @var StockMovementInterface[] $stockMovements */
401
        $stockMovements = $qb->getQuery()->getResult();
402
403
        $c = count($stockMovements);
404
405 View Code Duplication
        if ($c) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
406
            $this->logger->critical('There are '.$c.' stock movements with invalid vendor numbers');
407
            $productNumbers = [];
408
            foreach ($stockMovements as $stockMovement) {
409
                $productNumbers[] = $stockMovement->getProduct()->getNumber();
410
            }
411
412
            throw new InvalidVendorNumberException('Products with invalid vendor numbers: '.join($productNumbers), $productNumbers);
413
        }
414
    }
415
416
    protected function getFile(string $extension = 'csv'): \SplFileObject
417
    {
418
        do {
419
            $filename = $this->reportDir.'/'.uniqid('consignment-', true).'.'.$extension;
420
        } while (file_exists($filename));
421
422
        return new \SplFileObject($filename, 'w+');
423
    }
424
425
    protected function configureOptions(OptionsResolver $resolver): void
426
    {
427
        $resolver->setDefaults([
428
            'valid_bar_codes' => false,
429
            'valid_vendor_numbers' => false,
430
            'update_last_stock_movement' => true,
431
            'stock_movement_types' => [
432
                StockMovement::TYPE_RETURN,
433
                StockMovement::TYPE_SALE,
434
                StockMovement::TYPE_REGULATION,
435
            ],
436
            'include_complaints' => false,
437
            'use_last_stock_movement' => true,
438
            'start_date' => null,
439
            'end_date' => null,
440
        ]);
441
    }
442
}
443