Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.00% covered (success)
90.00%
72 / 80
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrefixGeneratorService
89.87% covered (warning)
89.87%
71 / 79
62.50% covered (warning)
62.50%
5 / 8
30.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateForCreate
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 formatPreview
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildPrefixString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 padNumber
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 interpolatePattern
90.24% covered (success)
90.24%
37 / 41
0.00% covered (danger)
0.00%
0 / 1
3.01
 resolveFieldTagValue
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
13.70
 sanitizeTagValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\FieldMetadata;
12use App\Core\Engine\Domain\Model\ModuleMetadata;
13use App\Core\Engine\Domain\Model\PrefixMetadata;
14use App\Core\Engine\Domain\Repository\PrefixRepositoryInterface;
15use DateTimeImmutable;
16
17/**
18 * Prefix and Record Sequence Generator Service.
19 *
20 * Coordinates generating unique, human-readable record prefix numbers upon record creation.
21 * Resolves template tags (temporal, sequence, picklist short codes, relations) and
22 * executes atomic, concurrency-safe counter incrementation.
23 *
24 * @package App\Core\Engine\Application\Service
25 */
26final readonly class PrefixGeneratorService
27{
28    /**
29     * PrefixGeneratorService constructor.
30     *
31     * @param PrefixRepositoryInterface $prefixRepo Prefix repository contract.
32     */
33    public function __construct(private PrefixRepositoryInterface $prefixRepo)
34    {
35    }
36
37    /**
38     * Inspects module fields and generates sequential prefix codes for any configured prefix fields.
39     *
40     * @param ModuleMetadata            $module    Target module metadata.
41     * @param array<int, FieldMetadata> $fields    All field metadata definitions for the module.
42     * @param array<string, mixed>      $inputData Input record data payload.
43     * @return array<string, mixed> Enriched record payload with generated prefix values.
44     */
45    public function generateForCreate(
46        ModuleMetadata $module,
47        array          $fields,
48        array          $inputData,
49    ): array {
50        $prefixRules = $this->prefixRepo->findActiveByModule($module->id);
51        if ($prefixRules === []) {
52            return $inputData;
53        }
54
55        $fieldsById = [];
56        foreach ($fields as $field) {
57            $fieldsById[$field->id] = $field;
58        }
59
60        $now = new DateTimeImmutable();
61
62        foreach ($prefixRules as $rule) {
63            $targetField = $fieldsById[$rule->fieldId] ?? null;
64            if ($targetField === null) {
65                continue;
66            }
67
68            // If value is already set and non-empty (and not default/placeholder), keep it
69            $currentVal = $inputData[$targetField->fieldKey] ?? null;
70            if ($currentVal !== null && $currentVal !== '' && $currentVal !== '[auto]') {
71                continue;
72            }
73
74            $allocatedNumber = $this->prefixRepo->acquireNextNumber($rule->id, $now);
75            $generatedCode = $this->buildPrefixString($rule, $allocatedNumber, $fields, $inputData, $now);
76
77            $inputData[$targetField->fieldKey] = $generatedCode;
78        }
79
80        return $inputData;
81    }
82
83    /**
84     * Generates a live WYSIWYG preview representation of a prefix rule for UI/Simulator.
85     *
86     * @param PrefixMetadata            $rule       Prefix configuration rule.
87     * @param array<int, FieldMetadata> $fields     Available module fields.
88     * @param array<string, mixed>      $sampleData Sample field values for template variable resolution.
89     * @param DateTimeImmutable|null    $now        Optional fixed point in time.
90     * @return string Formatted preview string (e.g. "#IT-2026-08-01000").
91     */
92    public function formatPreview(
93        PrefixMetadata     $rule,
94        array              $fields = [],
95        array              $sampleData = [],
96        ?DateTimeImmutable $now = null,
97    ): string {
98        $currentTime = $now ?? new DateTimeImmutable();
99        $sampleNumber = $rule->currentNumber > 0 ? $rule->currentNumber : $rule->startNumber;
100
101        return $this->buildPrefixString($rule, $sampleNumber, $fields, $sampleData, $currentTime);
102    }
103
104    /**
105     * Builds the complete prefix string based on static prefix/postfix or dynamic pattern.
106     *
107     * @param PrefixMetadata            $rule       Prefix configuration rule.
108     * @param int                       $number     Sequence number to format.
109     * @param array<int, FieldMetadata> $fields     Available module fields.
110     * @param array<string, mixed>      $data       Record data payload.
111     * @param DateTimeImmutable         $now        Current date and time.
112     * @return string Formatted prefix code.
113     */
114    private function buildPrefixString(
115        PrefixMetadata     $rule,
116        int                $number,
117        array              $fields,
118        array              $data,
119        DateTimeImmutable  $now,
120    ): string {
121        $paddedNumber = $this->padNumber($number, $rule->leadingZeros);
122
123        if ($rule->pattern !== null && trim($rule->pattern) !== '') {
124            return $this->interpolatePattern($rule->pattern, $rule, $paddedNumber, $fields, $data, $now);
125        }
126
127        return $rule->prefix . $paddedNumber . $rule->postfix;
128    }
129
130    /**
131     * Pads an integer sequence number with leading zeros according to configuration.
132     *
133     * @param int $number       Raw sequence integer.
134     * @param int $leadingZeros Number of total digits (0 for no padding).
135     * @return string Padded numeric string.
136     */
137    private function padNumber(int $number, int $leadingZeros): string
138    {
139        if ($leadingZeros <= 0) {
140            return (string) $number;
141        }
142
143        return str_pad((string) $number, $leadingZeros, '0', STR_PAD_LEFT);
144    }
145
146    /**
147     * Interpolates all placeholders in a pattern string.
148     *
149     * @param string                    $pattern      Pattern template.
150     * @param PrefixMetadata            $rule         Prefix metadata rule.
151     * @param string                    $paddedNumber Pre-padded sequence number string.
152     * @param array<int, FieldMetadata> $fields       Module fields.
153     * @param array<string, mixed>      $data         Input data for picklist / relational variables.
154     * @param DateTimeImmutable         $now          Current timestamp.
155     * @return string Fully interpolated string without whitespace.
156     */
157    private function interpolatePattern(
158        string            $pattern,
159        PrefixMetadata    $rule,
160        string            $paddedNumber,
161        array             $fields,
162        array             $data,
163        DateTimeImmutable $now,
164    ): string {
165        $quarter = 'Q' . (int) ceil(((int) $now->format('n')) / 3);
166
167        $replacements = [
168            '{YYYY}'    => $now->format('Y'),
169            '{YEAR}'    => $now->format('Y'),
170            '{YY}'      => $now->format('y'),
171            '{MM}'      => $now->format('m'),
172            '{MONTH}'   => $now->format('m'),
173            '{M}'       => $now->format('n'),
174            '{DD}'      => $now->format('d'),
175            '{DAY}'     => $now->format('d'),
176            '{D}'       => $now->format('j'),
177            '{QUARTER}' => $quarter,
178            '{Q}'       => $quarter,
179            '{WEEK}'    => $now->format('W'),
180            '{W}'       => $now->format('W'),
181            '{NUMBER}'  => $paddedNumber,
182            '{PREFIX}'  => $rule->prefix,
183            '{POSTFIX}' => $rule->postfix,
184        ];
185
186        // Replace picklist tags: {picklist:field_key} or {picklist_key}
187        $fieldsByKey = [];
188        foreach ($fields as $f) {
189            $fieldsByKey[$f->fieldKey] = $f;
190        }
191
192        $result = strtr($pattern, $replacements);
193
194        // Regex replace {picklist:(\w+)}
195        $result = (string) preg_replace_callback(
196            '/\{picklist:(\w+)\}/',
197            function (array $matches) use ($fieldsByKey, $data): string {
198                return $this->resolveFieldTagValue($matches[1], $fieldsByKey, $data, false);
199            },
200            $result
201        );
202
203        // Regex replace {field_key} for remaining direct module fields
204        $result = (string) preg_replace_callback(
205            '/\{(\w+)\}/',
206            function (array $matches) use ($fieldsByKey, $data): string {
207                $fieldKey = $matches[1];
208                if (!array_key_exists($fieldKey, $data)) {
209                    return $matches[0];
210                }
211                return $this->resolveFieldTagValue($fieldKey, $fieldsByKey, $data, true);
212            },
213            $result
214        );
215
216        return trim($result);
217    }
218
219    /**
220     * Resolves picklist short code or string value for a pattern field tag.
221     *
222     * @param string                     $fieldKey    Field machine name.
223     * @param array<string, FieldMetadata> $fieldsByKey Indexed field metadata.
224     * @param array<string, mixed>       $data        Record input payload.
225     * @param bool                       $fallbackRaw Whether to fall back to raw string.
226     * @return string Sanitized tag value.
227     */
228    private function resolveFieldTagValue(
229        string $fieldKey,
230        array  $fieldsByKey,
231        array  $data,
232        bool   $fallbackRaw
233    ): string {
234        $val = $data[$fieldKey] ?? null;
235        $field = $fieldsByKey[$fieldKey] ?? null;
236
237        if ($field !== null && $field->picklistId !== null && $val !== null && $val !== '') {
238            $short = $this->prefixRepo->findPicklistShortCode($val, $field->picklistId);
239            if ($short !== null && $short !== '') {
240                return $this->sanitizeTagValue($short);
241            }
242        }
243
244        if ($val !== null && ($fallbackRaw || $val !== '')) {
245            return $this->sanitizeTagValue((string) $val);
246        }
247
248        return '';
249    }
250
251    /**
252     * Sanitizes a tag value to remove forbidden characters and whitespace.
253     *
254     * @param string $value Raw string value.
255     * @return string Sanitized code-safe string.
256     */
257    private function sanitizeTagValue(string $value): string
258    {
259        // Replace spaces with hyphens and strip non-alphanumeric chars except - and _
260        $sanitized = (string) preg_replace('/\s+/', '-', trim($value));
261        return (string) preg_replace('/[^A-Za-z0-9_\-\/]/', '', $sanitized);
262    }
263}