312 خطوط
14 KiB
PHP
312 خطوط
14 KiB
PHP
<?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);
|
||
}
|
||
}
|