Completed
Push — master ( 70e4e5...5530b2 )
by Joachim
15:31
created

ConsignmentService   B

Complexity

Total Complexity 41

Size/Duplication

Total Lines 374
Duplicated Lines 4.81 %

Coupling/Cohesion

Components 2
Dependencies 12

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
wmc 41
lcom 2
cbo 12
dl 18
loc 374
ccs 0
cts 222
cp 0
rs 8.2769
c 0
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 15 3
C generateReport() 0 50 8
A generateReportFile() 0 15 2
B deliverReport() 0 34 4
C queryBuilder() 0 46 7
A getProductQueryBuilder() 0 17 2
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 configureGenerateReportOptions() 0 9 1
A configureQueryBuilderOptions() 0 15 1
A queryBuilderOptions() 0 10 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\QueryBuilder;
10
use Loevgaard\DandomainConsignment\Entity\Generated\ReportInterface;
11
use Loevgaard\DandomainConsignment\Entity\Report;
12
use Loevgaard\DandomainConsignment\Repository\ReportRepository;
13
use Loevgaard\DandomainConsignmentBundle\Exception\InvalidBarCodeException;
14
use Loevgaard\DandomainConsignmentBundle\Exception\InvalidVendorNumberException;
15
use Loevgaard\DandomainFoundation\Entity\Generated\ManufacturerInterface;
16
use Loevgaard\DandomainStock\Entity\Generated\StockMovementInterface;
17
use Loevgaard\DandomainStock\Entity\StockMovement;
18
use Psr\Log\LoggerInterface;
19
use Psr\Log\NullLogger;
20
use Symfony\Component\OptionsResolver\OptionsResolver;
21
22
abstract class ConsignmentService implements ConsignmentServiceInterface
23
{
24
    /**
25
     * @var EntityManager
26
     */
27
    protected $entityManager;
28
29
    /**
30
     * @var ReportRepository
31
     */
32
    protected $reportRepository;
33
34
    /**
35
     * The directory where report files will be saved
36
     *
37
     * @var string
38
     */
39
    protected $reportDir;
40
41
    /**
42
     * @var LoggerInterface
43
     */
44
    protected $logger;
45
46
    /**
47
     * @var ManufacturerInterface
48
     */
49
    protected $manufacturer;
50
51
    /**
52
     * Contains the included product ids
53
     *
54
     * @var array
55
     */
56
    protected $includedProductIds;
57
58
    public function __construct(ManagerRegistry $managerRegistry, ReportRepository $reportRepository, string $reportDir)
59
    {
60
        $this->entityManager = $managerRegistry->getManager();
61
        $this->reportRepository = $reportRepository;
62
        $this->reportDir = rtrim($reportDir, '/');
63
        $this->logger = new NullLogger();
64
65
        if(!is_dir($this->reportDir)) {
66
            throw new \InvalidArgumentException('The report dir given is not a directory');
67
        }
68
69
        if(!is_writable($this->reportDir)) {
70
            throw new \InvalidArgumentException('The report dir given is not writable');
71
        }
72
    }
73
74
    /**
75
     * @param array $options
76
     * @return ReportInterface
77
     * @throws \Doctrine\ORM\ORMException
78
     */
79
    public function generateReport(array $options = []): ReportInterface
80
    {
81
        // resolve options
82
        $resolver = new OptionsResolver();
83
        $this->configureGenerateReportOptions($resolver);
84
        $options = $resolver->resolve($options);
85
86
        $report = new Report();
87
        $report->setManufacturer($this->manufacturer);
88
89
        $this->reportRepository->persist($report);
90
91
        try {
92
            if($options['valid_bar_codes']) {
93
                $this->validateBarCodes();
94
            }
95
96
            if($options['valid_vendor_numbers']) {
97
                $this->validateVendorNumbers();
98
            }
99
100
            $qb = $this->queryBuilder();
101
102
            /** @var StockMovementInterface[] $stockMovements */
103
            $stockMovements = $qb->getQuery()->getResult();
104
105
            if(!count($stockMovements)) {
106
                throw new \Exception('No stock movements applicable for this report');
107
            }
108
109
            $lastStockMovement = null;
110
            foreach ($stockMovements as $stockMovement) {
111
                $report->addStockMovement($stockMovement);
112
113
                $lastStockMovement = $stockMovement;
114
            }
115
116
            if ($lastStockMovement && $options['update_last_stock_movement']) {
117
                $this->manufacturer->setConsignmentLastStockMovement($lastStockMovement);
118
            }
119
120
            $report->markAsSuccess();
121
        } catch (\Exception $e) {
122
            $report->markAsError($e->getMessage());
123
        }
124
125
        $this->reportRepository->flush();
126
127
        return $report;
128
    }
129
130
    public function generateReportFile(ReportInterface $report, array $options = []): \SplFileObject
131
    {
132
        $file = $this->getFile();
133
134
        foreach ($report->getStockMovements() as $stockMovement) {
135
            $file->fputcsv([
136
                $stockMovement->getQuantity(),
137
                $stockMovement->getProduct()->getBarCodeNumber(),
138
            ]);
139
        }
140
141
        $report->setFile($file);
142
143
        return $file;
144
    }
145
146
    public function deliverReport(ReportInterface $report, array $options = []) : bool
147
    {
148
        if(!$report->isDeliverable()) {
149
            return false;
150
        }
151
152
        $file = $report->getFile();
153
        if(!$file || $file->isFile()) {
154
            $file = $this->generateReportFile($report);
155
        }
156
157
        $this->logger->info('File has been delivered to: '.$file->getPathname());
158
159
        /*
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...
160
        Example of mailing the report
161
        -------
162
        $recipients = ['[email protected]'];
163
164
        $attachment = \Swift_Attachment::fromPath($reportFile->getPathname());
165
        $attachment->setFilename('report-'.$report->getId().'.csv');
166
167
        $message = \Swift_Message::newInstance()
168
            ->attach($attachment)
169
            ->setSubject('Consignment report (id: '.$report->getId().')')
170
            ->setFrom('[email protected]', 'Your Business')
171
            ->setTo($recipients)
172
            ->setBody('See the attached file.', 'text/plain')
173
        ;
174
175
        $this->mailer->send($message);
176
        */
177
178
        return true;
179
    }
180
181
    public function queryBuilder(array $options = [], string $alias = 's'): QueryBuilder
182
    {
183
        // resolve options
184
        $resolver = new OptionsResolver();
185
        $this->configureQueryBuilderOptions($resolver);
186
        $options = $resolver->resolve($options);
187
188
        $includedProductIds = $this->getIncludedProductIds();
189
190
        if (!count($includedProductIds)) {
191
            throw new \RuntimeException('No included product ids. Something is wrong');
192
        }
193
194
        $qb = $this->entityManager->createQueryBuilder();
195
        $qb->select('s, p')
196
            ->from('AppBundle:StockMovement', 's')
197
            ->join('s.product', 'p')
198
            ->andWhere($qb->expr()->in('p.id', ':includedProductIds'))
199
            ->andWhere($qb->expr()->in('s.type', ':stockMovementTypes'))
200
            ->addOrderBy('s.id', 'asc')
201
            ->setParameters([
202
                'includedProductIds' => $includedProductIds,
203
                'stockMovementTypes' => $options['stock_movement_types']
204
            ]);
205
206
        if(!$options['include_complaints']) {
207
            $qb->andWhere('s.complaint = 0');
208
        }
209
210
        if ($options['use_last_stock_movement'] && $this->manufacturer->getConsignmentLastStockMovement()) {
211
            $qb->andWhere($qb->expr()->gt('s.id', ':lastStockMovementId'))
212
                ->setParameter('lastStockMovementId', $this->manufacturer->getConsignmentLastStockMovement()->getId());
213
        }
214
215
        if($options['start_date']) {
216
            $qb->andWhere($qb->expr()->gte('s.createdAt', ':startDate'))
217
                ->setParameter('startDate', $options['start_date']);
218
        }
219
220
        if($options['end_date']) {
221
            $qb->andWhere($qb->expr()->lte('s.createdAt', ':endDate'))
222
                ->setParameter('endDate', $options['end_date']);
223
        }
224
225
        return $qb;
226
    }
227
228
    public function getProductQueryBuilder(string $alias = 'p'): QueryBuilder
229
    {
230
        $excludedProductIds = $this->getExcludedProductIds();
231
232
        $qb = $this->entityManager->createQueryBuilder();
233
        $qb->select($alias)
234
            ->from('Loevgaard\DandomainFoundation\Entity\Product', $alias)
235
            ->where($qb->expr()->isMemberOf(':manufacturer', $alias.'.manufacturers'))
236
            ->setParameter('manufacturer', $this->manufacturer);
237
238
        if(!empty($excludedProductIds)) {
239
            $qb->andWhere($qb->expr()->notIn($alias.'.id', ':excluded'))
240
                ->setParameter('excluded', $excludedProductIds);
241
        }
242
243
        return $qb;
244
    }
245
246
    /**
247
     * This method should return an array of included product ids
248
     * It excludes the excluded product ids, by using the getProductQueryBuilder method
249
     *
250
     * @return array
251
     */
252
    protected function getIncludedProductIds() : array
253
    {
254
        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...
255
            $qb = $this->getProductQueryBuilder();
256
            $qb->select('p.id');
257
258
            $res = $qb->getQuery()->getArrayResult();
259
260
            $this->includedProductIds = array_map(function ($elm) {
261
                return array_values($elm)[0];
262
            }, $res);
263
        }
264
265
        return $this->includedProductIds;
266
    }
267
268
    /**
269
     * This method should return an array of excluded product ids
270
     *
271
     * @return array
272
     */
273
    protected function getExcludedProductIds() : array
274
    {
275
        return [];
276
    }
277
278
    /**
279
     * This is a helper method which takes an array of product numbers and returns their respective product ids
280
     *
281
     * @param array $numbers
282
     * @return array
283
     */
284
    protected function getProductIdsFromProductNumbers(array $numbers) : array
285
    {
286
        $qb = $this->entityManager->createQueryBuilder();
287
        $qb->select('p.id')
288
            ->from('Loevgaard\DandomainFoundation\Entity\Product', 'p')
289
            ->where($qb->expr()->in('p.number', ':numbers'))
290
            ->setParameter('numbers', $numbers);
291
292
        $res = $qb->getQuery()->getArrayResult();
293
294
        return array_map(function ($elm) {
295
            return array_values($elm)[0];
296
        }, $res);
297
    }
298
299
    /**
300
     * @throws InvalidBarCodeException
301
     */
302
    protected function validateBarCodes() : void
303
    {
304
        $qb = $this->queryBuilder();
305
        $qb->andWhere('p.validBarCode = 0');
306
307
        /** @var StockMovementInterface[] $stockMovements */
308
        $stockMovements = $qb->getQuery()->getResult();
309
310
        $c = count($stockMovements);
311
312 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...
313
            $this->logger->emergency('There are '.$c.' stock movements with invalid bar codes');
314
            $productNumbers = [];
315
            foreach ($stockMovements as $stockMovement) {
316
                $productNumbers[] = $stockMovement->getProduct()->getNumber();
317
            }
318
319
            throw new InvalidBarCodeException('Products with invalid bar codes: '.join(', ', $productNumbers), $productNumbers);
320
        }
321
    }
322
323
    /**
324
     * @throws InvalidVendorNumberException
325
     */
326
    protected function validateVendorNumbers() : void
327
    {
328
        $qb = $this->queryBuilder();
329
        $qb->andWhere($qb->expr()->orX(
330
            $qb->expr()->eq('p.vendorNumber', ':empty'),
331
            $qb->expr()->isNull('p.vendorNumber')
332
        ))->setParameter(':empty', '');
333
334
        /** @var StockMovementInterface[] $stockMovements */
335
        $stockMovements = $qb->getQuery()->getResult();
336
337
        $c = count($stockMovements);
338
339 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...
340
            $this->logger->critical('There are '.$c.' stock movements with invalid vendor numbers');
341
            $productNumbers = [];
342
            foreach ($stockMovements as $stockMovement) {
343
                $productNumbers[] = $stockMovement->getProduct()->getNumber();
344
            }
345
346
            throw new InvalidVendorNumberException('Products with invalid vendor numbers: '.join($productNumbers), $productNumbers);
347
        }
348
    }
349
350
    protected function getFile(string $extension = 'csv') : \SplFileObject
351
    {
352
        do {
353
            $filename = $this->reportDir.'/'.uniqid('consignment-', true).'.'.$extension;
354
        } while(file_exists($filename));
355
356
        return new \SplFileObject($filename, 'w+');
357
    }
358
359
    protected function configureGenerateReportOptions(OptionsResolver $resolver) : void
360
    {
361
        $resolver->setDefined($this->queryBuilderOptions());
362
        $resolver->setDefaults([
363
            'valid_bar_codes' => false,
364
            'valid_vendor_numbers' => false,
365
            'update_last_stock_movement' => true,
366
        ]);
367
    }
368
369
    protected function configureQueryBuilderOptions(OptionsResolver $resolver) : void
370
    {
371
        $resolver->setDefined($this->queryBuilderOptions());
372
        $resolver->setDefaults([
373
            'stock_movement_types' => [
374
                StockMovement::TYPE_RETURN,
375
                StockMovement::TYPE_SALE,
376
                StockMovement::TYPE_REGULATION
377
            ],
378
            'include_complaints' => false,
379
            'use_last_stock_movement' => true,
380
            'start_date' => null,
381
            'end_date' => null,
382
        ]);
383
    }
384
385
    protected function queryBuilderOptions() : array
386
    {
387
        return [
388
            'stock_movement_types',
389
            'include_complaints',
390
            'use_last_stock_movement',
391
            'start_date',
392
            'end_date',
393
        ];
394
    }
395
}
396