522 lines
17 KiB
PHP
522 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Member;
|
|
use App\Models\MembershipPayment;
|
|
use App\Models\User;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
|
|
|
class ImportMembersCommand extends Command
|
|
{
|
|
protected $signature = 'members:import-roster
|
|
{roster : Path to member roster Excel file}
|
|
{--survey= : Optional path to survey Excel file with emails}
|
|
{--dry-run : Preview import without saving}';
|
|
|
|
protected $description = 'Import members from association Excel roster file';
|
|
|
|
protected array $surveyEmails = [];
|
|
|
|
public function handle(): int
|
|
{
|
|
$rosterPath = $this->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);
|
|
}
|
|
}
|