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 ]; $created = 0; $updated = 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; } $memberNumber = $this->normalizeMemberNumber($row[0] ?? ''); $birthDate = $this->normalizeRocDate($row[2] ?? ''); $gender = $this->normalizeGender($row[3] ?? ''); $occupation = trim($row[4] ?? ''); $address = trim($row[5] ?? ''); $phone = $this->normalizePhone($row[6] ?? ''); $nationalId = trim($row[7] ?? ''); [$identityType, $identityOtherText] = $this->parseIdentityType($row[8] ?? ''); $applyDate = $this->normalizeRocDate($row[9] ?? ''); $email = $this->resolveEmail($name, $row[10] ?? ''); $memberType = Member::TYPE_INDIVIDUAL; $existingMember = $this->findExistingMember($email, $memberNumber, $nationalId, $name); if ($existingMember) { $updateData = $this->buildUpdateData( $existingMember, $memberNumber, $birthDate, $gender, $occupation, $address, $phone, $nationalId, $identityType, $identityOtherText, $applyDate, ); if ($dryRun) { $this->line("[DRY RUN] Would update: {$name} (member_id {$existingMember->id})"); $updated++; } elseif (! empty($updateData)) { $existingMember->update($updateData); $this->info("Updated: {$name} (member_id {$existingMember->id})"); $updated++; } else { $skipped++; } continue; } $existingUser = User::where('email', $email)->first(); $isNewUser = false; if ($dryRun) { $this->line("[DRY RUN] Would create: {$name}, Phone: {$phone}, Email: {$email}"); $created++; continue; } if (! $existingUser) { if (strlen($phone) < 4) { $errors[] = "Row {$rowNum}: {$name} - Invalid phone number for password"; $skipped++; continue; } $password = substr($phone, -4); $existingUser = User::create([ 'name' => $name, 'email' => $email, 'password' => Hash::make($password), ]); $isNewUser = true; } // Create Member $member = Member::create([ 'user_id' => $existingUser->id, 'member_number' => $memberNumber ?: null, 'full_name' => $name, 'email' => $email, 'phone' => $phone, 'phone_home' => null, 'phone_fax' => null, 'address_line_1' => $address, 'national_id' => $nationalId ?: null, 'membership_status' => Member::STATUS_ACTIVE, 'membership_type' => $memberType, 'identity_type' => $identityType, 'identity_other_text' => $identityOtherText ?: null, 'birth_date' => $birthDate, 'gender' => $gender, 'occupation' => $occupation ?: null, 'employer' => null, 'job_title' => null, 'applied_at' => $applyDate, 'membership_started_at' => now(), 'membership_expires_at' => now()->endOfYear(), // 2026-12-31 ]); if ($isNewUser) { $this->info("Created user: {$name} ({$email})"); } // 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("Created: {$name} ({$email})"); $created++; } 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(" Created: {$created}"); $this->info(" Updated: {$updated}"); $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 normalizeMemberNumber(mixed $value): ?string { if ($value === null || $value === '') { return null; } $raw = is_numeric($value) ? (string) (int) $value : trim((string) $value); return $raw !== '' ? $raw : null; } protected function normalizeGender(mixed $value): ?string { $value = trim((string) $value); return match ($value) { '男' => 'male', '女' => 'female', '其他' => 'other', default => null, }; } protected function normalizeRocDate(mixed $value): ?string { if ($value === null || $value === '') { return null; } if ($value instanceof \DateTimeInterface) { return $value->format('Y-m-d'); } if (is_numeric($value)) { $numeric = (float) $value; if ($numeric > 10000) { return ExcelDate::excelToDateTimeObject($numeric)->format('Y-m-d'); } } $raw = trim((string) $value); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { return $raw; } if (preg_match('/^\d{4}\/\d{2}\/\d{2}$/', $raw)) { return str_replace('/', '-', $raw); } $digits = preg_replace('/\D/', '', $raw); if (strlen($digits) === 6 || strlen($digits) === 7) { $yearLength = strlen($digits) - 4; $rocYear = (int) substr($digits, 0, $yearLength); $month = (int) substr($digits, $yearLength, 2); $day = (int) substr($digits, $yearLength + 2, 2); $year = $rocYear + 1911; if (checkdate($month, $day, $year)) { return sprintf('%04d-%02d-%02d', $year, $month, $day); } } return null; } protected function parseIdentityType(mixed $value): array { $raw = trim((string) $value); if ($raw === '') { return [null, null]; } if (str_contains($raw, '病友')) { return [Member::IDENTITY_PATIENT, null]; } if (str_contains($raw, '父母') || str_contains($raw, '家長')) { return [Member::IDENTITY_PARENT, null]; } if (str_contains($raw, '社會') || str_contains($raw, '學者') || str_contains($raw, '醫師')) { return [Member::IDENTITY_SOCIAL, null]; } if (str_contains($raw, '其他')) { return [Member::IDENTITY_OTHER, $raw]; } return [Member::IDENTITY_OTHER, $raw]; } protected function findExistingMember(string $email, ?string $memberNumber, string $nationalId, string $name): ?Member { if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $user = User::where('email', $email)->first(); if ($user && $user->member) { return $user->member; } } if ($memberNumber) { $member = Member::where('member_number', $memberNumber)->first(); if ($member) { return $member; } } if ($nationalId !== '') { $member = Member::where('national_id_hash', hash('sha256', $nationalId))->first(); if ($member) { return $member; } } $matches = Member::where('full_name', $name)->get(); if ($matches->count() === 1) { return $matches->first(); } if ($matches->count() > 1) { $nonPlaceholder = $matches->filter(function (Member $member) { return ! $this->isPlaceholderEmail($member->email); }); if ($nonPlaceholder->count() === 1) { return $nonPlaceholder->first(); } } return null; } protected function isPlaceholderEmail(?string $email): bool { if (! $email) { return true; } return str_ends_with($email, '@member.usher.org.tw'); } protected function buildUpdateData( Member $member, ?string $memberNumber, ?string $birthDate, ?string $gender, string $occupation, string $address, string $phone, string $nationalId, ?string $identityType, ?string $identityOtherText, ?string $appliedAt, ): array { $update = []; if ($memberNumber && ! $member->member_number) { $update['member_number'] = $memberNumber; } if ($birthDate && ! $member->birth_date) { $update['birth_date'] = $birthDate; } if ($gender && ! $member->gender) { $update['gender'] = $gender; } if ($occupation !== '' && ! $member->occupation) { $update['occupation'] = $occupation; } if ($address !== '' && ! $member->address_line_1) { $update['address_line_1'] = $address; } if ($phone !== '' && ! $member->phone) { $update['phone'] = $phone; } if ($nationalId !== '' && empty($member->national_id_encrypted)) { $update['national_id'] = $nationalId; } if ($identityType && ! $member->identity_type) { $update['identity_type'] = $identityType; } if ($identityOtherText && ! $member->identity_other_text) { $update['identity_other_text'] = $identityOtherText; } if ($appliedAt && ! $member->applied_at) { $update['applied_at'] = $appliedAt; } return $update; } 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); } }