'شماره فاکتور', 'invoice.issue_date' => 'تاریخ صدور', 'customer.name' => 'نام خریدار', 'customer.company' => 'نام کسب‌وکار', 'customer.phone' => 'تلفن خریدار', 'customer.email' => 'ایمیل خریدار', 'customer.address' => 'نشانی خریدار', 'lead.product' => 'محصول یا خدمت', 'lead.contract_date' => 'تاریخ قرارداد', 'lead.payment_status' => 'وضعیت پرداخت', 'invoice.items_summary' => 'خلاصه اقلام', 'items.1.description' => 'ردیف ۱ — شرح', 'items.1.quantity' => 'ردیف ۱ — تعداد', 'items.1.unit_price' => 'ردیف ۱ — مبلغ واحد', 'items.1.line_total' => 'ردیف ۱ — مبلغ کل', 'items.2.description' => 'ردیف ۲ — شرح', 'items.2.quantity' => 'ردیف ۲ — تعداد', 'items.2.unit_price' => 'ردیف ۲ — مبلغ واحد', 'items.2.line_total' => 'ردیف ۲ — مبلغ کل', 'items.3.description' => 'ردیف ۳ — شرح', 'items.3.quantity' => 'ردیف ۳ — تعداد', 'items.3.unit_price' => 'ردیف ۳ — مبلغ واحد', 'items.3.line_total' => 'ردیف ۳ — مبلغ کل', 'items.4.description' => 'ردیف ۴ — شرح', 'items.4.quantity' => 'ردیف ۴ — تعداد', 'items.4.unit_price' => 'ردیف ۴ — مبلغ واحد', 'items.4.line_total' => 'ردیف ۴ — مبلغ کل', 'items.5.description' => 'ردیف ۵ — شرح', 'items.5.quantity' => 'ردیف ۵ — تعداد', 'items.5.unit_price' => 'ردیف ۵ — مبلغ واحد', 'items.5.line_total' => 'ردیف ۵ — مبلغ کل', 'items.6.description' => 'ردیف ۶ — شرح', 'items.6.quantity' => 'ردیف ۶ — تعداد', 'items.6.unit_price' => 'ردیف ۶ — مبلغ واحد', 'items.6.line_total' => 'ردیف ۶ — مبلغ کل', 'items.7.description' => 'ردیف ۷ — شرح', 'items.7.quantity' => 'ردیف ۷ — تعداد', 'items.7.unit_price' => 'ردیف ۷ — مبلغ واحد', 'items.7.line_total' => 'ردیف ۷ — مبلغ کل', 'invoice.subtotal' => 'جمع قبل از مالیات', 'invoice.discount' => 'تخفیف', 'invoice.tax' => 'مالیات', 'invoice.total' => 'مبلغ قابل پرداخت', 'invoice.paid_amount' => 'مبلغ پرداخت‌شده', 'invoice.balance_due' => 'مانده قابل پرداخت', 'invoice.currency' => 'واحد پول', 'invoice.notes' => 'توضیحات فاکتور', 'seller.name' => 'نام صادرکننده', ]; public function createFromLead(Lead $lead, User $actor, array $payload = []): Invoice { abort_unless($lead->final_result === 'موفق', 422, 'فقط لید منجر به فروش قابل تبدیل به فاکتور است.'); $duplicate = Invoice::where('lead_id', $lead->id) ->whereIn('status', ['draft', 'pending_approval', 'approved', 'issued']) ->latest('id') ->first(); if ($duplicate) { abort(409, 'برای این لید قبلاً فاکتور فعال ایجاد شده است.'); } $template = ! empty($payload['invoice_template_id']) ? InvoiceTemplate::where('is_active', true)->findOrFail($payload['invoice_template_id']) : $this->defaultTemplate($actor); $items = $this->normalizeItems($payload['items'] ?? [[ 'description' => $lead->sold_product ?: $lead->product_interest ?: 'محصول / خدمت', 'quantity' => 1, 'unit_price' => (float) ($lead->deal_value ?? 0), ]]); $totals = $this->calculateTotals($items, (float) ($payload['discount'] ?? 0), (float) ($payload['tax'] ?? 0)); $paidAmount = $this->normalizePaidAmount((float) ($payload['paid_amount'] ?? 0), $totals['total']); $customer = array_merge($this->customerSnapshot($lead), $payload['customer_snapshot'] ?? []); $invoice = DB::transaction(function () use ($lead, $actor, $payload, $template, $items, $totals, $customer, $paidAmount): Invoice { $invoice = Invoice::create([ 'lead_id' => $lead->id, 'invoice_template_id' => $template->id, 'created_by' => $actor->id, 'status' => 'pending_approval', 'currency' => $payload['currency'] ?? 'IRR', 'customer_snapshot' => $customer, 'seller_snapshot' => $payload['seller_snapshot'] ?? ['name' => $actor->name], 'lead_snapshot' => $this->leadSnapshot($lead), 'items' => $items, 'subtotal' => $totals['subtotal'], 'discount' => $totals['discount'], 'tax' => $totals['tax'], 'total' => $totals['total'], 'paid_amount' => $paidAmount, 'payment_status' => $this->paymentStatus($paidAmount, $totals['total']), 'notes' => $payload['notes'] ?? $lead->customer_notes, 'payment_terms' => $payload['payment_terms'] ?? null, 'due_date' => $payload['due_date'] ?? null, 'page_width_mm' => $payload['page_width_mm'] ?? $template->page_width_mm ?? 210, 'page_height_mm' => $payload['page_height_mm'] ?? $template->page_height_mm ?? 297, ]); $invoice->update(['number' => 'INV-'.now()->format('Y').'-'.str_pad((string) $invoice->id, 6, '0', STR_PAD_LEFT)]); $resolved = $this->resolveTemplateFields($invoice->fresh(), $template, $actor); if (! empty($payload['resolved_fields'])) { $resolved = $this->applyLayoutOverrides($resolved, $payload['resolved_fields']); } $invoice->update(['resolved_fields' => $resolved]); ActivityLogger::log('invoice_requested', "Invoice {$invoice->number} requested from lead {$lead->id}", $invoice); return $invoice->fresh($this->relations()); }); User::permission('approve_invoices')->where('is_active', true)->whereKeyNot($actor->id)->each(function (User $approver) use ($invoice, $actor): void { NotificationService::send($approver->id, 'درخواست تأیید فاکتور', "فاکتور {$invoice->number} توسط {$actor->name} برای تأیید ارسال شد", 'invoice_approval', [ 'invoice_id' => $invoice->id, 'url' => "/invoices?invoice={$invoice->id}", ]); }); return $invoice; } public function updateDraft(Invoice $invoice, array $payload, User $actor): Invoice { $wasRejected = $invoice->status === 'rejected'; $items = array_key_exists('items', $payload) ? $this->normalizeItems($payload['items']) : $invoice->items; $discount = (float) ($payload['discount'] ?? $invoice->discount); $tax = (float) ($payload['tax'] ?? $invoice->tax); $totals = $this->calculateTotals($items, $discount, $tax); $paidAmount = $this->normalizePaidAmount((float) ($payload['paid_amount'] ?? $invoice->paid_amount), $totals['total']); $template = ! empty($payload['invoice_template_id']) ? InvoiceTemplate::where('is_active', true)->findOrFail($payload['invoice_template_id']) : $invoice->template; $invoice->fill([ 'invoice_template_id' => $template?->id, 'customer_snapshot' => $payload['customer_snapshot'] ?? $invoice->customer_snapshot, 'seller_snapshot' => $payload['seller_snapshot'] ?? $invoice->seller_snapshot, 'items' => $items, 'subtotal' => $totals['subtotal'], 'discount' => $totals['discount'], 'tax' => $totals['tax'], 'total' => $totals['total'], 'paid_amount' => $paidAmount, 'payment_status' => $this->paymentStatus($paidAmount, $totals['total']), 'status' => $wasRejected ? 'pending_approval' : $invoice->status, 'rejected_at' => $wasRejected ? null : $invoice->rejected_at, 'rejection_reason' => $wasRejected ? null : $invoice->rejection_reason, 'notes' => array_key_exists('notes', $payload) ? $payload['notes'] : $invoice->notes, 'payment_terms' => array_key_exists('payment_terms', $payload) ? $payload['payment_terms'] : $invoice->payment_terms, 'due_date' => array_key_exists('due_date', $payload) ? $payload['due_date'] : $invoice->due_date, 'page_width_mm' => $payload['page_width_mm'] ?? $invoice->page_width_mm, 'page_height_mm' => $payload['page_height_mm'] ?? $invoice->page_height_mm, 'version' => $invoice->version + 1, ])->save(); $resolved = $this->resolveTemplateFields($invoice->fresh(), $template, $actor); if (! empty($payload['resolved_fields'])) { $resolved = $this->applyLayoutOverrides($resolved, $payload['resolved_fields']); } $invoice->update(['resolved_fields' => $resolved]); ActivityLogger::log('invoice_reviewed', "Invoice {$invoice->number} reviewed", $invoice); if ($wasRejected) { User::permission('approve_invoices')->where('is_active', true)->whereKeyNot($actor->id)->each(function (User $approver) use ($invoice, $actor): void { NotificationService::send($approver->id, 'فاکتور اصلاح و دوباره ارسال شد', "فاکتور {$invoice->number} توسط {$actor->name} دوباره برای تأیید ارسال شد", 'invoice_approval', [ 'invoice_id' => $invoice->id, 'url' => "/invoices?invoice={$invoice->id}", ]); }); } return $invoice->fresh($this->relations()); } public function issue(Invoice $invoice, User $actor): Invoice { abort_unless(in_array($invoice->status, ['pending_approval', 'approved'], true), 422, 'فقط فاکتور تأییدشده قابل صدور است.'); abort_if((float) $invoice->total < 0, 422, 'مبلغ نهایی فاکتور معتبر نیست.'); $invoice->update([ 'status' => 'issued', 'approved_by' => $actor->id, 'approved_at' => $invoice->approved_at ?? now(), 'issued_at' => now(), 'version' => $invoice->version + 1, ]); $resolved = $this->resolveTemplateFields($invoice->fresh(), $invoice->template, $actor); $invoice->update(['resolved_fields' => $this->applyLayoutOverrides($resolved, $invoice->resolved_fields ?? [])]); ActivityLogger::log('invoice_issued', "Invoice {$invoice->number} issued", $invoice); $this->notifyCreator($invoice, 'فاکتور صادر شد', "فاکتور {$invoice->number} تأیید و صادر شد"); return $invoice->fresh($this->relations()); } public function approve(Invoice $invoice, User $actor): Invoice { abort_unless($invoice->status === 'pending_approval', 422, 'فقط فاکتور در انتظار بررسی قابل تأیید است.'); $invoice->update([ 'status' => 'approved', 'approved_by' => $actor->id, 'approved_at' => now(), 'rejected_at' => null, 'rejection_reason' => null, 'version' => $invoice->version + 1, ]); ActivityLogger::log('invoice_approved', "Invoice {$invoice->number} approved", $invoice); $this->notifyCreator($invoice, 'فاکتور تأیید شد', "فاکتور {$invoice->number} تأیید و آماده صدور است"); return $invoice->fresh($this->relations()); } public function reject(Invoice $invoice, User $actor, string $reason): Invoice { abort_unless($invoice->status === 'pending_approval', 422, 'فقط فاکتور در انتظار بررسی قابل رد است.'); $invoice->update([ 'status' => 'rejected', 'approved_by' => $actor->id, 'rejected_at' => now(), 'rejection_reason' => $reason, 'version' => $invoice->version + 1, ]); ActivityLogger::log('invoice_rejected', "Invoice {$invoice->number} rejected", $invoice); $this->notifyCreator($invoice, 'فاکتور نیازمند اصلاح است', "فاکتور {$invoice->number} رد شد: {$reason}"); return $invoice->fresh($this->relations()); } public function void(Invoice $invoice, User $actor): Invoice { $invoice->update(['status' => 'void', 'voided_at' => now(), 'version' => $invoice->version + 1]); ActivityLogger::log('invoice_voided', "Invoice {$invoice->number} voided", $invoice); return $invoice->fresh($this->relations()); } public function defaultTemplate(User $actor): InvoiceTemplate { $template = InvoiceTemplate::where('is_active', true)->where('is_default', true)->first(); if ($template) { return $template; } return InvoiceTemplate::create([ 'name' => 'قالب استاندارد فاکتور', 'is_default' => true, 'is_active' => true, 'created_by' => $actor->id, 'layout' => $this->defaultLayout(), ]); } public function defaultLayout(): array { return [ ['id' => 'number', 'label' => 'شماره فاکتور', 'source' => 'invoice.number', 'x' => 68, 'y' => 8, 'width' => 24, 'font_size' => 12, 'align' => 'right'], ['id' => 'date', 'label' => 'تاریخ صدور', 'source' => 'invoice.issue_date', 'x' => 68, 'y' => 13, 'width' => 24, 'font_size' => 11, 'align' => 'right'], ['id' => 'customer', 'label' => 'خریدار', 'source' => 'customer.name', 'x' => 8, 'y' => 24, 'width' => 40, 'font_size' => 12, 'align' => 'right'], ['id' => 'company', 'label' => 'کسب‌وکار', 'source' => 'customer.company', 'x' => 52, 'y' => 24, 'width' => 40, 'font_size' => 12, 'align' => 'right'], ['id' => 'phone', 'label' => 'تلفن', 'source' => 'customer.phone', 'x' => 8, 'y' => 30, 'width' => 30, 'font_size' => 11, 'align' => 'right'], ['id' => 'items', 'label' => 'شرح اقلام', 'source' => 'invoice.items_summary', 'x' => 8, 'y' => 42, 'width' => 84, 'font_size' => 12, 'align' => 'right'], ['id' => 'total', 'label' => 'مبلغ نهایی', 'source' => 'invoice.total', 'x' => 60, 'y' => 78, 'width' => 32, 'font_size' => 15, 'align' => 'right'], ['id' => 'notes', 'label' => 'توضیحات', 'source' => 'invoice.notes', 'x' => 8, 'y' => 86, 'width' => 84, 'font_size' => 10, 'align' => 'right'], ]; } public function resolveTemplateFields(Invoice $invoice, ?InvoiceTemplate $template, User $actor): array { $sources = [ 'invoice.number' => $invoice->number, 'invoice.issue_date' => ($invoice->issued_at ?? now())->format('Y-m-d'), 'customer.name' => Arr::get($invoice->customer_snapshot, 'name'), 'customer.company' => Arr::get($invoice->customer_snapshot, 'company'), 'customer.phone' => Arr::get($invoice->customer_snapshot, 'phone'), 'customer.email' => Arr::get($invoice->customer_snapshot, 'email'), 'customer.address' => Arr::get($invoice->customer_snapshot, 'address'), 'lead.product' => Arr::get($invoice->lead_snapshot, 'sold_product'), 'lead.contract_date' => Arr::get($invoice->lead_snapshot, 'contract_date'), 'lead.payment_status' => Arr::get($invoice->lead_snapshot, 'payment_status'), 'invoice.items_summary' => collect($invoice->items)->map(fn (array $item) => ($item['description'] ?? 'قلم').' × '.($item['quantity'] ?? 1))->implode(' | '), 'invoice.subtotal' => number_format((float) $invoice->subtotal), 'invoice.discount' => number_format((float) $invoice->discount), 'invoice.tax' => number_format((float) $invoice->tax), 'invoice.total' => number_format((float) $invoice->total), 'invoice.paid_amount' => number_format((float) $invoice->paid_amount), 'invoice.balance_due' => number_format(max(0, (float) $invoice->total - (float) $invoice->paid_amount)), 'invoice.currency' => $invoice->currency, 'invoice.notes' => $invoice->notes, 'seller.name' => $actor->name, ]; foreach (array_slice($invoice->items ?? [], 0, 7) as $index => $item) { $row = $index + 1; $quantity = (float) ($item['quantity'] ?? 0); $unitPrice = (float) ($item['unit_price'] ?? 0); $sources["items.{$row}.description"] = (string) ($item['description'] ?? ''); $sources["items.{$row}.quantity"] = rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'); $sources["items.{$row}.unit_price"] = number_format($unitPrice); $sources["items.{$row}.line_total"] = number_format((float) ($item['line_total'] ?? ($quantity * $unitPrice))); } foreach ((array) Arr::get($invoice->lead_snapshot, 'custom_fields', []) as $key => $value) { $sources['custom.'.$key] = is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE); } return collect($template?->layout ?: $this->defaultLayout())->mapWithKeys(function (array $field) use ($sources): array { $id = (string) ($field['id'] ?? uniqid('field_', true)); return [$id => array_merge($field, ['value' => (string) ($sources[$field['source'] ?? ''] ?? ($field['default'] ?? ''))])]; })->all(); } private function customerSnapshot(Lead $lead): array { return [ 'name' => trim($lead->first_name.' '.$lead->last_name), 'company' => $lead->company, 'phone' => $lead->phone, 'email' => $lead->email, 'address' => trim(implode('، ', array_filter([$lead->province, $lead->city]))), 'national_code' => $lead->national_code, ]; } private function leadSnapshot(Lead $lead): array { $lead->loadMissing('customFieldValues.definition'); return [ 'id' => $lead->id, 'source' => $lead->source, 'sold_product' => $lead->sold_product ?: $lead->product_interest, 'deal_value' => $lead->deal_value, 'contract_date' => optional($lead->contract_date)->format('Y-m-d'), 'payment_status' => $lead->payment_status, 'customer_notes' => $lead->customer_notes, 'custom_fields' => $lead->customFieldValues->mapWithKeys(fn ($value) => [$value->definition?->key ?? (string) $value->custom_field_definition_id => $value->value])->all(), ]; } private function normalizeItems(array $items): array { abort_if($items === [], 422, 'فاکتور باید حداقل یک قلم داشته باشد.'); return collect($items)->map(function (array $item): array { $description = trim((string) ($item['description'] ?? '')); $quantity = max(0.01, (float) ($item['quantity'] ?? 1)); $unitPrice = max(0, (float) ($item['unit_price'] ?? 0)); abort_if($description === '', 422, 'شرح قلم فاکتور الزامی است.'); return [ 'description' => $description, 'unit' => trim((string) ($item['unit'] ?? 'عدد')) ?: 'عدد', 'quantity' => $quantity, 'unit_price' => $unitPrice, 'line_total' => round($quantity * $unitPrice, 2), ]; })->values()->all(); } private function calculateTotals(array $items, float $discount, float $tax): array { $subtotal = round((float) collect($items)->sum('line_total'), 2); $discount = max(0, min($subtotal, $discount)); $tax = max(0, $tax); return compact('subtotal', 'discount', 'tax') + ['total' => round($subtotal - $discount + $tax, 2)]; } private function normalizePaidAmount(float $paidAmount, float $total): float { abort_if($paidAmount < 0 || $paidAmount > $total, 422, 'مبلغ پرداخت‌شده باید بین صفر و مبلغ نهایی باشد.'); return round($paidAmount, 2); } private function paymentStatus(float $paidAmount, float $total): string { if ($paidAmount <= 0) { return 'unpaid'; } if ($paidAmount >= $total) { return 'paid'; } return 'partial'; } private function notifyCreator(Invoice $invoice, string $title, string $message): void { if (! $invoice->created_by) { return; } NotificationService::send($invoice->created_by, $title, $message, 'invoice', [ 'invoice_id' => $invoice->id, 'url' => "/invoices?invoice={$invoice->id}", ]); } private function relations(): array { return ['lead:id,first_name,last_name,company,assigned_to,final_result', 'template', 'creator:id,name', 'approver:id,name']; } private function applyLayoutOverrides(array $resolved, array $overrides): array { foreach ($overrides as $id => $field) { if (! isset($resolved[$id]) || ! is_array($field)) { continue; } foreach (['x', 'y', 'width', 'font_size', 'align'] as $key) { if (array_key_exists($key, $field)) { $resolved[$id][$key] = $field[$key]; } } } return $resolved; } }