CRM/backend/tests/Feature/InvoiceWorkflowTest.php

312 خطوط
14 KiB
PHP
خام پیوند همیشگی سرزنش تاریخچه

مخزن ambiguous runes header

راهنمای مخزن ambiguous runes توضیحات

<?php
namespace Tests\Feature;
use App\Models\Call;
use App\Models\Invoice;
use App\Models\InvoiceTemplate;
use App\Models\Lead;
use App\Models\Setting;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class InvoiceWorkflowTest extends TestCase
{
use RefreshDatabase;
public function test_agent_requests_invoice_from_won_lead_and_admin_issues_immutable_snapshot(): 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' => '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);
}
}