CRM/backend/app/Http/Controllers/Api/InvoiceController.php

384 خطوط
17 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use App\Models\InvoiceTemplate;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Services\InvoiceService;
use App\Services\InvoiceWordService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class InvoiceController extends Controller
{
public function __construct(private InvoiceService $service, private InvoiceWordService $wordService) {}
public function index(Request $request): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
$user = auth()->user();
$query = Invoice::with('lead:id,first_name,last_name,company,assigned_to,final_result', 'template:id,name', 'creator:id,name', 'approver:id,name');
if (! $user->hasRole('admin')) {
if ($user->hasRole('supervisor') || $user->can('approve_invoices')) {
$query->whereHas('lead', fn ($leads) => AccessControl::scopeLeads($leads, $user, false));
} else {
$query->where(fn ($scope) => $scope->where('created_by', $user->id)->orWhereHas('lead', fn ($leads) => $leads->where('assigned_to', $user->id)));
}
}
foreach (['status', 'lead_id', 'created_by'] as $filter) {
if ($request->filled($filter)) {
$query->where($filter, $request->get($filter));
}
}
if ($request->filled('search')) {
$search = trim((string) $request->get('search'));
$query->where(fn ($scope) => $scope->where('number', 'like', "%{$search}%")
->orWhereHas('lead', fn ($leads) => $leads->where('company', 'like', "%{$search}%")->orWhere('phone', 'like', "%{$search}%")));
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
}
public function summary(): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
$user = auth()->user();
$query = Invoice::query();
if (! $user->hasRole('admin')) {
if ($user->hasRole('supervisor') || $user->can('approve_invoices')) {
$query->whereHas('lead', fn ($leads) => AccessControl::scopeLeads($leads, $user, false));
} else {
$query->where(fn ($scope) => $scope->where('created_by', $user->id)
->orWhereHas('lead', fn ($leads) => $leads->where('assigned_to', $user->id)));
}
}
$counts = (clone $query)->selectRaw('status, COUNT(*) as aggregate')->groupBy('status')->pluck('aggregate', 'status');
$issued = (clone $query)->where('status', 'issued');
$issuedTotal = (float) (clone $issued)->sum('total');
$paidTotal = (float) (clone $issued)->sum('paid_amount');
return response()->json([
'total' => (clone $query)->count(),
'counts' => [
'draft' => (int) ($counts['draft'] ?? 0),
'pending_approval' => (int) ($counts['pending_approval'] ?? 0),
'approved' => (int) ($counts['approved'] ?? 0),
'rejected' => (int) ($counts['rejected'] ?? 0),
'issued' => (int) ($counts['issued'] ?? 0),
'void' => (int) ($counts['void'] ?? 0),
],
'issued_total' => $issuedTotal,
'paid_total' => $paidTotal,
'outstanding_total' => max(0, $issuedTotal - $paidTotal),
'currency' => 'IRR',
]);
}
public function show(Invoice $invoice): JsonResponse
{
Gate::authorize('view', $invoice);
return response()->json($this->invoicePayload($invoice->load('lead', 'template', 'creator:id,name', 'approver:id,name')));
}
public function word(Invoice $invoice): BinaryFileResponse
{
Gate::authorize('view', $invoice);
$document = $this->wordService->create($invoice);
return response()->download(
$document['path'],
$document['filename'],
['Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
)->deleteFileAfterSend(true);
}
public function fromLead(Request $request, Lead $lead): JsonResponse
{
Gate::authorize('create', Invoice::class);
Gate::authorize('view', $lead);
if (auth()->user()->hasRole('agent')) {
abort_unless($lead->assigned_to === auth()->id(), 403, 'فقط مسئول این لید می‌تواند درخواست فاکتور ثبت کند.');
}
$payload = $request->validate($this->invoiceRules());
$invoice = $this->service->createFromLead($lead, auth()->user(), $payload);
return response()->json($this->invoicePayload($invoice), 201);
}
public function update(Request $request, Invoice $invoice): JsonResponse
{
Gate::authorize('update', $invoice);
$invoice = $this->service->updateDraft($invoice, $request->validate($this->invoiceRules(true)), auth()->user());
return response()->json($this->invoicePayload($invoice));
}
public function issue(Invoice $invoice): JsonResponse
{
Gate::authorize('issue', $invoice);
return response()->json($this->invoicePayload($this->service->issue($invoice, auth()->user())));
}
public function approve(Invoice $invoice): JsonResponse
{
Gate::authorize('approve', $invoice);
return response()->json($this->invoicePayload($this->service->approve($invoice, auth()->user())));
}
public function reject(Request $request, Invoice $invoice): JsonResponse
{
Gate::authorize('approve', $invoice);
$validated = $request->validate(['reason' => 'required|string|max:2000']);
return response()->json($this->invoicePayload($this->service->reject($invoice, auth()->user(), $validated['reason'])));
}
public function void(Invoice $invoice): JsonResponse
{
Gate::authorize('void', $invoice);
return response()->json($this->invoicePayload($this->service->void($invoice, auth()->user())));
}
public function templates(Request $request): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
$query = InvoiceTemplate::query();
if (! ($request->boolean('include_inactive') && Gate::allows('manageTemplates', Invoice::class))) {
$query->where('is_active', true);
}
return response()->json($query->orderByDesc('is_default')->orderBy('name')->get()->map(fn ($template) => $this->templatePayload($template)));
}
public function storeTemplate(Request $request): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
$data = $request->validate($this->templateRules());
if (! empty($data['is_default'])) {
InvoiceTemplate::query()->update(['is_default' => false]);
}
$template = InvoiceTemplate::create($data + ['created_by' => auth()->id(), 'layout' => $data['layout'] ?? $this->service->defaultLayout()]);
ActivityLogger::log('invoice_template_created', "Invoice template {$template->name} created", $template);
return response()->json($this->templatePayload($template), 201);
}
public function updateTemplate(Request $request, InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
$data = $request->validate($this->templateRules(true));
if (! empty($data['is_default'])) {
InvoiceTemplate::where('id', '!=', $invoiceTemplate->id)->update(['is_default' => false]);
}
$invoiceTemplate->update($data);
ActivityLogger::log('invoice_template_updated', "Invoice template {$invoiceTemplate->name} updated", $invoiceTemplate);
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
}
public function destroyTemplate(InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
abort_if($invoiceTemplate->invoices()->exists(), 422, 'این قالب در فاکتورهای ثبت‌شده استفاده شده است؛ به‌جای حذف، آن را غیرفعال کنید.');
$paths = array_filter([$invoiceTemplate->background_path, $invoiceTemplate->source_path]);
DB::transaction(function () use ($invoiceTemplate): void {
$wasDefault = $invoiceTemplate->is_default;
$name = $invoiceTemplate->name;
$invoiceTemplate->delete();
if ($wasDefault) {
InvoiceTemplate::where('is_active', true)->orderBy('id')->first()?->update(['is_default' => true]);
}
ActivityLogger::log('invoice_template_deleted', "Invoice template {$name} deleted");
});
if ($paths) {
Storage::disk('local')->delete(array_values(array_unique($paths)));
}
return response()->json(['message' => 'قالب حذف شد.']);
}
public function uploadTemplateBackground(Request $request, InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
$data = $request->validate([
'file' => 'required|image|mimes:png,jpg,jpeg|max:20480',
'source_file' => 'nullable|file|mimes:png,jpg,jpeg,pdf|max:20480',
'source_name' => 'nullable|string|max:255',
'source_mime' => 'nullable|in:image/png,image/jpeg,application/pdf',
]);
$file = $request->file('file');
$sourceFile = $request->file('source_file');
if ($invoiceTemplate->background_path) {
Storage::disk('local')->delete($invoiceTemplate->background_path);
}
if ($invoiceTemplate->source_path) {
Storage::disk('local')->delete($invoiceTemplate->source_path);
}
$path = $file->store('invoice-templates', 'local');
$sourcePath = $sourceFile?->store('invoice-template-sources', 'local');
$invoiceTemplate->update([
'background_path' => $path,
'background_name' => $file->getClientOriginalName(),
'background_mime' => $file->getMimeType(),
'source_path' => $sourcePath,
'source_name' => $data['source_name'] ?? $sourceFile?->getClientOriginalName() ?? $file->getClientOriginalName(),
'source_mime' => $data['source_mime'] ?? $sourceFile?->getMimeType() ?? $file->getMimeType(),
'base_type' => $invoiceTemplate->base_type === 'blank' ? 'full_template' : $invoiceTemplate->base_type,
'background_settings' => $invoiceTemplate->background_settings ?: ['fit' => 'contain', 'top' => 0, 'height' => 100],
]);
ActivityLogger::log('invoice_template_background_uploaded', "Background uploaded for invoice template {$invoiceTemplate->id}", $invoiceTemplate);
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
}
public function deleteTemplateBackground(InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
if ($invoiceTemplate->background_path) {
Storage::disk('local')->delete($invoiceTemplate->background_path);
}
if ($invoiceTemplate->source_path) {
Storage::disk('local')->delete($invoiceTemplate->source_path);
}
$invoiceTemplate->update([
'background_path' => null,
'background_name' => null,
'background_mime' => null,
'source_path' => null,
'source_name' => null,
'source_mime' => null,
'base_type' => 'blank',
'background_settings' => null,
]);
ActivityLogger::log('invoice_template_background_deleted', "Background deleted for invoice template {$invoiceTemplate->id}", $invoiceTemplate);
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
}
public function templateBackground(InvoiceTemplate $invoiceTemplate)
{
Gate::authorize('viewAny', Invoice::class);
abort_unless($invoiceTemplate->background_path && Storage::disk('local')->exists($invoiceTemplate->background_path), 404);
return response()->file(Storage::disk('local')->path($invoiceTemplate->background_path), [
'Content-Type' => $invoiceTemplate->background_mime ?: 'application/octet-stream',
'Cache-Control' => 'private, max-age=300',
]);
}
public function fieldCatalog(): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
return response()->json(InvoiceService::FIELD_CATALOG);
}
private function invoiceRules(bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return [
'invoice_template_id' => 'nullable|exists:invoice_templates,id',
'currency' => $sometimes.'nullable|string|max:8',
'customer_snapshot' => 'nullable|array',
'customer_snapshot.name' => 'nullable|string|max:255',
'customer_snapshot.company' => 'nullable|string|max:255',
'customer_snapshot.phone' => 'nullable|string|max:40',
'customer_snapshot.email' => 'nullable|email|max:255',
'customer_snapshot.address' => 'nullable|string|max:1000',
'customer_snapshot.national_code' => 'nullable|string|max:40',
'customer_snapshot.economic_code' => 'nullable|string|max:40',
'customer_snapshot.postal_code' => 'nullable|string|max:40',
'seller_snapshot' => 'nullable|array',
'seller_snapshot.name' => 'nullable|string|max:255',
'seller_snapshot.company' => 'nullable|string|max:255',
'seller_snapshot.phone' => 'nullable|string|max:40',
'seller_snapshot.email' => 'nullable|email|max:255',
'seller_snapshot.address' => 'nullable|string|max:1000',
'seller_snapshot.national_id' => 'nullable|string|max:40',
'seller_snapshot.economic_code' => 'nullable|string|max:40',
'seller_snapshot.postal_code' => 'nullable|string|max:40',
'items' => 'nullable|array|min:1',
'items.*.description' => 'required_with:items|string|max:500',
'items.*.quantity' => 'required_with:items|numeric|min:0.01',
'items.*.unit_price' => 'required_with:items|numeric|min:0',
'items.*.unit' => 'nullable|string|max:40',
'discount' => 'nullable|numeric|min:0',
'tax' => 'nullable|numeric|min:0',
'paid_amount' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:2000',
'due_date' => 'nullable|date',
'resolved_fields' => 'nullable|array',
'page_width_mm' => 'nullable|integer|min:100|max:500',
'page_height_mm' => 'nullable|integer|min:100|max:500',
];
}
private function templateRules(bool $partial = false): array
{
$required = $partial ? 'sometimes|' : 'required|';
return [
'name' => $required.'string|max:255',
'layout' => 'nullable|array',
'layout.*.id' => 'required_with:layout|string|max:80',
'layout.*.label' => 'required_with:layout|string|max:255',
'layout.*.source' => 'required_with:layout|string|max:120',
'layout.*.x' => 'required_with:layout|numeric|min:0|max:100',
'layout.*.y' => 'required_with:layout|numeric|min:0|max:100',
'layout.*.width' => 'required_with:layout|numeric|min:1|max:100',
'layout.*.font_size' => 'nullable|integer|min:8|max:48',
'layout.*.align' => 'nullable|in:right,center,left',
'layout.*.default' => 'nullable|string|max:1000',
'base_type' => 'nullable|in:blank,letterhead,full_template',
'background_settings' => 'nullable|array',
'background_settings.fit' => 'nullable|in:contain,cover,stretch',
'background_settings.top' => 'nullable|numeric|min:0|max:95',
'background_settings.height' => 'nullable|numeric|min:5|max:100',
'page_width_mm' => 'nullable|integer|min:100|max:500',
'page_height_mm' => 'nullable|integer|min:100|max:500',
'is_default' => 'nullable|boolean',
'is_active' => 'nullable|boolean',
];
}
private function invoicePayload(Invoice $invoice): array
{
$array = $invoice->toArray();
$array['balance_due'] = max(0, (float) $invoice->total - (float) $invoice->paid_amount);
$array['capabilities'] = [
'update' => Gate::allows('update', $invoice),
'approve' => Gate::allows('approve', $invoice),
'issue' => Gate::allows('issue', $invoice),
'void' => Gate::allows('void', $invoice),
];
if ($invoice->template) {
$array['template'] = $this->templatePayload($invoice->template);
}
return $array;
}
private function templatePayload(InvoiceTemplate $template): array
{
return $template->toArray() + [
'background_url' => $template->background_path ? url("/api/invoice-templates/{$template->id}/background") : null,
];
}
}