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, ]; } }