Commit 069533c5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add JulyPlayersImportSeeder for OC-Sport legacy player data

Imports Team (99) and Academy (93) players from HTML sheet one at a time
using existing services (InvoiceService, PaymentService). Creates Person →
Participant → Enrollment → Invoice → Payment per player, with installment
plan attachment for partial القيد payments. Cross-sheet duplicates are
written to storage/app/july_import_duplicates.json. Idempotent by name.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 41b7657c
<?php
namespace Database\Seeders;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Models\Person;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\Academy;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Imports July player data from نادى الشيخ زايد كشف بيانات اللاعبين (2).html
*
* Run: php artisan db:seed --class=JulyPlayersImportSeeder
* Safe to re-run: skips players whose name already exists as a Person in this academy.
*/
class JulyPlayersImportSeeder extends Seeder
{
private Academy $academy;
private Branch $branch;
private User $actor;
private InvoiceService $invoiceService;
private PaymentService $paymentService;
private int $imported = 0;
private int $skipped = 0;
private array $duplicates = [];
public function run(): void
{
$this->academy = Academy::first();
if (!$this->academy) {
$this->command->error('No academy found.');
return;
}
app()->instance('current_academy', $this->academy);
$this->branch = Branch::withoutGlobalScopes()
->where('academy_id', $this->academy->id)
->first();
$this->actor = User::where('academy_id', $this->academy->id)
->where('is_super_admin', true)
->first()
?? User::where('academy_id', $this->academy->id)->first();
$this->invoiceService = app(InvoiceService::class);
$this->paymentService = app(PaymentService::class);
$players = $this->parseHtml();
$this->command->info('Parsed ' . count($players['team']) . ' team players and ' . count($players['academy']) . ' academy players.');
// Track names seen in team sheet to detect cross-sheet duplicates
$seenNames = [];
// --- TEAM SHEET ---
$this->command->info('--- Processing Team sheet ---');
foreach ($players['team'] as $row) {
if (empty($row['name'])) {
continue;
}
$nameKey = $this->normalizeNameKey($row['name']);
if (isset($seenNames[$nameKey])) {
$this->duplicates[] = array_merge($row, ['sheet' => 'team', 'reason' => 'name already seen in team sheet']);
$this->skipped++;
continue;
}
$seenNames[$nameKey] = 'team';
$this->processPlayer($row, 'team');
}
// --- ACADEMY SHEET ---
$this->command->info('--- Processing Academy sheet ---');
foreach ($players['academy'] as $row) {
if (empty($row['name'])) {
continue;
}
$nameKey = $this->normalizeNameKey($row['name']);
if (isset($seenNames[$nameKey])) {
$this->duplicates[] = array_merge($row, ['sheet' => 'academy', 'reason' => 'name seen in team sheet']);
$this->skipped++;
continue;
}
$seenNames[$nameKey] = 'academy';
$this->processPlayer($row, 'academy');
}
$this->command->info("Done. Imported: {$this->imported} | Skipped/duplicates: {$this->skipped}");
if (!empty($this->duplicates)) {
$this->writeDuplicatesFile();
$this->command->warn(count($this->duplicates) . ' duplicates written to storage/app/july_import_duplicates.json');
}
}
// ─────────────────────────────────────────────────────────────────────────
// PARSE HTML
// ─────────────────────────────────────────────────────────────────────────
private function parseHtml(): array
{
$htmlPath = base_path('نادى الشيخ زايد كشف بيانات اللاعبين (2).html');
if (!file_exists($htmlPath)) {
$this->command->error('HTML file not found at: ' . $htmlPath);
return ['team' => [], 'academy' => []];
}
$content = file_get_contents($htmlPath);
$tables = preg_split('/<A NAME="table\d+">/', $content);
return [
'team' => $this->parseTeamTable($tables[1] ?? ''),
'academy' => $this->parseAcademyTable($tables[2] ?? ''),
];
}
private function parseTeamTable(string $html): array
{
$rows = [];
$rawRows = $this->extractRows($html);
foreach ($rawRows as $cells) {
// Skip header rows and empty rows
if (count($cells) < 4) {
continue;
}
if (in_array($cells[0], ['م', 'القسط الأول', '']) && !is_numeric($cells[0] ?? '')) {
continue;
}
$name = trim($cells[1] ?? '');
if (empty($name) || mb_strlen($name) < 2) {
continue;
}
// Columns: م | الاسم | فريق(year) | موبايل | رقم_عضوية | اشتراك_يوليو | آلية_الدفع | القيد | الطقم
// Note: row 9 (Ahmed Haitham) has 9 cells — القيد=8000 الطقم=1200 in positions 7,8
// but some rows have 11 cells with installment breakdown at 8,9,10
$rows[] = [
'name' => $name,
'birth_year' => trim($cells[2] ?? ''),
'phone' => trim($cells[3] ?? ''),
'membership_id'=> trim($cells[4] ?? ''),
'subscription' => $this->parseAmount($cells[5] ?? ''),
'pay_method' => $this->normalizeMethod($cells[6] ?? ''),
'qaid' => $this->parseAmount($cells[7] ?? ''),
'kit' => $this->parseAmount($cells[count($cells) - 1] ?? ''),
'sheet' => 'team',
];
}
return $rows;
}
private function parseAcademyTable(string $html): array
{
$rows = [];
$rawRows = $this->extractRows($html);
foreach ($rawRows as $cells) {
if (count($cells) < 4) {
continue;
}
if ($cells[0] === 'م' || $cells[0] === '') {
continue;
}
$name = trim($cells[1] ?? '');
if (empty($name) || mb_strlen($name) < 2) {
continue;
}
// Columns: م | الاسم | مواليد | موبايل | رقم_عضوية | اشتراك_يوليو | آلية_الدفع | تاريخ_الدفع | الطقم
$rows[] = [
'name' => $name,
'birth_year' => trim($cells[2] ?? ''),
'phone' => trim($cells[3] ?? ''),
'membership_id'=> trim($cells[4] ?? ''),
'subscription' => $this->parseAmount($cells[5] ?? ''),
'pay_method' => $this->normalizeMethod($cells[6] ?? ''),
'pay_date' => $this->parseDate($cells[7] ?? ''),
'kit' => $this->parseAmount($cells[8] ?? ''),
'qaid' => 0, // Academy players don't pay القيد
'sheet' => 'academy',
];
}
return $rows;
}
private function extractRows(string $html): array
{
$rows = [];
$rawRows = [];
preg_match_all('/<tr>(.*?)<\/tr>/s', $html, $matches);
foreach ($matches[1] as $rowHtml) {
preg_match_all('/<td[^>]*>(.*?)<\/td>/s', $rowHtml, $cellMatches);
$cells = array_map(
fn ($c) => trim(strip_tags(html_entity_decode($c, ENT_QUOTES | ENT_HTML5, 'UTF-8'))),
$cellMatches[1]
);
$rawRows[] = $cells;
}
return $rawRows;
}
// ─────────────────────────────────────────────────────────────────────────
// PROCESS ONE PLAYER
// ─────────────────────────────────────────────────────────────────────────
private function processPlayer(array $row, string $sheet): void
{
try {
DB::transaction(function () use ($row, $sheet) {
// 1. Find or create Person
$participant = $this->findOrCreateParticipant($row);
// 2. Find correct group
$group = $this->resolveGroup($row, $sheet);
if (!$group) {
$this->command->warn(" No group found for '{$row['name']}' (year={$row['birth_year']}, sheet={$sheet}) — enrolled without group");
}
// 3. Enroll (if not already enrolled in this group)
if ($group) {
$this->ensureEnrollment($participant, $group, $row);
}
// 4. July subscription invoice + payment
if ($row['subscription'] > 0) {
$this->createSubscriptionInvoice($participant, $row, $group);
}
// 5. القيد (annual federation fee) — Team sheet only
if (($row['qaid'] ?? 0) > 0) {
$this->createQaidInvoice($participant, $row);
}
// 6. Kit (شنطة ملابس)
if (($row['kit'] ?? 0) > 0) {
$this->createKitInvoice($participant, $row);
}
$this->imported++;
$this->command->line(" ✓ {$row['name']}");
});
} catch (\Throwable $e) {
$this->command->error(" ✗ {$row['name']}: " . $e->getMessage());
$this->skipped++;
}
}
// ─────────────────────────────────────────────────────────────────────────
// FIND OR CREATE PARTICIPANT
// ─────────────────────────────────────────────────────────────────────────
private function findOrCreateParticipant(array $row): Participant
{
$academyId = $this->academy->id;
// Try to find existing person by name within this academy
$existing = Person::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('name_ar', $row['name'])
->first();
if ($existing && $existing->participant) {
return $existing->participant;
}
// Determine membership type
$membershipId = $row['membership_id'] ?? '';
$isMember = is_numeric($membershipId) && (int) $membershipId > 0;
$membershipType = $isMember ? 'member' : 'non_member';
// Birth year → approximate date of birth (Jan 1 of that year)
$birthYear = (int) ($row['birth_year'] ?? 0);
$dob = ($birthYear > 1990 && $birthYear <= 2026)
? "{$birthYear}-01-01"
: null;
// Create Person
$person = Person::create([
'academy_id' => $academyId,
'name_ar' => $row['name'],
'name' => null,
'phone' => $row['phone'] ?: null,
'gender' => 'male', // All players assumed male
'date_of_birth' => $dob,
'created_by' => $this->actor->id,
]);
// Create Participant
$participant = Participant::create([
'academy_id' => $academyId,
'branch_id' => $this->branch->id,
'person_id' => $person->id,
'participant_number' => $this->generateParticipantNumber(),
'registration_date' => '2026-07-01',
'registration_source' => 'walk_in',
'status' => 'active',
'status_changed_at' => now(),
'membership_type' => $membershipType,
'membership_id' => $isMember ? $membershipId : null,
'metadata' => [],
'created_by' => $this->actor->id,
]);
return $participant;
}
// ─────────────────────────────────────────────────────────────────────────
// RESOLVE GROUP
// ─────────────────────────────────────────────────────────────────────────
private function resolveGroup(array $row, string $sheet): ?TrainingGroup
{
$year = (int) ($row['birth_year'] ?? 0);
if ($year < 2000 || $year > 2025) {
return null;
}
// Find program whose name contains the birth year
$programs = TrainingProgram::withoutGlobalScopes()
->where('academy_id', $this->academy->id)
->get();
$program = null;
if ($sheet === 'team') {
// Team sheet: look for "فريق YEAR" programs
foreach ($programs as $p) {
if (str_contains($p->name_ar, (string) $year) && str_contains($p->name_ar, 'فريق')) {
$program = $p;
break;
}
}
// Fallback: any program containing the year
if (!$program) {
foreach ($programs as $p) {
if (str_contains($p->name_ar, (string) $year)) {
$program = $p;
break;
}
}
}
} else {
// Academy sheet: look for "أكاديمية YEAR" programs
foreach ($programs as $p) {
if (str_contains($p->name_ar, (string) $year) && str_contains($p->name_ar, 'أكاديمية')) {
$program = $p;
break;
}
}
// Fallback
if (!$program) {
foreach ($programs as $p) {
if (str_contains($p->name_ar, (string) $year)) {
$program = $p;
break;
}
}
}
}
if (!$program) {
return null;
}
// Get the default (first) group for this program
return TrainingGroup::withoutGlobalScopes()
->where('academy_id', $this->academy->id)
->where('training_program_id', $program->id)
->first();
}
// ─────────────────────────────────────────────────────────────────────────
// ENSURE ENROLLMENT
// ─────────────────────────────────────────────────────────────────────────
private function ensureEnrollment(Participant $participant, TrainingGroup $group, array $row): Enrollment
{
$existing = Enrollment::withoutGlobalScopes()
->where('participant_id', $participant->id)
->where('training_group_id', $group->id)
->whereIn('status', ['pending', 'active'])
->first();
if ($existing) {
return $existing;
}
$enrollment = Enrollment::create([
'academy_id' => $this->academy->id,
'participant_id' => $participant->id,
'training_group_id' => $group->id,
'training_program_id' => $group->training_program_id,
'enrollment_date' => '2026-07-01',
'start_date' => '2026-07-01',
'status' => 'active',
'enrolled_by' => $this->actor->id,
'payment_status' => 'pending',
]);
// Increment group count
$group->increment('current_count');
return $enrollment;
}
// ─────────────────────────────────────────────────────────────────────────
// SUBSCRIPTION INVOICE (July fee)
// ─────────────────────────────────────────────────────────────────────────
private function createSubscriptionInvoice(Participant $participant, array $row, ?TrainingGroup $group): void
{
$amountPiasters = $row['subscription'] * 100;
$method = $row['pay_method'];
$payDate = $row['pay_date'] ?? '2026-07-01';
$invoice = $this->invoiceService->create([
'academy_id' => $this->academy->id,
'type' => 'standard',
'billable_type' => Participant::class,
'billable_id' => $participant->id,
'contact_name' => $participant->person->name_ar,
'subtotal_amount'=> $amountPiasters,
'discount_amount'=> 0,
'tax_amount' => 0,
'service_fee_amount' => 0,
'total_amount' => $amountPiasters,
'currency' => 'EGP',
'issue_date' => $payDate,
'due_date' => $payDate,
'notes' => 'اشتراك يوليو 2026' . ($group ? ' — ' . $group->name_ar : ''),
], [
[
'description' => 'اشتراك يوليو 2026',
'quantity' => 1,
'unit_price' => $amountPiasters,
'discount_amount' => 0,
'tax_amount' => 0,
'metadata' => [],
],
], $this->actor);
$invoice->update(['status' => InvoiceStatus::Sent]);
// Record payment
$this->paymentService->recordPayment([
'academy_id' => $this->academy->id,
'branch_id' => $this->branch->id,
'invoice_id' => $invoice->id,
'direction' => 'inbound',
'payer_type' => Participant::class,
'payer_id' => $participant->id,
'method' => $method,
'amount' => $amountPiasters,
'currency' => 'EGP',
'payment_date' => $payDate,
'reference' => 'JUL26-SUB-' . $participant->id,
'notes' => 'اشتراك يوليو 2026 — استيراد',
], $this->actor);
}
// ─────────────────────────────────────────────────────────────────────────
// QAID INVOICE (annual federation fee)
// ─────────────────────────────────────────────────────────────────────────
private function createQaidInvoice(Participant $participant, array $row): void
{
$qaidProduct = Product::withoutGlobalScopes()
->where('academy_id', $this->academy->id)
->where('billing_cycle', 'annual')
->where('is_essential', true)
->first();
if (!$qaidProduct) {
$this->command->warn(" No annual essential product found for قيد, skipping");
return;
}
$membershipType = $participant->membership_type->value;
$fullPricePiasters = $qaidProduct->priceForTier($membershipType);
$paidPiasters = $row['qaid'] * 100;
$method = $row['pay_method'];
// Determine installment plan based on paid amount
$planTemplate = null;
if ($paidPiasters < $fullPricePiasters) {
// Partial payment — find matching plan
$planTemplate = ProductInstallmentPlan::withoutGlobalScopes()
->where('product_id', $qaidProduct->id)
->where('is_active', true)
->get()
->first(function ($plan) use ($membershipType, $paidPiasters, $qaidProduct) {
$tier = $plan->membership_tier;
if ($tier !== 'any' && $tier !== $membershipType) {
return false;
}
// First slot should match paid amount
$schedule = $plan->buildSchedule($qaidProduct->priceForTier($membershipType));
return isset($schedule[0]) && abs($schedule[0] - $paidPiasters) < 100;
});
}
$invoice = $this->invoiceService->create([
'academy_id' => $this->academy->id,
'type' => 'standard',
'billable_type' => Participant::class,
'billable_id' => $participant->id,
'contact_name' => $participant->person->name_ar,
'subtotal_amount' => $fullPricePiasters,
'discount_amount' => 0,
'tax_amount' => 0,
'service_fee_amount' => 0,
'total_amount' => $fullPricePiasters,
'currency' => 'EGP',
'issue_date' => '2026-07-01',
'due_date' => '2026-07-01',
'notes' => 'قيد اشتراك فريق اتحاد الكرة — استيراد يوليو 2026',
], [
[
'description' => $qaidProduct->name_ar,
'quantity' => 1,
'unit_price' => $fullPricePiasters,
'discount_amount' => 0,
'tax_amount' => 0,
'itemable_type' => Product::class,
'itemable_id' => $qaidProduct->id,
'metadata' => [],
],
], $this->actor);
$invoice->update(['status' => InvoiceStatus::Sent]);
// Record the payment that was actually made
$this->paymentService->recordPayment([
'academy_id' => $this->academy->id,
'branch_id' => $this->branch->id,
'invoice_id' => $invoice->id,
'direction' => 'inbound',
'payer_type' => Participant::class,
'payer_id' => $participant->id,
'method' => $method,
'amount' => $paidPiasters,
'currency' => 'EGP',
'payment_date' => '2026-07-01',
'reference' => 'JUL26-QAID-' . $participant->id,
'notes' => 'قيد اتحاد — استيراد',
], $this->actor);
// Create installment plan if partial payment
if ($planTemplate && $paidPiasters < $fullPricePiasters) {
$schedule = $planTemplate->buildSchedule($fullPricePiasters);
$regularAmt = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];
$paymentPlan = PaymentPlan::create([
'academy_id' => $this->academy->id,
'invoice_id' => $invoice->id,
'status' => 'active',
'total_installments' => $planTemplate->installments,
'paid_installments' => 1,
'installment_amount' => $regularAmt,
'frequency' => $planTemplate->frequency,
'start_date' => '2026-07-01',
'next_due_date' => '2026-08-01',
'notes' => $planTemplate->label_ar . ' — استيراد يوليو',
]);
$dueDate = now()->setDate(2026, 7, 1);
foreach ($schedule as $idx => $amount) {
Installment::create([
'payment_plan_id' => $paymentPlan->id,
'sequence' => $idx + 1,
'amount' => $amount,
'due_date' => $dueDate->toDateString(),
'status' => $idx === 0 ? 'paid' : 'pending',
'paid_at' => $idx === 0 ? now() : null,
]);
$dueDate = match ($planTemplate->frequency) {
'weekly' => $dueDate->copy()->addWeek(),
'biweekly' => $dueDate->copy()->addWeeks(2),
'quarterly' => $dueDate->copy()->addMonths(3),
default => $dueDate->copy()->addMonth(),
};
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// KIT INVOICE (شنطة ملابس)
// ─────────────────────────────────────────────────────────────────────────
private function createKitInvoice(Participant $participant, array $row): void
{
$kitProduct = Product::withoutGlobalScopes()
->where('academy_id', $this->academy->id)
->where('billing_cycle', 'one_time')
->where('is_essential', true)
->first();
if (!$kitProduct) {
$this->command->warn(" No one_time essential product found for kit, skipping");
return;
}
$paidPiasters = $row['kit'] * 100;
$method = $row['pay_method'];
$invoice = $this->invoiceService->create([
'academy_id' => $this->academy->id,
'type' => 'standard',
'billable_type' => Participant::class,
'billable_id' => $participant->id,
'contact_name' => $participant->person->name_ar,
'subtotal_amount' => $paidPiasters,
'discount_amount' => 0,
'tax_amount' => 0,
'service_fee_amount' => 0,
'total_amount' => $paidPiasters,
'currency' => 'EGP',
'issue_date' => '2026-07-01',
'due_date' => '2026-07-01',
'notes' => 'شنطة ملابس — استيراد يوليو 2026',
], [
[
'description' => $kitProduct->name_ar,
'quantity' => 1,
'unit_price' => $paidPiasters,
'discount_amount' => 0,
'tax_amount' => 0,
'itemable_type' => Product::class,
'itemable_id' => $kitProduct->id,
'metadata' => [],
],
], $this->actor);
$invoice->update(['status' => InvoiceStatus::Sent]);
$this->paymentService->recordPayment([
'academy_id' => $this->academy->id,
'branch_id' => $this->branch->id,
'invoice_id' => $invoice->id,
'direction' => 'inbound',
'payer_type' => Participant::class,
'payer_id' => $participant->id,
'method' => $method,
'amount' => $paidPiasters,
'currency' => 'EGP',
'payment_date' => '2026-07-01',
'reference' => 'JUL26-KIT-' . $participant->id,
'notes' => 'شنطة ملابس — استيراد',
], $this->actor);
}
// ─────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────
private function parseAmount(string $raw): int
{
// Handle "800(باقي 100)" → 800
$raw = preg_replace('/\(.*?\)/', '', $raw);
$raw = preg_replace('/[^0-9.]/', '', $raw);
return (int) $raw;
}
private function normalizeMethod(string $raw): string
{
$raw = strtolower(trim($raw));
return match (true) {
$raw === 'cash' => 'cash',
in_array($raw, ['visa', 'card']) => 'card',
in_array($raw, ['instapay', 'online'])=> 'online',
default => 'other',
};
}
private function parseDate(string $raw): string
{
// "1\7\2026" or "5\7\2026"
$raw = str_replace('\\', '/', $raw);
$parts = explode('/', $raw);
if (count($parts) === 3) {
$d = str_pad($parts[0], 2, '0', STR_PAD_LEFT);
$m = str_pad($parts[1], 2, '0', STR_PAD_LEFT);
$y = $parts[2];
return "{$y}-{$m}-{$d}";
}
return '2026-07-01';
}
private function normalizeNameKey(string $name): string
{
return preg_replace('/\s+/', ' ', trim($name));
}
private function generateParticipantNumber(): string
{
$count = Participant::withoutGlobalScopes()
->where('academy_id', $this->academy->id)
->count();
return 'OC-' . str_pad($count + 1, 5, '0', STR_PAD_LEFT);
}
private function writeDuplicatesFile(): void
{
$path = storage_path('app/july_import_duplicates.json');
file_put_contents($path, json_encode($this->duplicates, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment