Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 67 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
| PriceImportService | |
0.00% |
0 / 67 |
|
0.00% |
0 / 5 |
272 | |
0.00% |
0 / 1 |
| import | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
110 | |||
| truncateRegion | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| parsePrice | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| parseInt | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| columnLetter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services; |
| 4 | |
| 5 | use App\Models\PricePostalCode; |
| 6 | use App\Models\PriceScoreTierService; |
| 7 | use App\Models\PriceService; |
| 8 | use App\Models\PriceZone; |
| 9 | use Illuminate\Support\Facades\DB; |
| 10 | use Illuminate\Support\Facades\Log; |
| 11 | use PhpOffice\PhpSpreadsheet\IOFactory; |
| 12 | use PhpOffice\PhpSpreadsheet\Reader\Csv; |
| 13 | |
| 14 | class 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 | } |