Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.00% covered (success)
88.00%
88 / 100
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DiscoveryService
87.88% covered (success)
87.88%
87 / 99
33.33% covered (danger)
33.33%
2 / 6
45.14
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 discover
86.11% covered (success)
86.11%
31 / 36
0.00% covered (danger)
0.00%
0 / 1
22.18
 resolveDiscovery
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
10.03
 emptyResult
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 hasActionableData
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
8.12
 normalizeBarcode
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices;
6
7require_once __DIR__.'/legacy/ddbb/conx.php';
8
9use UppServices\Barcode\Gs1Client;
10use UppServices\Barcode\OFFClient;
11
12/**
13 * Barcode discovery: Open Food Facts catalog lookup with optional GS1 verification.
14 */
15class DiscoveryService
16{
17    private readonly SessionService $sessionService;
18    private readonly OFFClient $offClient;
19    private readonly Gs1Client $gs1Client;
20
21    /**
22     * @param callable(string, array<string, mixed>): ?string|null|null $httpFetcher Optional shared HTTP fetcher for tests.
23     */
24    public function __construct(
25        SessionService $sessionService,
26        ?callable $httpFetcher = null,
27    ) {
28        $this->sessionService = $sessionService;
29        $this->offClient = new OFFClient($httpFetcher);
30        $this->gs1Client = new Gs1Client($httpFetcher);
31    }
32
33    /**
34     * @param array<string, mixed> $query
35     * @param string|null        $postBody
36     * @return array<string, mixed>
37     */
38    public function discover(array $query, ?string $postBody): array
39    {
40        $retval = \ApplicationError::Success;
41        $result = $this->emptyResult('');
42
43        $device = isset($query['device']) ? (string) $query['device'] : '';
44        $sessionId = isset($query['session']) ? (string) $query['session'] : '';
45        $place = isset($query['place']) ? $query['place'] : null;
46
47        $barcode = isset($query['barcode']) ? (string) $query['barcode'] : '';
48        if ($barcode === '' && is_string($postBody) && $postBody !== '') {
49            $body = json_decode($postBody, true);
50            if (is_array($body) && isset($body['barcode'])) {
51                $barcode = (string) $body['barcode'];
52            }
53        }
54
55        if (\success($retval)) {
56            if ($device === '' || $sessionId === '') {
57                \addlog(__FILE__, \LogLevel::Error, 'DiscoveryService::discover: missing device or session.');
58                $retval = \ApplicationError::Parameters;
59            }
60        }
61
62        if (\success($retval)) {
63            if ($place === null || $place === '') {
64                \addlog(__FILE__, \LogLevel::Error, "DiscoveryService::discover: mandatory parameter 'place' not provided.");
65                $retval = \ApplicationError::Parameters;
66            }
67        }
68
69        if (\success($retval)) {
70            $normalized = $this->normalizeBarcode($barcode);
71            if ($normalized === null) {
72                \addlog(__FILE__, \LogLevel::Warning, 'DiscoveryService::discover: invalid barcode format.');
73                $retval = \ApplicationError::Parameters;
74            }
75            else {
76                $barcode = $normalized;
77            }
78        }
79
80        if (\success($retval)) {
81            $conx = new \ConxHelper(__FILE__);
82            try {
83                $userid = null;
84                $sessionObjid = null;
85                $retval = $this->sessionService->checkSession($conx, $sessionId, $device, $userid, $sessionObjid);
86            }
87            finally {
88                $conx->release();
89            }
90        }
91
92        if (\success($retval)) {
93            $result = $this->resolveDiscovery($barcode);
94        }
95
96        $output = ['errorcode' => $retval];
97        if (\success($retval)) {
98            $output = array_merge($output, $result);
99        }
100
101        return $output;
102    }
103
104    /**
105     * @return array<string, mixed>
106     */
107    private function resolveDiscovery(string $barcode): array
108    {
109        $sources = [];
110        $result = $this->emptyResult($barcode);
111
112        $offProduct = $this->offClient->fetchProduct($barcode);
113        if (is_array($offProduct)) {
114            $mapped = $this->offClient->mapToFields($offProduct);
115            $result = array_merge($result, $mapped);
116            $sources[] = 'openfoodfacts';
117            $result['found'] = $this->hasActionableData($result);
118        }
119
120        if ($result['found']) {
121            $checksumValid = $this->gs1Client->isValidGtinChecksum($barcode);
122            $result['gs1Valid'] = $checksumValid;
123            $result['gs1Licensee'] = null;
124            if ($checksumValid !== null) {
125                $sources[] = 'gs1';
126            }
127        }
128        else {
129            $gs1 = $this->gs1Client->verify($barcode);
130            $result['gs1Valid'] = $gs1['gs1Valid'];
131            $result['gs1Licensee'] = $gs1['gs1Licensee'];
132
133            if ($gs1['gs1Valid'] !== null) {
134                $sources[] = 'gs1';
135            }
136        }
137
138        if (!$result['found']) {
139            if ($result['gs1Valid'] === true) {
140                $result['hint'] = 'GTIN verified but no product details found in external catalogs.';
141            }
142            elseif ($result['gs1Valid'] === false) {
143                $result['hint'] = 'Barcode checksum invalid or GTIN not verified.';
144            }
145            else {
146                $result['hint'] = 'No product data found for this barcode.';
147            }
148        }
149        elseif ($result['name'] === null && $result['gs1Licensee'] !== null) {
150            $result['hint'] = 'Partial data: verify product name before saving.';
151        }
152
153        $result['sources'] = array_values(array_unique($sources));
154
155        return $result;
156    }
157
158    /**
159     * @return array<string, mixed>
160     */
161    private function emptyResult(string $barcode): array
162    {
163        return [
164            'barcode' => $barcode,
165            'found' => false,
166            'name' => null,
167            'nameLang' => null,
168            'brand' => null,
169            'category' => null,
170            'imageUrl' => null,
171            'ingredients' => null,
172            'ingredientsLang' => null,
173            'allergens' => null,
174            'nutrition' => null,
175            'gs1Valid' => null,
176            'gs1Licensee' => null,
177            'sources' => [],
178            'hint' => null,
179        ];
180    }
181
182    /**
183     * @param array<string, mixed> $result
184     */
185    private function hasActionableData(array $result): bool
186    {
187        foreach (['name', 'brand', 'category', 'imageUrl', 'ingredients'] as $field) {
188            if (!empty($result[$field])) {
189                return true;
190            }
191        }
192
193        if (!empty($result['allergens']) && is_array($result['allergens'])) {
194            return true;
195        }
196
197        return false;
198    }
199
200    public function normalizeBarcode(string $barcode): ?string
201    {
202        $trimmed = trim($barcode);
203        if ($trimmed === '') {
204            return null;
205        }
206
207        if (strlen($trimmed) > 128) {
208            return null;
209        }
210
211        if (!preg_match('/^[A-Za-z0-9]+$/', $trimmed)) {
212            return null;
213        }
214
215        return $trimmed;
216    }
217}