argument('roster'); $surveyPath = $this->option('survey'); $dryRun = $this->option('dry-run'); if (!file_exists($rosterPath)) { $this->error("Roster file not found: {$rosterPath}"); return 1; } // Load survey emails if provided if ($surveyPath && file_exists($surveyPath)) { $this->loadSurveyEmails($surveyPath); $this->info("Loaded {$this->getSurveyEmailCount()} emails from survey."); } // Load roster $spreadsheet = IOFactory::load($rosterPath); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray(); // Skip header row $header = array_shift($rows); $this->info("Header: " . implode(', ', array_filter($header))); // Column mapping (based on 2026-01-23 會員名冊.xlsx structure) // 序號, 姓名, 民國出生年月日, 性別, 現職, 聯絡地址, 聯絡電話, 身分證字號, 身份別, 申請日期, e-mail $columnMap = [ 0 => 'sequence', // 序號 1 => 'name', // 姓名 2 => 'birth_date', // 民國出生年月日 3 => 'gender', // 性別 4 => 'occupation', // 現職 5 => 'address', // 聯絡地址 6 => 'phone', // 聯絡電話 7 => 'national_id', // 身分證字號 8 => 'member_type', // 身份別 9 => 'apply_date', // 申請日期 10 => 'email', // e-mail ]; $imported = 0; $skipped = 0; $errors = []; DB::beginTransaction(); try { foreach ($rows as $index => $row) { $rowNum = $index + 2; // Excel row number (1-indexed + header) // Skip empty rows $name = trim($row[1] ?? ''); if (empty($name)) { continue; } $phone = $this->normalizePhone($row[6] ?? ''); $email = $this->resolveEmail($name, $row[10] ?? ''); $nationalId = trim($row[7] ?? ''); $address = trim($row[5] ?? ''); $memberType = $this->mapMemberType($row[8] ?? ''); // Validate phone for password generation if (strlen($phone) < 4) { $errors[] = "Row {$rowNum}: {$name} - Invalid phone number for password"; $skipped++; continue; } // Generate password from last 4 digits $password = substr($phone, -4); // Check for existing user by email $existingUser = User::where('email', $email)->first(); if ($existingUser) { $this->warn("Row {$rowNum}: {$name} - Email already exists: {$email}"); $skipped++; continue; } // Check for existing member by national ID if ($nationalId) { $existingMember = Member::where('national_id_hash', hash('sha256', $nationalId))->first(); if ($existingMember) { $this->warn("Row {$rowNum}: {$name} - National ID already exists"); $skipped++; continue; } } if ($dryRun) { $this->line("[DRY RUN] Would import: {$name}, Phone: {$phone}, Email: {$email}, Password: ****{$password}"); $imported++; continue; } // Create User $user = User::create([ 'name' => $name, 'email' => $email, 'password' => Hash::make($password), ]); // Create Member $member = Member::create([ 'user_id' => $user->id, 'full_name' => $name, 'email' => $email, 'phone' => $phone, 'address_line_1' => $address, 'national_id' => $nationalId ?: null, 'membership_status' => Member::STATUS_ACTIVE, 'membership_type' => $memberType, 'membership_started_at' => now(), 'membership_expires_at' => now()->endOfYear(), // 2026-12-31 ]); // Create fully approved MembershipPayment MembershipPayment::create([ 'member_id' => $member->id, 'fee_type' => MembershipPayment::FEE_TYPE_ENTRANCE, 'amount' => 500, // Standard entrance fee 'base_amount' => 500, 'discount_amount' => 0, 'final_amount' => 500, 'disability_discount' => false, 'payment_method' => MembershipPayment::METHOD_CASH, 'status' => MembershipPayment::STATUS_APPROVED_CHAIR, 'paid_at' => now(), 'cashier_verified_at' => now(), 'accountant_verified_at' => now(), 'chair_verified_at' => now(), 'notes' => 'Imported from legacy roster - pre-approved', ]); $this->info("Imported: {$name} ({$email})"); $imported++; } if (!$dryRun) { DB::commit(); } } catch (\Exception $e) { DB::rollBack(); $this->error("Import failed: " . $e->getMessage()); return 1; } $this->newLine(); $this->info("Import complete!"); $this->info(" Imported: {$imported}"); $this->info(" Skipped: {$skipped}"); if (!empty($errors)) { $this->newLine(); $this->warn("Errors:"); foreach ($errors as $error) { $this->line(" - {$error}"); } } if ($dryRun) { $this->newLine(); $this->warn("This was a dry run. No data was saved."); } return 0; } protected function loadSurveyEmails(string $path): void { $spreadsheet = IOFactory::load($path); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray(); // Skip header array_shift($rows); foreach ($rows as $row) { // Survey structure: timestamp, 身份, 姓名, 年齡, 職業, 居住地, 疾病類型, 聯絡用電子郵件, 聯絡電話, 電子郵件地址 // Name is column 2, email is column 9 (電子郵件地址) $name = trim($row[2] ?? ''); $email = trim($row[9] ?? ''); // Also check 聯絡用電子郵件 (column 7) as fallback if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { $email = trim($row[7] ?? ''); } if ($email && $name && filter_var($email, FILTER_VALIDATE_EMAIL)) { // Normalize name for matching $normalizedName = $this->normalizeName($name); $this->surveyEmails[$normalizedName] = $email; } } } protected function getSurveyEmailCount(): int { return count($this->surveyEmails); } protected function resolveEmail(string $name, string $rosterEmail): string { // First, use roster email if valid $rosterEmail = trim($rosterEmail); if ($rosterEmail && filter_var($rosterEmail, FILTER_VALIDATE_EMAIL)) { return $rosterEmail; } // Second, check survey data $normalizedName = $this->normalizeName($name); if (isset($this->surveyEmails[$normalizedName])) { return $this->surveyEmails[$normalizedName]; } // Generate placeholder email $safeName = preg_replace('/[^a-zA-Z0-9]/', '', $this->toPinyin($name)); if (empty($safeName)) { $safeName = 'member' . time() . rand(100, 999); } return strtolower($safeName) . '@member.usher.org.tw'; } protected function normalizeName(string $name): string { // Remove spaces and convert to lowercase for matching return mb_strtolower(preg_replace('/\s+/', '', $name)); } protected function normalizePhone(string $phone): string { // Remove all non-numeric characters $phone = preg_replace('/[^0-9]/', '', $phone); // If phone contains a mobile number (starting with 09), extract it // This handles cases where home and mobile are concatenated if (preg_match('/(09\d{8})/', $phone, $matches)) { return $matches[1]; } // If no mobile found but has 10 digits starting with 09, use it if (strlen($phone) === 10 && str_starts_with($phone, '09')) { return $phone; } // Return last 10 digits if longer (might be concatenated numbers) if (strlen($phone) > 10) { return substr($phone, -10); } return $phone; } protected function mapMemberType(string $type): string { // Map Chinese member types to constants $type = trim($type); return match ($type) { '榮譽會員' => Member::TYPE_HONORARY, '終身會員' => Member::TYPE_LIFETIME, '學生會員' => Member::TYPE_STUDENT, default => Member::TYPE_REGULAR, // 患者, 家屬, etc. }; } protected function toPinyin(string $name): string { // Simple romanization for email generation // Just use a hash-based approach for Chinese names return 'member' . substr(md5($name), 0, 8); } }