|
9 | 9 |
|
10 | 10 | class ExportCertificatesProof extends Command |
11 | 11 | { |
12 | | - protected $signature = 'cw:export-certificates-proof |
13 | | - {--start= : Start datetime (YYYY-MM-DD or full Y-m-d H:i:s)} |
14 | | - {--end= : End datetime (YYYY-MM-DD or full Y-m-d H:i:s)} |
15 | | - {--path= : Output relative path under storage/app (default: exports/certificates_manifest_[range].csv)}'; |
| 12 | + protected $signature = 'cw:export-certificates-proof |
| 13 | + {--start= : Start datetime (YYYY-MM-DD or full Y-m-d H:i:s)} |
| 14 | + {--end= : End datetime (YYYY-MM-DD or full Y-m-d H:i:s)} |
| 15 | + {--path= : Output path under storage/app (default: exports/certificates_manifest_[range].csv)} |
| 16 | + {--family=both : Which family to export: participations|excellence|both} |
| 17 | + {--inclusive=0 : If 1, do not require URL and do not force status=DONE} |
| 18 | + {--date-field=created_at : Date field to use (created_at|event_date|issued_at if present)}'; |
16 | 19 |
|
17 | | - protected $description = 'Export a CSV manifest of issued certificates (with PDF links) for an interval'; |
| 20 | + protected $description = 'Export a CSV manifest of issued certificates (links + metadata) for the requested interval'; |
18 | 21 |
|
19 | 22 | public function handle() |
20 | 23 | { |
| 24 | + // ---- Window normalize |
21 | 25 | $start = $this->option('start') ?: now()->subYear()->startOfDay()->toDateTimeString(); |
22 | 26 | $end = $this->option('end') ?: now()->endOfDay()->toDateTimeString(); |
23 | | - |
24 | | - // Normalize date-only inputs |
25 | 27 | if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $start)) $start .= ' 00:00:00'; |
26 | 28 | if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) $end .= ' 23:59:59'; |
27 | 29 |
|
28 | | - // --- Schema detection ------------------------------------------------- |
29 | | - $hasEventId = Schema::hasColumn('participations', 'event_id'); |
30 | | - $hasActivityId = Schema::hasColumn('participations', 'activity_id'); // common alternative |
31 | | - $hasEventTitle = Schema::hasColumn('participations', 'event_title'); // sometimes stored directly |
32 | | - $hasTitle = Schema::hasColumn('participations', 'title'); // generic fallback |
| 30 | + $family = strtolower($this->option('family') ?: 'both'); // participations|excellence|both |
| 31 | + $inclusive = (int)($this->option('inclusive') ?: 0) === 1; |
| 32 | + $datePref = strtolower($this->option('date-field') ?: 'created_at'); // created_at|event_date|issued_at |
| 33 | + |
| 34 | + $defaultPath = 'exports/certificates_manifest_' |
| 35 | + . str_replace([':', ' '], ['_', '_'], $start) |
| 36 | + . '_to_' |
| 37 | + . str_replace([':', ' '], ['_', '_'], $end) |
| 38 | + . ($inclusive ? '_inclusive' : '') |
| 39 | + . ($family !== 'both' ? "_{$family}" : '') |
| 40 | + . '.csv'; |
| 41 | + |
| 42 | + $path = $this->option('path') ?: $defaultPath; |
| 43 | + |
| 44 | + $rows = collect(); |
| 45 | + |
| 46 | + if ($family === 'participations' || $family === 'both') { |
| 47 | + $rows = $rows->merge($this->exportParticipations($start, $end, $inclusive, $datePref)); |
| 48 | + } |
| 49 | + |
| 50 | + if ($family === 'excellence' || $family === 'both') { |
| 51 | + $rows = $rows->merge($this->exportExcellence($start, $end, $inclusive, $datePref)); |
| 52 | + } |
| 53 | + |
| 54 | + // Write merged CSV |
| 55 | + $stream = fopen('php://temp', 'w+'); |
| 56 | + fputcsv($stream, [ |
| 57 | + 'family', 'record_id', 'issued_at', 'event_date', |
| 58 | + 'status', 'owner_email', 'event_id', 'title', |
| 59 | + 'certificate_url', 'missing_url' |
| 60 | + ]); |
| 61 | + |
| 62 | + foreach ($rows as $r) { |
| 63 | + fputcsv($stream, [ |
| 64 | + $r['family'] ?? null, |
| 65 | + $r['record_id'] ?? null, |
| 66 | + $r['issued_at'] ?? null, |
| 67 | + $r['event_date'] ?? null, |
| 68 | + $r['status'] ?? null, |
| 69 | + $r['owner_email'] ?? null, |
| 70 | + $r['event_id'] ?? null, |
| 71 | + $r['title'] ?? null, |
| 72 | + $r['certificate_url'] ?? null, |
| 73 | + !empty($r['certificate_url']) ? 0 : 1, |
| 74 | + ]); |
| 75 | + } |
| 76 | + |
| 77 | + rewind($stream); |
| 78 | + $csv = stream_get_contents($stream); |
| 79 | + fclose($stream); |
| 80 | + Storage::disk('local')->put($path, $csv); |
| 81 | + |
| 82 | + $this->info("Wrote {$rows->count()} rows to storage/app/{$path}"); |
| 83 | + $this->line('Breakdown:'); |
| 84 | + |
| 85 | + // Print per-family monthly breakdowns for the memo |
| 86 | + $this->printMonthly('participations', $start, $end, $inclusive, $datePref); |
| 87 | + $this->printMonthly('excellence', $start, $end, $inclusive, $datePref); |
| 88 | + |
| 89 | + return self::SUCCESS; |
| 90 | + } |
| 91 | + |
| 92 | + // ---------- Helpers ---------- |
| 93 | + |
| 94 | + protected function pickDateColumn(string $table, string $preferred): ?string |
| 95 | + { |
| 96 | + // Respect requested preference first |
| 97 | + if ($preferred === 'event_date' && Schema::hasColumn($table, 'event_date')) { |
| 98 | + return 'event_date'; |
| 99 | + } |
| 100 | + if ($preferred === 'issued_at' && Schema::hasColumn($table, 'issued_at')) { |
| 101 | + return 'issued_at'; |
| 102 | + } |
| 103 | + if ($preferred === 'created_at' && Schema::hasColumn($table, 'created_at')) { |
| 104 | + return 'created_at'; |
| 105 | + } |
| 106 | + // Fallbacks |
| 107 | + foreach (['created_at','issued_at','event_date','date'] as $c) { |
| 108 | + if (Schema::hasColumn($table, $c)) return $c; |
| 109 | + } |
| 110 | + return null; |
| 111 | + } |
| 112 | + |
| 113 | + protected function exportParticipations(string $start, string $end, bool $inclusive, string $datePref) |
| 114 | + { |
| 115 | + $table = 'participations'; |
| 116 | + $dateCol = $this->pickDateColumn($table, $datePref) ?? 'created_at'; |
| 117 | + $dateExpr = "p.$dateCol"; |
33 | 118 |
|
34 | 119 | $q = DB::table('participations as p') |
35 | 120 | ->leftJoin('users as u', 'u.id', '=', 'p.user_id') |
36 | | - ->where('p.status', 'DONE') |
37 | | - ->whereNotNull('p.participation_url') |
38 | | - ->whereBetween('p.created_at', [$start, $end]) |
| 121 | + ->whereBetween($dateExpr, [$start, $end]) |
39 | 122 | ->orderBy('p.id'); |
40 | 123 |
|
41 | | - // Join events table only if we have a FK on participations |
| 124 | + $hasEventId = Schema::hasColumn($table, 'event_id'); |
| 125 | + $hasActivityId = Schema::hasColumn($table, 'activity_id'); |
| 126 | + |
42 | 127 | if ($hasEventId) { |
43 | 128 | $q->leftJoin('events as e', 'e.id', '=', 'p.event_id'); |
44 | 129 | } elseif ($hasActivityId) { |
45 | 130 | $q->leftJoin('events as e', 'e.id', '=', 'p.activity_id'); |
46 | 131 | } |
47 | 132 |
|
| 133 | + if (!$inclusive) { |
| 134 | + if (Schema::hasColumn($table, 'status')) { |
| 135 | + $q->where('p.status', 'DONE'); |
| 136 | + } |
| 137 | + $q->whereNotNull('p.participation_url'); |
| 138 | + } |
| 139 | + |
48 | 140 | $select = [ |
49 | | - 'p.id as participation_id', |
50 | | - 'p.created_at as issued_at', |
51 | | - 'p.event_date', |
| 141 | + 'p.id as record_id', |
| 142 | + DB::raw("$dateExpr as issued_at"), |
| 143 | + (Schema::hasColumn($table, 'event_date') ? 'p.event_date' : DB::raw('NULL as event_date')), |
| 144 | + (Schema::hasColumn($table, 'status') ? 'p.status' : DB::raw('NULL as status')), |
52 | 145 | 'u.email as owner_email', |
53 | | - 'p.participation_url as certificate_url', |
| 146 | + (Schema::hasColumn($table, 'participation_url') ? 'p.participation_url as certificate_url' : DB::raw('NULL as certificate_url')), |
54 | 147 | ]; |
55 | 148 |
|
56 | 149 | if ($hasEventId || $hasActivityId) { |
57 | | - // We can read from events |
58 | 150 | $select[] = 'e.id as event_id'; |
59 | | - $select[] = 'e.title as event_title'; |
| 151 | + $select[] = 'e.title as title'; |
60 | 152 | } else { |
61 | | - // No join available; fall back to a title present on participations (or NULL) |
62 | 153 | $select[] = DB::raw('NULL as event_id'); |
63 | | - if ($hasEventTitle) { |
64 | | - $select[] = 'p.event_title as event_title'; |
65 | | - } elseif ($hasTitle) { |
66 | | - $select[] = 'p.title as event_title'; |
67 | | - } else { |
68 | | - $select[] = DB::raw('NULL as event_title'); |
69 | | - } |
| 154 | + $select[] = DB::raw('NULL as title'); // `participations` has no native title in your DB |
70 | 155 | } |
71 | 156 |
|
72 | | - $rows = $q->get($select); |
| 157 | + return collect($q->get($select))->map(function ($r) { |
| 158 | + return [ |
| 159 | + 'family' => 'participations', |
| 160 | + 'record_id' => $r->record_id, |
| 161 | + 'issued_at' => $r->issued_at, |
| 162 | + 'event_date' => $r->event_date, |
| 163 | + 'status' => $r->status, |
| 164 | + 'owner_email' => $r->owner_email, |
| 165 | + 'event_id' => property_exists($r, 'event_id') ? $r->event_id : null, |
| 166 | + 'title' => $r->title ?? null, |
| 167 | + 'certificate_url' => $r->certificate_url ?? null, |
| 168 | + ]; |
| 169 | + }); |
| 170 | + } |
73 | 171 |
|
74 | | - $defaultPath = 'exports/certificates_manifest_' |
75 | | - . str_replace([':', ' '], ['_', '_'], $start) |
76 | | - . '_to_' |
77 | | - . str_replace([':', ' '], ['_', '_'], $end) |
78 | | - . '.csv'; |
| 172 | + protected function exportExcellence(string $start, string $end, bool $inclusive, string $datePref) |
| 173 | + { |
| 174 | + $table = 'CertificatesOfExcellence'; |
| 175 | + if (!Schema::hasTable($table)) return collect(); |
79 | 176 |
|
80 | | - $path = $this->option('path') ?: $defaultPath; |
| 177 | + $dateCol = $this->pickDateColumn($table, $datePref) ?? 'created_at'; |
| 178 | + $alias = 'x'; |
| 179 | + $dateExpr = "$alias.$dateCol"; |
81 | 180 |
|
82 | | - // Write CSV |
83 | | - $stream = fopen('php://temp', 'w+'); |
84 | | - fputcsv($stream, ['participation_id','issued_at','event_date','owner_email','event_id','event_title','certificate_url']); |
85 | | - foreach ($rows as $r) { |
86 | | - // event_id may be missing if we couldn’t join events |
87 | | - $eventId = property_exists($r, 'event_id') ? $r->event_id : null; |
88 | | - fputcsv($stream, [ |
89 | | - $r->participation_id, |
90 | | - $r->issued_at, |
91 | | - $r->event_date, |
92 | | - $r->owner_email, |
93 | | - $eventId, |
94 | | - $r->event_title, |
95 | | - $r->certificate_url, |
96 | | - ]); |
| 181 | + $q = DB::table("$table as $alias") |
| 182 | + ->whereBetween($dateExpr, [$start, $end]) |
| 183 | + ->orderBy("$alias.id"); |
| 184 | + |
| 185 | + if (!$inclusive && Schema::hasColumn($table, 'status')) { |
| 186 | + $q->where("$alias.status", 'DONE'); |
| 187 | + } |
| 188 | + if (!$inclusive) { |
| 189 | + $urlCol = Schema::hasColumn($table, 'certificate_url') ? 'certificate_url' |
| 190 | + : (Schema::hasColumn($table, 'url') ? 'url' : null); |
| 191 | + if ($urlCol) $q->whereNotNull("$alias.$urlCol"); |
97 | 192 | } |
98 | | - rewind($stream); |
99 | | - $csv = stream_get_contents($stream); |
100 | | - fclose($stream); |
101 | 193 |
|
102 | | - Storage::disk('local')->put($path, $csv); |
| 194 | + // Build select list defensively |
| 195 | + $select = ["$alias.id as record_id", DB::raw("$dateExpr as issued_at")]; |
| 196 | + $select[] = Schema::hasColumn($table,'event_date') ? "$alias.event_date" : DB::raw('NULL as event_date'); |
| 197 | + $select[] = Schema::hasColumn($table,'status') ? "$alias.status" : DB::raw('NULL as status'); |
103 | 198 |
|
104 | | - $this->info("Wrote ".count($rows)." rows to storage/app/{$path}"); |
| 199 | + if (Schema::hasColumn($table,'email')) $select[] = "$alias.email as owner_email"; |
| 200 | + elseif (Schema::hasColumn($table,'user_email')) $select[] = "$alias.user_email as owner_email"; |
| 201 | + else $select[] = DB::raw('NULL as owner_email'); |
105 | 202 |
|
106 | | - // Monthly breakdown for the audit note |
107 | | - $monthly = DB::table('participations') |
108 | | - ->selectRaw('DATE_FORMAT(created_at, "%Y-%m") as yyyymm, COUNT(*) as cnt') |
109 | | - ->where('status','DONE') |
110 | | - ->whereNotNull('participation_url') |
111 | | - ->whereBetween('created_at', [$start,$end]) |
112 | | - ->groupBy('yyyymm') |
113 | | - ->orderBy('yyyymm') |
114 | | - ->get(); |
| 203 | + $select[] = Schema::hasColumn($table,'event_id') ? "$alias.event_id" : DB::raw('NULL as event_id'); |
| 204 | + $select[] = Schema::hasColumn($table,'title') ? "$alias.title" : DB::raw('NULL as title'); |
115 | 205 |
|
116 | | - $this->line('Breakdown:'); |
117 | | - foreach ($monthly as $m) { |
118 | | - $this->line(" {$m->yyyymm}: {$m->cnt}"); |
| 206 | + if (Schema::hasColumn($table,'certificate_url')) $select[] = "$alias.certificate_url as certificate_url"; |
| 207 | + elseif (Schema::hasColumn($table,'url')) $select[] = "$alias.url as certificate_url"; |
| 208 | + else $select[] = DB::raw('NULL as certificate_url'); |
| 209 | + |
| 210 | + return collect($q->get($select))->map(function ($r) { |
| 211 | + return [ |
| 212 | + 'family' => 'excellence', |
| 213 | + 'record_id' => $r->record_id, |
| 214 | + 'issued_at' => $r->issued_at, |
| 215 | + 'event_date' => $r->event_date ?? null, |
| 216 | + 'status' => $r->status ?? null, |
| 217 | + 'owner_email' => $r->owner_email ?? null, |
| 218 | + 'event_id' => $r->event_id ?? null, |
| 219 | + 'title' => $r->title ?? null, |
| 220 | + 'certificate_url' => $r->certificate_url ?? null, |
| 221 | + ]; |
| 222 | + }); |
| 223 | + } |
| 224 | + |
| 225 | + protected function printMonthly(string $family, string $start, string $end, bool $inclusive, string $datePref): void |
| 226 | + { |
| 227 | + if ($family === 'participations') { |
| 228 | + $table = 'participations'; |
| 229 | + $alias = 'p'; |
| 230 | + } elseif ($family === 'excellence') { |
| 231 | + $table = 'CertificatesOfExcellence'; |
| 232 | + if (!Schema::hasTable($table)) { $this->line(" {$family}: table missing"); return; } |
| 233 | + $alias = 'x'; |
| 234 | + } else { |
| 235 | + return; |
119 | 236 | } |
120 | 237 |
|
121 | | - return self::SUCCESS; |
| 238 | + $dateCol = $this->pickDateColumn($table, $datePref) ?? 'created_at'; |
| 239 | + $dateExpr = "$alias.$dateCol"; |
| 240 | + |
| 241 | + $q = DB::table("$table as $alias")->whereBetween($dateExpr, [$start, $end]); |
| 242 | + |
| 243 | + if (!$inclusive && Schema::hasColumn($table, 'status')) { |
| 244 | + $q->where("$alias.status", 'DONE'); |
| 245 | + } |
| 246 | + if (!$inclusive) { |
| 247 | + $urlCol = Schema::hasColumn($table,'participation_url') ? 'participation_url' |
| 248 | + : (Schema::hasColumn($table,'certificate_url') ? 'certificate_url' |
| 249 | + : (Schema::hasColumn($table,'url') ? 'url' : null)); |
| 250 | + if ($urlCol) $q->whereNotNull("$alias.$urlCol"); |
| 251 | + } |
| 252 | + |
| 253 | + $monthly = $q->selectRaw('DATE_FORMAT('.$dateExpr.', "%Y-%m") as yyyymm, COUNT(*) as cnt') |
| 254 | + ->groupBy('yyyymm')->orderBy('yyyymm')->get(); |
| 255 | + |
| 256 | + $this->line(" {$family}:"); |
| 257 | + foreach ($monthly as $m) { |
| 258 | + $this->line(" {$m->yyyymm}: {$m->cnt}"); |
| 259 | + } |
122 | 260 | } |
123 | 261 | } |
0 commit comments