Passed
Push — master ( 94b7c6...9cdec7 )
by Christian
15:16 queued 12s
created

PromotionCodeService::generateIndividualCodes()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 28
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 13
c 1
b 0
f 0
nc 4
nop 3
dl 0
loc 28
rs 9.5222
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Checkout\Promotion\Util;
4
5
use Shopware\Core\Checkout\Promotion\Exception\PatternAlreadyInUseException;
6
use Shopware\Core\Checkout\Promotion\Exception\PatternNotComplexEnoughException;
7
use Shopware\Core\Checkout\Promotion\PromotionEntity;
8
use Shopware\Core\Framework\Context;
9
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
10
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
11
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
12
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
13
14
class PromotionCodeService
15
{
16
    public const PROMOTION_PATTERN_REGEX = '/(?<prefix>[^%]*)(?<replacement>(%[sd])+)(?<suffix>.*)/';
17
    public const CODE_COMPLEXITY_FACTOR = 0.5;
18
19
    /**
20
     * @var EntityRepositoryInterface
21
     */
22
    private $individualCodesRepository;
23
24
    /**
25
     * @var EntityRepositoryInterface
26
     */
27
    private $promotionRepository;
28
29
    public function __construct(EntityRepositoryInterface $promotionRepository, EntityRepositoryInterface $individualCodesRepository)
30
    {
31
        $this->promotionRepository = $promotionRepository;
32
        $this->individualCodesRepository = $individualCodesRepository;
33
    }
34
35
    public function getFixedCode(): string
36
    {
37
        $pattern = \implode('', \array_fill(0, 4, '%s%d'));
38
39
        return $this->generateIndividualCodes($pattern, 1)[0];
40
    }
41
42
    public function getPreview(string $pattern): string
43
    {
44
        return $this->generateIndividualCodes($pattern, 1)[0];
45
    }
46
47
    /**
48
     * @throws PatternNotComplexEnoughException
49
     *
50
     * @return array<string>
51
     */
52
    public function generateIndividualCodes(string $pattern, int $amount, array $codeBlacklist = []): array
53
    {
54
        if ($amount < 1) {
55
            return [];
56
        }
57
58
        $codePattern = $this->splitPattern($pattern);
59
        $blacklistCount = \count($codeBlacklist);
60
61
        /*
62
         * This condition ensures a fundamental randomness to the generated codes in ratio to all possibilities, which
63
         * also minimizes the number of retries. Therefore, the CODE_COMPLEXITY_FACTOR is the worst-case-scenario
64
         * probability to find a new unique promotion code.
65
         */
66
        if ($this->calculatePossibilites($codePattern['replacementString']) * self::CODE_COMPLEXITY_FACTOR < ($amount + $blacklistCount)) {
67
            throw new PatternNotComplexEnoughException();
68
        }
69
70
        $codes = $codeBlacklist;
71
        do {
72
            $codes[] = $this->generateCode($codePattern);
73
74
            if (\count($codes) >= $amount + $blacklistCount) {
75
                $codes = \array_unique($codes);
76
            }
77
        } while (\count($codes) < $amount + $blacklistCount);
78
79
        return \array_diff($codes, $codeBlacklist);
80
    }
81
82
    public function addIndividualCodes(string $promotionId, int $amount, Context $context): void
83
    {
84
        $criteria = (new Criteria([$promotionId]))
85
            ->addAssociation('individualCodes');
86
87
        /** @var PromotionEntity $promotion */
88
        $promotion = $this->promotionRepository->search($criteria, $context)->first();
89
90
        if ($promotion->getIndividualCodes() === null) {
91
            $this->replaceIndividualCodes($promotionId, $promotion->getIndividualCodePattern(), $amount, $context);
92
93
            return;
94
        }
95
96
        $newCodes = $this->generateIndividualCodes(
97
            $promotion->getIndividualCodePattern(),
98
            $amount,
99
            $promotion->getIndividualCodes()->getCodeArray()
100
        );
101
102
        $codeEntries = $this->prepareCodeEntities($promotionId, $newCodes);
103
        $this->individualCodesRepository->upsert($codeEntries, $context);
104
    }
105
106
    /**
107
     * @throws PatternAlreadyInUseException
108
     */
109
    public function replaceIndividualCodes(string $promotionId, string $pattern, int $amount, Context $context): void
110
    {
111
        if ($this->isCodePatternAlreadyInUse($pattern, $promotionId, $context)) {
112
            throw new PatternAlreadyInUseException();
113
        }
114
115
        $codes = $this->generateIndividualCodes($pattern, $amount);
116
        $codeEntries = $this->prepareCodeEntities($promotionId, $codes);
117
118
        $this->resetPromotionCodes($promotionId, $context);
119
        $this->individualCodesRepository->upsert($codeEntries, $context);
120
    }
121
122
    public function resetPromotionCodes(string $promotionId, Context $context): void
123
    {
124
        $criteria = (new Criteria())
125
            ->addFilter(new EqualsFilter('promotionId', $promotionId));
126
        $deleteCodes = \array_values($this->individualCodesRepository->searchIds($criteria, $context)->getData());
127
128
        $this->individualCodesRepository->delete($deleteCodes, $context);
129
    }
130
131
    public function splitPattern(string $pattern): array
132
    {
133
        \preg_match(self::PROMOTION_PATTERN_REGEX, $pattern, $codePattern);
134
        $codePattern['replacementString'] = \str_replace('%', '', $codePattern['replacement']);
135
        $codePattern['replacementArray'] = \str_split($codePattern['replacementString']);
136
137
        return $codePattern;
138
    }
139
140
    public function isCodePatternAlreadyInUse(string $pattern, string $promotionId, Context $context): bool
141
    {
142
        $criteria = (new Criteria())
143
            ->addFilter(new NotFilter('AND', [new EqualsFilter('id', $promotionId)]))
144
            ->addFilter(new EqualsFilter('individualCodePattern', $pattern));
145
146
        return $this->promotionRepository->search($criteria, $context)->getTotal() > 0;
147
    }
148
149
    private function generateCode(array $codePattern): string
150
    {
151
        $code = '';
152
        foreach ($codePattern['replacementArray'] as $letter) {
153
            $code .= $this->getRandomChar($letter);
154
        }
155
156
        return $codePattern['prefix'] . $code . $codePattern['suffix'];
157
    }
158
159
    private function calculatePossibilites(string $pattern): int
160
    {
161
        /*
162
         * These counts describe the amount of possibilities in a single digit, depending on variable type:
163
         * - d: digits (0-9)
164
         * - s: letters (A-Z)
165
         */
166
        $possibilityCounts = [
167
            'd' => 10,
168
            's' => 26,
169
        ];
170
        $counts = \count_chars($pattern, 1);
171
172
        $result = 1;
173
        foreach ($counts as $key => $count) {
174
            $result *= $possibilityCounts[\chr($key)] ** $count;
175
        }
176
177
        return $result;
178
    }
179
180
    private function getRandomChar(string $type): string
181
    {
182
        if ($type === 'd') {
183
            return (string) \random_int(0, 9);
184
        }
185
186
        return \chr(\random_int(65, 90));
187
    }
188
189
    private function prepareCodeEntities(string $promotionId, array $codes): array
190
    {
191
        return \array_values(\array_map(static function ($code) use ($promotionId) {
192
            return [
193
                'promotionId' => $promotionId,
194
                'code' => $code,
195
            ];
196
        }, $codes));
197
    }
198
}
199