Add phone login support and member import functionality
Features: - Support login via phone number or email (LoginRequest) - Add members:import-roster command for Excel roster import - Merge survey emails with roster data Code Quality (Phase 1-4): - Add database locking for balance calculation - Add self-approval checks for finance workflow - Create service layer (FinanceDocumentApprovalService, PaymentVerificationService) - Add HasAccountingEntries and HasApprovalWorkflow traits - Create FormRequest classes for validation - Add status-badge component - Define authorization gates in AuthServiceProvider - Add accounting config file Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
295
app/Console/Commands/ImportMembersCommand.php
Normal file
295
app/Console/Commands/ImportMembersCommand.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?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;
|
||||
|
||||
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
|
||||
];
|
||||
|
||||
$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(),
|
||||
'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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user