384 خطوط
17 KiB
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,
|
|
];
|
|
}
|
|
}
|