seed(RolePermissionSeeder::class); $agent = User::factory()->create(['is_active' => true]); $agent->assignRole('agent'); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $lead = Lead::create([ 'company' => 'فروشگاه نمونه', 'first_name' => 'علی', 'last_name' => 'احمدی', 'phone' => '09120000000', 'assigned_to' => $agent->id, 'is_unassigned' => false, 'final_result' => 'موفق', 'sold_product' => 'اشتراک سالانه', 'deal_value' => 12500000, ]); $this->actingAs($agent)->getJson('/api/invoice-template-fields') ->assertOk() ->assertJsonFragment([ 'customer.name' => 'نام خریدار', 'items.1.description' => 'ردیف ۱ — شرح', 'items.7.line_total' => 'ردیف ۷ — مبلغ کل', ]); $invoiceId = $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice", [ 'page_width_mm' => 297, 'page_height_mm' => 210, 'resolved_fields' => ['number' => ['x' => 12, 'y' => 9, 'width' => 28]], ]) ->assertCreated() ->assertJsonPath('status', 'pending_approval') ->assertJsonPath('customer_snapshot.company', 'فروشگاه نمونه') ->assertJsonPath('page_width_mm', 297) ->assertJsonPath('page_height_mm', 210) ->assertJsonPath('resolved_fields.number.x', 12) ->json('id'); $this->assertDatabaseHas('invoices', ['id' => $invoiceId, 'lead_id' => $lead->id, 'created_by' => $agent->id]); $lead->update(['company' => 'نام ویرایش‌شده']); $this->actingAs($admin)->postJson("/api/invoices/{$invoiceId}/issue") ->assertOk() ->assertJsonPath('status', 'issued') ->assertJsonPath('customer_snapshot.company', 'فروشگاه نمونه') ->assertJsonPath('page_width_mm', 297) ->assertJsonPath('resolved_fields.number.x', 12); $this->assertNotNull(Invoice::findOrFail($invoiceId)->issued_at); } public function test_non_won_lead_cannot_create_invoice(): void { $this->seed(RolePermissionSeeder::class); $agent = User::factory()->create(['is_active' => true]); $agent->assignRole('agent'); $lead = Lead::create([ 'company' => 'لید باز', 'first_name' => 'تست', 'last_name' => 'باز', 'phone' => '09121111111', 'assigned_to' => $agent->id, 'is_unassigned' => false, ]); $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice") ->assertUnprocessable() ->assertJsonFragment(['message' => 'فقط لید منجر به فروش قابل تبدیل به فاکتور است.']); } public function test_invoice_center_summary_uses_scoped_live_financial_data(): void { $this->seed(RolePermissionSeeder::class); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $lead = Lead::create(['company' => 'خریدار خلاصه', 'first_name' => 'تست', 'last_name' => 'مالی', 'phone' => '09124445555']); $base = [ 'lead_id' => $lead->id, 'created_by' => $admin->id, 'currency' => 'IRR', 'customer_snapshot' => [], 'lead_snapshot' => [], 'items' => [], 'resolved_fields' => [], 'subtotal' => 0, 'discount' => 0, 'tax' => 0, ]; Invoice::create($base + ['number' => 'INV-SUM-1', 'status' => 'issued', 'total' => 100000, 'paid_amount' => 40000, 'payment_status' => 'partial']); Invoice::create($base + ['number' => 'INV-SUM-2', 'status' => 'pending_approval', 'total' => 50000, 'paid_amount' => 0, 'payment_status' => 'unpaid']); $this->actingAs($admin)->getJson('/api/invoices-summary') ->assertOk() ->assertJsonPath('total', 2) ->assertJsonPath('counts.issued', 1) ->assertJsonPath('counts.pending_approval', 1) ->assertJsonPath('issued_total', 100000) ->assertJsonPath('paid_total', 40000) ->assertJsonPath('outstanding_total', 60000); } public function test_authorized_user_can_download_a_real_a4_word_invoice(): void { $this->seed(RolePermissionSeeder::class); $admin = User::factory()->create(['is_active' => true, 'name' => 'فروشنده نمونه']); $admin->assignRole('admin'); $lead = Lead::create([ 'company' => 'خریدار Word', 'first_name' => 'علی', 'last_name' => 'آزمایشی', 'phone' => '09127778888', 'final_result' => 'موفق', ]); $invoiceId = $this->actingAs($admin)->postJson("/api/leads/{$lead->id}/invoice", [ 'seller_snapshot' => ['name' => 'شرکت فروشنده', 'economic_code' => '123456'], 'items' => [['description' => 'خدمت مشاوره', 'unit' => 'ساعت', 'quantity' => 2, 'unit_price' => 500000]], 'payment_terms' => 'پرداخت طی هفت روز', ])->assertCreated()->json('id'); $this->actingAs($admin) ->get("/api/invoices/{$invoiceId}/word") ->assertOk() ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') ->assertDownload(); } public function test_structured_invoice_item_rows_are_resolved_into_template_positions(): void { $this->seed(RolePermissionSeeder::class); $agent = User::factory()->create(['is_active' => true]); $agent->assignRole('agent'); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $lead = Lead::create([ 'company' => 'خریدار ردیفی', 'first_name' => 'تست', 'last_name' => 'اقلام', 'phone' => '09125556666', 'assigned_to' => $agent->id, 'is_unassigned' => false, 'final_result' => 'موفق', ]); $template = InvoiceTemplate::create([ 'name' => 'قالب ردیفی', 'is_default' => true, 'is_active' => true, 'created_by' => $admin->id, 'layout' => [ ['id' => 'row_1_description', 'label' => 'شرح ردیف اول', 'source' => 'items.1.description', 'x' => 9, 'y' => 51, 'width' => 22], ['id' => 'row_2_total', 'label' => 'جمع ردیف دوم', 'source' => 'items.2.line_total', 'x' => 51, 'y' => 55, 'width' => 9], ], ]); $this->actingAs($agent)->postJson("/api/leads/{$lead->id}/invoice", [ 'invoice_template_id' => $template->id, 'items' => [ ['description' => 'محصول اول', 'quantity' => 1, 'unit_price' => 1000], ['description' => 'محصول دوم', 'quantity' => 2, 'unit_price' => 2500], ], ]) ->assertCreated() ->assertJsonPath('resolved_fields.row_1_description.value', 'محصول اول') ->assertJsonPath('resolved_fields.row_2_total.value', '5,000'); } public function test_admin_can_create_a5_template_and_remove_its_background(): void { Storage::fake('local'); $this->seed(RolePermissionSeeder::class); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $templateId = $this->actingAs($admin)->postJson('/api/invoice-templates', [ 'name' => 'قالب A5 افقی', 'page_width_mm' => 210, 'page_height_mm' => 148, 'base_type' => 'letterhead', 'background_settings' => ['fit' => 'contain', 'top' => 0, 'height' => 30], 'layout' => [[ 'id' => 'invoice_number', 'label' => 'شماره فاکتور', 'source' => 'invoice.number', 'x' => 68, 'y' => 7, 'width' => 25, 'font_size' => 12, 'align' => 'right', ]], 'is_active' => true, ]) ->assertCreated() ->assertJsonPath('page_width_mm', 210) ->assertJsonPath('page_height_mm', 148) ->assertJsonPath('base_type', 'letterhead') ->assertJsonPath('background_settings.height', 30) ->json('id'); $this->actingAs($admin)->post("/api/invoice-templates/{$templateId}/background", [ 'file' => UploadedFile::fake()->createWithContent('rendered-page.png', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')), 'source_file' => UploadedFile::fake()->createWithContent('letterhead.pdf', "%PDF-1.4\n%%EOF"), 'source_name' => 'letterhead.pdf', 'source_mime' => 'application/pdf', ], ['Accept' => 'application/json']) ->assertOk() ->assertJsonPath('source_name', 'letterhead.pdf') ->assertJsonPath('source_mime', 'application/pdf'); $storedPath = InvoiceTemplate::findOrFail($templateId)->background_path; $sourcePath = InvoiceTemplate::findOrFail($templateId)->source_path; Storage::disk('local')->assertExists($storedPath); Storage::disk('local')->assertExists($sourcePath); $this->actingAs($admin)->deleteJson("/api/invoice-templates/{$templateId}/background") ->assertOk() ->assertJsonPath('background_path', null) ->assertJsonPath('background_url', null); Storage::disk('local')->assertMissing($storedPath); Storage::disk('local')->assertMissing($sourcePath); } public function test_template_deletion_cleans_files_promotes_default_and_preserves_used_templates(): void { Storage::fake('local'); $this->seed(RolePermissionSeeder::class); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); Storage::disk('local')->put('invoice-templates/default.png', 'image'); $default = InvoiceTemplate::create([ 'name' => 'قالب پیش‌فرض', 'layout' => [], 'is_default' => true, 'is_active' => true, 'background_path' => 'invoice-templates/default.png', 'background_name' => 'default.png', 'background_mime' => 'image/png', 'created_by' => $admin->id, ]); $replacement = InvoiceTemplate::create([ 'name' => 'قالب جایگزین', 'layout' => [], 'is_default' => false, 'is_active' => true, 'created_by' => $admin->id, ]); $this->actingAs($admin)->deleteJson("/api/invoice-templates/{$default->id}") ->assertOk() ->assertJsonPath('message', 'قالب حذف شد.'); $this->assertDatabaseMissing('invoice_templates', ['id' => $default->id]); $this->assertTrue($replacement->fresh()->is_default); Storage::disk('local')->assertMissing('invoice-templates/default.png'); $used = InvoiceTemplate::create([ 'name' => 'قالب استفاده‌شده', 'layout' => [], 'is_default' => false, 'is_active' => true, 'created_by' => $admin->id, ]); $lead = Lead::create(['company' => 'نمونه', 'first_name' => 'کاربر', 'last_name' => 'آزمایشی', 'phone' => '09123334444']); Invoice::create([ 'lead_id' => $lead->id, 'invoice_template_id' => $used->id, 'created_by' => $admin->id, 'status' => 'issued', 'currency' => 'IRR', 'customer_snapshot' => [], 'lead_snapshot' => [], 'items' => [], 'resolved_fields' => [], 'subtotal' => 0, 'discount' => 0, 'tax' => 0, 'total' => 0, ]); $this->actingAs($admin)->deleteJson("/api/invoice-templates/{$used->id}") ->assertUnprocessable() ->assertJsonFragment(['message' => 'این قالب در فاکتورهای ثبت‌شده استفاده شده است؛ به‌جای حذف، آن را غیرفعال کنید.']); $this->assertDatabaseHas('invoice_templates', ['id' => $used->id]); } public function test_signed_voip_webhook_updates_real_call_lifecycle(): void { $secret = 'test-webhook-secret'; Setting::create(['key' => 'voip_webhook_secret', 'value' => $secret, 'group' => 'voip', 'type' => 'string']); $user = User::factory()->create(); $lead = Lead::create(['company' => 'Acme', 'first_name' => 'A', 'last_name' => 'B', 'phone' => '09122222222']); $call = Call::create([ 'lead_id' => $lead->id, 'user_id' => $user->id, 'direction' => 'outbound', 'phone' => $lead->phone, 'provider_call_id' => 'provider-123', 'provider_status' => 'ringing', 'is_manual' => false, ]); $payload = json_encode([ 'provider_call_id' => 'provider-123', 'status' => 'completed', 'duration_seconds' => 93, 'recording_url' => 'https://pbx.example.test/recordings/123.mp3', ], JSON_UNESCAPED_SLASHES); $signature = hash_hmac('sha256', $payload, $secret); $response = $this->call('POST', '/api/voip/webhook', [], [], [], [ 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_VOIP_SIGNATURE' => $signature, ], $payload); $response->assertOk()->assertJsonPath('ok', true); $call->refresh(); $this->assertSame('completed', $call->provider_status); $this->assertSame(93, $call->duration); $this->assertNotNull($call->ended_at); $this->assertSame('https://pbx.example.test/recordings/123.mp3', $call->recording_url); } }