CRM/backend/app/Services/InvoiceService.php

428 خطوط
22 KiB
PHP
خام سرزنش تاریخچه

مخزن ambiguous runes header

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

<?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;
}
}