Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
QuotationsCleanupEmailFragments
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
182
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
182
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TblQuotations;
6use Illuminate\Console\Command;
7use Illuminate\Support\Facades\DB;
8
9/**
10 * One-off cleanup of tbl_quotations.email rows that contain empty
11 * fragments caused by the historical Gestiona ; → , translation bug
12 * (PresupuestosService stripped no empty pieces, so a trailing ";"
13 * left "foo@bar.com," in the DB).
14 *
15 * Idempotent: only rows whose email LIKE-matches a suspect pattern
16 * are inspected, and we only write back the cleaned value if it
17 * differs from the original. Safe to re-run.
18 *
19 * Targets:
20 *   - trailing  ","   →  "foo@bar.com,"
21 *   - leading   ","   →  ",foo@bar.com"
22 *   - doubled   ",,"  →  "foo@bar.com,,baz@qux.com"
23 *   - unconverted ";" →  "foo@bar.com;baz@qux.com" (older rows that
24 *                       slipped through before normalization existed)
25 *   - stray spaces around separators
26 *
27 * Side effects: only updates the `email` column. `budget_status_id`
28 * and `x_status` are left alone — if a row got flagged "Correo erróneo"
29 * (22) or x_status='Error' because of this bug, the user can decide
30 * separately whether to reset those.
31 */
32class QuotationsCleanupEmailFragments extends Command
33{
34    protected $signature = 'quotations:cleanup-email-fragments
35                            {--dry-run : Report what would change without writing}
36                            {--chunk=500 : How many candidate rows to inspect per batch}';
37
38    protected $description = 'Strip empty email fragments left behind by the Gestiona ";" → "," parsing bug.';
39
40    public function handle(): int
41    {
42        $dryRun = (bool) $this->option('dry-run');
43        $chunkSize = max(1, (int) $this->option('chunk'));
44
45        $candidates = TblQuotations::query()
46            ->whereNotNull('email')
47            ->where(function ($q) {
48                $q->where('email', 'LIKE', '%;%')
49                  ->orWhere('email', 'LIKE', ',%')
50                  ->orWhere('email', 'LIKE', '%,')
51                  ->orWhere('email', 'LIKE', '%,,%')
52                  ->orWhere('email', 'LIKE', '%, ,%')
53                  ->orWhere('email', 'LIKE', '% ,%')
54                  ->orWhere('email', 'LIKE', '%, %');
55            });
56
57        $total = (clone $candidates)->count();
58
59        if ($total === 0) {
60            $this->info('No quotations match the suspect patterns. Nothing to clean.');
61
62            return self::SUCCESS;
63        }
64
65        $this->info(($dryRun ? '[DRY RUN] ' : '')."Scanning {$total} candidate row(s)…");
66
67        $scanned = 0;
68        $changed = 0;
69        $cleared = 0;
70        $alsoStatus22 = 0;
71        $alsoErrorX = 0;
72
73        $candidates->orderBy('id')->chunkById($chunkSize, function ($rows) use (
74            $dryRun,
75            &$scanned,
76            &$changed,
77            &$cleared,
78            &$alsoStatus22,
79            &$alsoErrorX,
80        ) {
81            foreach ($rows as $row) {
82                $scanned++;
83
84                $original = (string) $row->email;
85                $parts = preg_split('/\s*[;,]\s*/', $original, -1, PREG_SPLIT_NO_EMPTY);
86                $cleaned = $parts ? implode(',', $parts) : null;
87
88                if ($cleaned === $original) {
89                    continue; // already canonical (e.g. a single email matched LIKE but had no fragments)
90                }
91
92                $changed++;
93                if ($cleaned === null) {
94                    $cleared++;
95                }
96                if ((int) $row->budget_status_id === 22) {
97                    $alsoStatus22++;
98                }
99                if (in_array($row->x_status, ['Error', 'Error - Bounce', 'Error - Spam'], true)) {
100                    $alsoErrorX++;
101                }
102
103                if (! $dryRun) {
104                    DB::table('tbl_quotations')
105                        ->where('id', $row->id)
106                        ->update(['email' => $cleaned, 'updated_at' => now()]);
107                }
108            }
109        });
110
111        $this->line('');
112        $this->line('  Scanned ............. '.number_format($scanned));
113        $this->line('  Would change ........ '.number_format($changed));
114        $this->line('  Cleared (→ NULL) .... '.number_format($cleared));
115        $this->line('  Also at status 22 ... '.number_format($alsoStatus22));
116        $this->line('  Also x_status=Error  '.number_format($alsoErrorX));
117
118        if ($dryRun) {
119            $this->warn('Dry run — no rows were written. Re-run without --dry-run to apply.');
120        } else {
121            $this->info("Done. Updated {$changed} row(s).");
122        }
123
124        if ($alsoStatus22 > 0 || $alsoErrorX > 0) {
125            $this->line('');
126            $this->comment('Note: '.($alsoStatus22 + $alsoErrorX).' of the changed rows still carry budget_status_id=22 (Correo erróneo) or x_status="Error*" from earlier failed sends. They may still appear under "Emails con error" until those fields are reviewed.');
127        }
128
129        return self::SUCCESS;
130    }
131}