428 خطوط
22 KiB
PHP
428 خطوط
22 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Invoice;
|
||
use App\Models\InvoiceTemplate;
|
||
use App\Models\Lead;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Arr;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class InvoiceService
|
||
{
|
||
public const FIELD_CATALOG = [
|
||
'invoice.number' => 'شماره فاکتور',
|
||
'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;
|
||
}
|
||
}
|