Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportFinanceFromDrive
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 3
272
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
56
 buildDriveService
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 processFile
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TblFinanceReportSemanal;
6use App\Models\TblFinanceSedes;
7use Carbon\Carbon;
8use Google\Client as GoogleClient;
9use Google\Service\Drive as DriveService;
10use Google\Service\Drive\DriveFile;
11use Illuminate\Console\Command;
12use Illuminate\Support\Facades\Log;
13use PhpOffice\PhpSpreadsheet\IOFactory;
14
15class ImportFinanceFromDrive extends Command
16{
17    protected $signature = 'finance:import-drive';
18
19    protected $description = 'Import finance data from Excel files in Google Drive for regions not in Zenital';
20
21    /**
22     * Map of Spanish month names to month numbers.
23     */
24    private const MONTH_MAP = [
25        'enero' => 1,
26        'febrero' => 2,
27        'marzo' => 3,
28        'abril' => 4,
29        'mayo' => 5,
30        'junio' => 6,
31        'julio' => 7,
32        'agosto' => 8,
33        'septiembre' => 9,
34        'octubre' => 10,
35        'noviembre' => 11,
36        'diciembre' => 12,
37    ];
38
39    public function handle(): int
40    {
41        try {
42            $folderId = env('GOOGLE_DRIVE_FINANCE_FOLDER_ID') ?: env('GOOGLE_DRIVE_FOLDER_ID');
43            if (! $folderId) {
44                $this->error('GOOGLE_DRIVE_FINANCE_FOLDER_ID is not set.');
45                return 1;
46            }
47
48            $service = $this->buildDriveService();
49
50            // List children of the finance folder. Include Google Sheets in
51            // addition to .xlsx uploads — finance drops spreadsheets directly
52            // in Drive, which are google-apps.spreadsheet, not xlsx.
53            $list = $service->files->listFiles([
54                'q' => "'{$folderId}' in parents and trashed=false and ("
55                    . "mimeType='application/vnd.google-apps.spreadsheet' or "
56                    . "mimeType='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')",
57                'fields' => 'files(id,name,mimeType)',
58                'pageSize' => 200,
59                'supportsAllDrives' => true,
60                'includeItemsFromAllDrives' => true,
61            ]);
62
63            $files = $list->getFiles();
64            if (empty($files)) {
65                $this->info('No spreadsheet files found in Google Drive folder.');
66                return 0;
67            }
68
69            // Files stay in place — business updates the same spreadsheets weekly.
70            // Re-import is idempotent via updateOrCreate on (company_id, sede_id,
71            // year, month, week_date) in processFile().
72            foreach ($files as $file) {
73                /** @var DriveFile $file */
74                $name = $file->getName();
75                $mime = $file->getMimeType();
76                $this->info("Processing: {$name} ({$mime})");
77
78                // Use the OS temp dir instead of storage/app — the latter
79                // can be non-writable after a deploy (permissions drift) and
80                // a fixed filename also means concurrent imports clobber
81                // each other. tempnam handles both issues.
82                $tempBase = tempnam(sys_get_temp_dir(), 'finance_import_');
83                $tempPath = $tempBase . '.xlsx';
84                // Laravel's rename is safer than tempnam's auto-created file
85                // because we need the .xlsx extension for PhpSpreadsheet.
86                @unlink($tempBase);
87                $bytes = $mime === 'application/vnd.google-apps.spreadsheet'
88                    ? $service->files->export($file->getId(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ['alt' => 'media'])->getBody()->getContents()
89                    : $service->files->get($file->getId(), ['alt' => 'media'])->getBody()->getContents();
90
91                file_put_contents($tempPath, $bytes);
92
93                $imported = $this->processFile($tempPath, $name);
94
95                @unlink($tempPath);
96
97                $this->info("Imported {$imported} rows from {$name}");
98                Log::channel('third-party')->info("finance:import-drive — Imported {$imported} rows from {$name}");
99            }
100
101            return 0;
102        } catch (\Exception $e) {
103            $this->error("Import failed: {$e->getMessage()}");
104            Log::channel('third-party')->error("finance:import-drive — {$e->getMessage()}");
105
106            return 1;
107        }
108    }
109
110    private function buildDriveService(): DriveService
111    {
112        $client = new GoogleClient();
113        $client->setClientId(env('GOOGLE_DRIVE_CLIENT_ID'));
114        $client->setClientSecret(env('GOOGLE_DRIVE_CLIENT_SECRET'));
115        $client->refreshToken(env('GOOGLE_DRIVE_REFRESH_TOKEN'));
116
117        return new DriveService($client);
118    }
119
120    /**
121     * Expected Excel format (per-sede billing file):
122     * Filename: "Facturación <SedeName>.xlsx"
123     * Column A: Rango Inferior (start date, e.g. 01/01/2026)
124     * Column B: Rango Superior (end date, e.g. 31/01/2026)
125     * Column C: Mes (month name in Spanish: Enero, Febrero, etc.)
126     * Column D: Base Imponible (billing amount)
127     *
128     * Each row is stored into tbl_finance_report_semanal using week_date = Rango Inferior.
129     */
130    private function processFile(string $path, string $filename): int
131    {
132        // Parse sede name from filename: "Facturación Cano Lopera.xlsx" -> "Cano Lopera"
133        // Also tolerate a timestamp prefix like "2026-04-20_144743_Facturación X"
134        // left behind by older versions of this command that renamed files.
135        $sedeName = $filename;
136        $sedeName = preg_replace('/\.xlsx$/i', '', $sedeName);
137        $sedeName = preg_replace('/^\d{4}-\d{2}-\d{2}_\d{6}_/', '', $sedeName);
138        $sedeName = preg_replace('/^Facturación\s+/iu', '', $sedeName);
139        $sedeName = trim($sedeName);
140
141        if (empty($sedeName)) {
142            $this->warn("Could not parse sede name from filename: {$filename}");
143
144            return 0;
145        }
146
147        // Look up sede by name (case-insensitive partial match)
148        $sede = TblFinanceSedes::where('is_active', 1)
149            ->whereRaw('LOWER(name) LIKE ?', ['%'.strtolower($sedeName).'%'])
150            ->first();
151
152        if (! $sede) {
153            $this->warn("Sede not found for name: {$sedeName} (filename: {$filename})");
154
155            return 0;
156        }
157
158        $spreadsheet = IOFactory::load($path);
159        $sheet = $spreadsheet->getActiveSheet();
160        $rows = $sheet->toArray(null, true, true, true);
161
162        // Skip header row
163        array_shift($rows);
164        $imported = 0;
165
166        foreach ($rows as $row) {
167            $rangoInferior = $row['A'] ?? null;
168            $rangoSuperior = $row['B'] ?? null;
169            $mesName = $row['C'] ?? null;
170            $baseImponible = $row['D'] ?? null;
171
172            if (! $rangoInferior || ! $mesName) {
173                continue;
174            }
175
176            // Parse dates (format: dd/mm/yyyy)
177            try {
178                $weekStart = Carbon::createFromFormat('d/m/Y', trim($rangoInferior));
179            } catch (\Exception $e) {
180                $this->warn("Invalid start date: {$rangoInferior}");
181
182                continue;
183            }
184
185            // Determine year from the start date
186            $year = $weekStart->year;
187
188            // Map Spanish month name to number
189            $monthNumber = self::MONTH_MAP[strtolower(trim($mesName))] ?? null;
190            if (! $monthNumber) {
191                $this->warn("Unknown month name: {$mesName}");
192
193                continue;
194            }
195
196            // Use week_date = Rango Inferior (the table has a single week_date column, not separate start/end)
197            TblFinanceReportSemanal::updateOrCreate(
198                [
199                    'company_id' => $sede->company_id,
200                    'sede_id' => $sede->id,
201                    'year' => $year,
202                    'month' => $monthNumber,
203                    'week_date' => $weekStart->toDateString(),
204                ],
205                [
206                    'actuals' => $baseImponible,
207                    'source' => 'google_drive',
208                    'loaded_at' => now(),
209                ]
210            );
211            $imported++;
212        }
213
214        return $imported;
215    }
216}