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                    continue;
73                }
74
75                $zoneName = trim((string) ($row['B'] ?? ''));
76                $zoneType = trim((string) ($row['C'] ?? ''));
77
78                if ($zoneName === '') {
79                    Log::channel('third-party')->warning("[prices:import] Row for postal_code {$postalCode} has empty zone_name, skipped");
80                    $stats['skipped']++;
81                    continue;
82                }
83
84                $zone = PriceZone::updateOrCreate(
85                    ['region' => $region, 'zone_name' => $zoneName],
86                    ['zone_type' => $zoneType]
87                );
88                $stats['zones']++;
89
90                $scoreCp = $this->parseInt($row['F'] ?? null);
91
92                PricePostalCode::updateOrCreate(
93                    ['postal_code' => $postalCode, 'zone_id' => $zone->id],
94                    [
95                        'num_clients' => $this->parseInt($row['D'] ?? null),
96                        'weighted_clients' => $this->parseInt($row['E'] ?? null),
97                        'score_cp' => $scoreCp,
98                    ]
99                );
100                $stats['postal_codes']++;
101
102                // Skip writing tier prices if score_cp is missing (can't tier without it).
103                if ($scoreCp === null) {
104                    continue;
105                }
106
107                foreach (self::SERVICE_HEADER_MAP as $colIndex => $serviceName) {
108                    $colLetter = $this->columnLetter($colIndex);
109                    $price = $this->parsePrice((string) ($row[$colLetter] ?? ''));
110                    if ($price === null) {
111                        continue;
112                    }
113
114                    $serviceId = $serviceIds[$serviceName] ?? null;
115                    if (!$serviceId) {
116                        Log::channel('third-party')->warning("[prices:import] Unknown service '{$serviceName}'");
117                        continue;
118                    }
119
120                    PriceScoreTierService::updateOrCreate(
121                        [
122                            'region' => $region,
123                            'score_cp' => $scoreCp,
124                            'service_id' => $serviceId,
125                            'effective_from' => null,
126                        ],
127                        ['price' => $price]
128                    );
129                    $stats['prices']++;
130                }
131            }
132
133            if ($dryRun) {
134                DB::rollBack();
135            } else {
136                DB::commit();
137            }
138        } catch (\Exception $e) {
139            DB::rollBack();
140            throw $e;
141        }
142
143        return $stats;
144    }
145
146    /**
147     * Delete all price data for a region (cascades via FKs to postal codes and zone services).
148     */
149    public function truncateRegion(string $region): int
150    {
151        return PriceZone::where('region', $region)->delete();
152    }
153
154    /**
155     * Parse a price string. Handles "€9.80", "9.80", or variations.
156     * Returns null if the value can't be parsed as a number.
157     */
158    private function parsePrice(string $value): ?float
159    {
160        $clean = preg_replace('/[^0-9.\-]/', '', $value);
161
162        return is_numeric($clean) ? (float) $clean : null;
163    }
164
165    /**
166     * Parse an integer; return null on non-numeric input.
167     */
168    private function parseInt($value): ?int
169    {
170        return is_numeric($value) ? (int) $value : null;
171    }
172
173    /**
174     * Convert a 1-based column index to a spreadsheet column letter (A=1..Z=26).
175     */
176    private function columnLetter(int $index): string
177    {
178        return chr(64 + $index);
179    }
180}