Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
PriceImportService
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 5
272
0.00% covered (danger)
0.00%
0 / 1
 import
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
110
 truncateRegion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parsePrice
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 parseInt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 columnLetter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Services;
4
5use App\Models\PricePostalCode;
6use App\Models\PriceScoreTierService;
7use App\Models\PriceService;
8use App\Models\PriceZone;
9use Illuminate\Support\Facades\DB;
10use Illuminate\Support\Facades\Log;
11use PhpOffice\PhpSpreadsheet\IOFactory;
12use PhpOffice\PhpSpreadsheet\Reader\Csv;
13
14class PriceImportService
15{
16    /**
17     * The CSV row (1-indexed) that contains the real column headers.
18     * Rows 1-4 are preamble/empty from the Excel export.
19     */
20    private const HEADER_ROW = 5;
21
22    /**
23     * Maps CSV column index (1-based) -> service name in tbl_price_services.
24     * Mapping is by column index (not header text) to be resilient to encoding
25     * issues and minor header variations across regions.
26     */
27    private const SERVICE_HEADER_MAP = [
28        7 => 'Revisión BIES',
29        8 => 'Revisión Detección Incendios - Central',
30        9 => 'Revisión Detección de Gases',
31        10 => 'Revisión ABA - Abastecimiento',
32        11 => 'Revisión Extinción Campanas',
33        12 => 'Revisión Detección Incendios - Sistema',
34        13 => 'Revisión Extintor',
35        14 => 'Revisión Alumbrado de Emergencia',
36        15 => 'Revisión Extracción Forzada',
37        16 => 'Revisión Columna Seca',
38        17 => 'Revisión Hidrantes',
39        18 => 'Revisión ABA - B.Electrica',
40        19 => 'Revisión Cortinas de Agua',
41        20 => 'Revisión ABA - B.Diesel',
42        21 => 'Prueba Hidráulica BIE',
43    ];
44
45    /**
46     * Import prices from a CSV file into the tbl_price_* tables.
47     *
48     * @return array{zones: int, postal_codes: int, prices: int, skipped: int}
49     */
50    public function import(string $filePath, string $region, bool $dryRun = false): array
51    {
52        $stats = ['zones' => 0, 'postal_codes' => 0, 'prices' => 0, 'skipped' => 0];
53
54        // Build service name -> id lookup (keyed by seeded names from FIRE-931)
55        $serviceIds = PriceService::pluck('id', 'name')->toArray();
56
57        // Parse CSV with auto-detected encoding (source is ISO-8859 / Windows-1252)
58        $reader = IOFactory::createReader('Csv');
59        $reader->setInputEncoding(Csv::GUESS_ENCODING);
60        $sheet = $reader->load($filePath)->getActiveSheet();
61        $rows = $sheet->toArray(null, true, true, true);
62
63        // Drop rows up to and including the header row
64        $dataRows = array_slice($rows, self::HEADER_ROW);
65
66        DB::beginTransaction();
67        try {
68            foreach ($dataRows as $row) {
69                $postalCode = trim((string) ($row['A'] ?? ''));
70                if (! preg_match('/^\d{5}$/', $postalCode)) {
71                    $stats['skipped']++;
72
73                    continue;
74                }
75
76                $zoneName = trim((string) ($row['B'] ?? ''));
77                $zoneType = trim((string) ($row['C'] ?? ''));
78
79                if ($zoneName === '') {
80                    Log::channel('third-party')->warning("[prices:import] Row for postal_code {$postalCode} has empty zone_name, skipped");
81                    $stats['skipped']++;
82
83                    continue;
84                }
85
86                $zone = PriceZone::updateOrCreate(
87                    ['region' => $region, 'zone_name' => $zoneName],
88                    ['zone_type' => $zoneType]
89                );
90                $stats['zones']++;
91
92                $scoreCp = $this->parseInt($row['F'] ?? null);
93
94                PricePostalCode::updateOrCreate(
95                    ['postal_code' => $postalCode, 'zone_id' => $zone->id],
96                    [
97                        'num_clients' => $this->parseInt($row['D'] ?? null),
98                        'weighted_clients' => $this->parseInt($row['E'] ?? null),
99                        'score_cp' => $scoreCp,
100                    ]
101                );
102                $stats['postal_codes']++;
103
104                // Skip writing tier prices if score_cp is missing (can't tier without it).
105                if ($scoreCp === null) {
106                    continue;
107                }
108
109                foreach (self::SERVICE_HEADER_MAP as $colIndex => $serviceName) {
110                    $colLetter = $this->columnLetter($colIndex);
111                    $price = $this->parsePrice((string) ($row[$colLetter] ?? ''));
112                    if ($price === null) {
113                        continue;
114                    }
115
116                    $serviceId = $serviceIds[$serviceName] ?? null;
117                    if (! $serviceId) {
118                        Log::channel('third-party')->warning("[prices:import] Unknown service '{$serviceName}'");
119
120                        continue;
121                    }
122
123                    PriceScoreTierService::updateOrCreate(
124                        [
125                            'region' => $region,
126                            'score_cp' => $scoreCp,
127                            'service_id' => $serviceId,
128                            'effective_from' => null,
129                        ],
130                        ['price' => $price]
131                    );
132                    $stats['prices']++;
133                }
134            }
135
136            if ($dryRun) {
137                DB::rollBack();
138            } else {
139                DB::commit();
140            }
141        } catch (\Exception $e) {
142            DB::rollBack();
143            throw $e;
144        }
145
146        return $stats;
147    }
148
149    /**
150     * Delete all price data for a region (cascades via FKs to postal codes and zone services).
151     */
152    public function truncateRegion(string $region): int
153    {
154        return PriceZone::where('region', $region)->delete();
155    }
156
157    /**
158     * Parse a price string. Handles "€9.80", "9.80", or variations.
159     * Returns null if the value can't be parsed as a number.
160     */
161    private function parsePrice(string $value): ?float
162    {
163        $clean = preg_replace('/[^0-9.\-]/', '', $value);
164
165        return is_numeric($clean) ? (float) $clean : null;
166    }
167
168    /**
169     * Parse an integer; return null on non-numeric input.
170     */
171    private function parseInt($value): ?int
172    {
173        return is_numeric($value) ? (int) $value : null;
174    }
175
176    /**
177     * Convert a 1-based column index to a spreadsheet column letter (A=1..Z=26).
178     */
179    private function columnLetter(int $index): string
180    {
181        return chr(64 + $index);
182    }
183}