feat: Implement sale management features including controllers, services, resources, and routes; add PDF generation for sales and modifications
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\SaleFormResource;
|
||||||
|
use App\Domains\Forms\Services\SaleFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class SaleFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected SaleFormService $saleFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(): SaleFormResource
|
||||||
|
{
|
||||||
|
return SaleFormResource::make($this->saleFormService->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class SaleFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
|
||||||
|
class SaleFormService
|
||||||
|
{
|
||||||
|
/** @return array{statuses: list<array{code: string, name: string}>} */
|
||||||
|
public function get(): array
|
||||||
|
{
|
||||||
|
$names = [
|
||||||
|
Purchase::STATUS_CREATED => 'Creada',
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
||||||
|
Purchase::STATUS_PAID => 'Confirmada',
|
||||||
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
|
Purchase::STATUS_EXPIRED => 'Vencida',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => array_map(
|
||||||
|
fn (string $status): array => [
|
||||||
|
'code' => $status,
|
||||||
|
'name' => $names[$status],
|
||||||
|
],
|
||||||
|
Purchase::statuses(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
|
Route::get('sale', SaleFormController::class);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Purchase\Resources\AdminApp\PurchaseModificationResource;
|
|
||||||
use App\Domains\Purchase\Resources\PurchaseResource;
|
|
||||||
use App\Domains\Purchase\Services\AdminAppPurchaseService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
||||||
|
|
||||||
class PurchaseController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(protected AdminAppPurchaseService $purchaseService) {}
|
|
||||||
|
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
return PurchaseResource::collection(
|
|
||||||
$this->purchaseService->purchases($tenant)
|
|
||||||
)->additional([
|
|
||||||
'confirmed_sales_total' => $this->purchaseService->confirmedSalesTotal($tenant),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function modifications(Request $request): AnonymousResourceCollection
|
|
||||||
{
|
|
||||||
return PurchaseModificationResource::collection(
|
|
||||||
$this->purchaseService->modifications(
|
|
||||||
$request->user()->tenant()->firstOrFail()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,6 +46,19 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_EXPIRED = 'expired';
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function statuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_CREATED,
|
||||||
|
self::STATUS_PENDING_PAYMENT,
|
||||||
|
self::STATUS_PAID,
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
|
||||||
|
|
||||||
use App\Domains\Logging\Models\ValueChange;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
class AdminAppPurchaseService
|
|
||||||
{
|
|
||||||
public function confirmedSalesTotal(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
$total = Purchase::query()
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('status', Purchase::STATUS_PAID)
|
|
||||||
->sum('total');
|
|
||||||
|
|
||||||
return number_format((float) $total, 2, '.', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return LengthAwarePaginator<Purchase> */
|
|
||||||
public function purchases(Tenant $tenant): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
return Purchase::query()
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->with(['items.imageAttachment'])
|
|
||||||
->withCount('tickets')
|
|
||||||
->latest()
|
|
||||||
->paginateFromRequest()
|
|
||||||
->withQueryString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return LengthAwarePaginator<ValueChange> */
|
|
||||||
public function modifications(Tenant $tenant): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
return ValueChange::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->where('trackable_type', (new Purchase)->getMorphClass())
|
|
||||||
->with(['trackable', 'user'])
|
|
||||||
->orderByDesc('changed_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->paginateFromRequest()
|
|
||||||
->withQueryString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Purchase\Controllers\AdminApp\PurchaseController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/tenant')
|
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
|
||||||
->group(function (): void {
|
|
||||||
Route::get('purchases', [PurchaseController::class, 'index']);
|
|
||||||
Route::get('purchases/modifications', [PurchaseController::class, 'modifications']);
|
|
||||||
});
|
|
||||||
@@ -15,5 +15,3 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
|||||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/adminapp.php';
|
|
||||||
|
|||||||
61
app/Domains/Sale/Controllers/AdminApp/SaleController.php
Normal file
61
app/Domains/Sale/Controllers/AdminApp/SaleController.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||||
|
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
||||||
|
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSaleService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class SaleController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AdminAppSaleService $saleService,
|
||||||
|
protected AdminAppSalePdfService $salePdfService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return SaleResource::collection(
|
||||||
|
$this->saleService->sales($tenant, $request->validated())
|
||||||
|
)->additional([
|
||||||
|
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modifications(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return SaleModificationResource::collection(
|
||||||
|
$this->saleService->modifications(
|
||||||
|
$request->user()->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->salePdfService->downloadSales(
|
||||||
|
$tenant,
|
||||||
|
$this->saleService->salesForExport($tenant, $request->validated()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadModificationsPdf(Request $request): Response
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->salePdfService->downloadModifications(
|
||||||
|
$tenant,
|
||||||
|
$this->saleService->modificationsForExport($tenant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php
Normal file
30
app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AdminAppSaleIndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
|
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||||
|
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
|
||||||
|
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
||||||
|
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Resources\AdminApp;
|
namespace App\Domains\Sale\Resources\AdminApp;
|
||||||
|
|
||||||
use App\Domains\Logging\Models\ValueChange;
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
@@ -8,27 +8,28 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/** @mixin ValueChange */
|
/** @mixin ValueChange */
|
||||||
class PurchaseModificationResource extends JsonResource
|
class SaleModificationResource extends JsonResource
|
||||||
{
|
{
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
/** @var Purchase|null $purchase */
|
/** @var Purchase|null $sale */
|
||||||
$purchase = $this->whenLoaded('trackable');
|
$sale = $this->whenLoaded('trackable');
|
||||||
$user = $this->whenLoaded('user');
|
$user = $this->whenLoaded('user');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'purchase_id' => $this->trackable_id,
|
'sale_id' => $this->trackable_id,
|
||||||
'attribute' => $this->attribute,
|
'attribute' => $this->attribute,
|
||||||
'old_value' => $this->old_value,
|
'old_value' => $this->old_value,
|
||||||
'new_value' => $this->new_value,
|
'new_value' => $this->new_value,
|
||||||
'changed_at' => $this->changed_at,
|
'date' => $this->changed_at->format('Y-m-d'),
|
||||||
|
'time' => $this->changed_at->format('H:i:s'),
|
||||||
'actor_type' => $this->actor_type->value,
|
'actor_type' => $this->actor_type->value,
|
||||||
'purchase' => $purchase instanceof Purchase ? [
|
'sale' => $sale instanceof Purchase ? [
|
||||||
'id' => $purchase->id,
|
'id' => $sale->id,
|
||||||
'customer_name' => $purchase->nombre_apellido,
|
'customer_name' => $sale->nombre_apellido,
|
||||||
'status' => $purchase->status,
|
'status' => $sale->status,
|
||||||
] : null,
|
] : null,
|
||||||
'modified_by' => $user ? [
|
'modified_by' => $user ? [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
28
app/Domains/Sale/Resources/AdminApp/SaleResource.php
Normal file
28
app/Domains/Sale/Resources/AdminApp/SaleResource.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Purchase */
|
||||||
|
class SaleResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$ticketsCount = (int) ($this->tickets_count ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'customer_name' => $this->nombre_apellido,
|
||||||
|
'quantity' => (int) ($this->quantity ?? 0),
|
||||||
|
'status' => $this->status,
|
||||||
|
'total' => number_format((float) $this->total, 2, '.', ''),
|
||||||
|
'tickets_count' => $ticketsCount,
|
||||||
|
'has_generated_tickets' => $ticketsCount > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Domains/Sale/Services/AdminAppSalePdfService.php
Normal file
68
app/Domains/Sale/Services/AdminAppSalePdfService.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Services;
|
||||||
|
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppSalePdfService
|
||||||
|
{
|
||||||
|
/** @param Collection<int, Purchase> $sales */
|
||||||
|
public function downloadSales(Tenant $tenant, Collection $sales): Response
|
||||||
|
{
|
||||||
|
$pdf = Pdf::loadView('pdf.adminapp.sales', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'sales' => $sales,
|
||||||
|
'generatedAt' => now(),
|
||||||
|
'confirmedSalesTotal' => number_format(
|
||||||
|
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
|
||||||
|
2,
|
||||||
|
'.',
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
])->setPaper('a4', 'landscape');
|
||||||
|
|
||||||
|
$this->addPageNumbers($pdf);
|
||||||
|
|
||||||
|
return $pdf->download(
|
||||||
|
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, ValueChange> $modifications */
|
||||||
|
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
|
||||||
|
{
|
||||||
|
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'modifications' => $modifications,
|
||||||
|
'generatedAt' => now(),
|
||||||
|
])->setPaper('a4', 'landscape');
|
||||||
|
|
||||||
|
$this->addPageNumbers($pdf);
|
||||||
|
|
||||||
|
return $pdf->download(
|
||||||
|
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addPageNumbers(DomPdf $pdf): void
|
||||||
|
{
|
||||||
|
$pdf->render();
|
||||||
|
$domPdf = $pdf->getDomPDF();
|
||||||
|
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
|
||||||
|
|
||||||
|
$domPdf->getCanvas()->page_text(
|
||||||
|
385,
|
||||||
|
575,
|
||||||
|
'Página {PAGE_NUM} de {PAGE_COUNT}',
|
||||||
|
$font,
|
||||||
|
7,
|
||||||
|
[0.48, 0.52, 0.49],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Domains/Sale/Services/AdminAppSaleService.php
Normal file
120
app/Domains/Sale/Services/AdminAppSaleService.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Services;
|
||||||
|
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppSaleService
|
||||||
|
{
|
||||||
|
public function confirmedSalesTotal(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$total = Purchase::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('status', Purchase::STATUS_PAID)
|
||||||
|
->sum('total');
|
||||||
|
|
||||||
|
return number_format((float) $total, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* q?: string|null,
|
||||||
|
* id?: int|null,
|
||||||
|
* sale_date?: string|null,
|
||||||
|
* status?: string|null,
|
||||||
|
* sort_by?: string,
|
||||||
|
* sort_direction?: string
|
||||||
|
* } $filters
|
||||||
|
* @return LengthAwarePaginator<Purchase>
|
||||||
|
*/
|
||||||
|
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->salesQuery($tenant, $filters)
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, Purchase>
|
||||||
|
*/
|
||||||
|
public function salesForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
|
{
|
||||||
|
return $this->salesQuery($tenant, $filters)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return LengthAwarePaginator<ValueChange> */
|
||||||
|
public function modifications(Tenant $tenant): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->modificationsQuery($tenant)
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, ValueChange> */
|
||||||
|
public function modificationsForExport(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return $this->modificationsQuery($tenant)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $filters */
|
||||||
|
protected function salesQuery(Tenant $tenant, array $filters): Builder
|
||||||
|
{
|
||||||
|
$sortColumns = [
|
||||||
|
'id' => 'id',
|
||||||
|
'date' => 'created_at',
|
||||||
|
'customer_name' => 'nombre_apellido',
|
||||||
|
'quantity' => 'quantity',
|
||||||
|
'status' => 'status',
|
||||||
|
'total' => 'total',
|
||||||
|
];
|
||||||
|
$requestedSort = $filters['sort_by'] ?? 'date';
|
||||||
|
$sortBy = array_key_exists($requestedSort, $sortColumns) ? $requestedSort : 'date';
|
||||||
|
$requestedDirection = $filters['sort_direction'] ?? 'desc';
|
||||||
|
$sortDirection = in_array($requestedDirection, ['asc', 'desc'], true)
|
||||||
|
? $requestedDirection
|
||||||
|
: 'desc';
|
||||||
|
|
||||||
|
return Purchase::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
|
$term = trim($search);
|
||||||
|
|
||||||
|
$query->where(function (Builder $query) use ($term): void {
|
||||||
|
$query
|
||||||
|
->where('id', 'like', "%{$term}%")
|
||||||
|
->orWhere('nombre_apellido', 'like', "%{$term}%")
|
||||||
|
->orWhere('created_at', 'like', "%{$term}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($filters['id'] ?? null, fn (Builder $query, int $id): Builder => $query->whereKey($id))
|
||||||
|
->when(
|
||||||
|
$filters['sale_date'] ?? null,
|
||||||
|
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', $date)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$filters['status'] ?? null,
|
||||||
|
fn (Builder $query, string $status): Builder => $query->where('status', $status)
|
||||||
|
)
|
||||||
|
->withSum('items as quantity', 'cantidad')
|
||||||
|
->withCount('tickets')
|
||||||
|
->orderBy($sortColumns[$sortBy], $sortDirection)
|
||||||
|
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<ValueChange> */
|
||||||
|
protected function modificationsQuery(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return ValueChange::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||||
|
->with(['trackable', 'user'])
|
||||||
|
->orderByDesc('changed_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Domains/Sale/routes/adminapp.php
Normal file
13
app/Domains/Sale/routes/adminapp.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Sale\Controllers\AdminApp\SaleController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('sales', [SaleController::class, 'index']);
|
||||||
|
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
||||||
|
Route::get('sales/modifications', [SaleController::class, 'modifications']);
|
||||||
|
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
|
||||||
|
});
|
||||||
3
app/Domains/Sale/routes/api.php
Normal file
3
app/Domains/Sale/routes/api.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
63
resources/views/pdf/adminapp/sale-modifications.blade.php
Normal file
63
resources/views/pdf/adminapp/sale-modifications.blade.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 28px 30px 58px; }
|
||||||
|
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 8px; margin: 0; }
|
||||||
|
h1 { font-size: 21px; margin: 0 0 3px; }
|
||||||
|
.subtitle { color: #66736b; margin: 0 0 18px; }
|
||||||
|
table { border-collapse: collapse; table-layout: fixed; width: 100%; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
th { background: #26382e; color: #fff; font-size: 7px; letter-spacing: .35px; padding: 7px 5px; text-align: left; text-transform: uppercase; }
|
||||||
|
td { border-bottom: 1px solid #dfe7e2; overflow-wrap: break-word; padding: 7px 5px; vertical-align: top; }
|
||||||
|
tbody tr:nth-child(even) { background: #f7f9f8; }
|
||||||
|
.date { width: 10%; }
|
||||||
|
.time { width: 7%; }
|
||||||
|
.sale { width: 8%; }
|
||||||
|
.customer { width: 17%; }
|
||||||
|
.attribute { width: 10%; }
|
||||||
|
.value { width: 16%; }
|
||||||
|
.actor { width: 16%; }
|
||||||
|
.empty { color: #66736b; padding: 24px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Historial de modificaciones de ventas</h1>
|
||||||
|
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="date">Fecha</th>
|
||||||
|
<th class="time">Hora</th>
|
||||||
|
<th class="sale">Venta</th>
|
||||||
|
<th class="customer">Cliente</th>
|
||||||
|
<th class="attribute">Campo</th>
|
||||||
|
<th class="value">Valor anterior</th>
|
||||||
|
<th class="value">Valor nuevo</th>
|
||||||
|
<th class="actor">Modificado por</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($modifications as $modification)
|
||||||
|
@php($sale = $modification->trackable)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $modification->changed_at->format('d/m/Y') }}</td>
|
||||||
|
<td>{{ $modification->changed_at->format('H:i:s') }}</td>
|
||||||
|
<td>#{{ $modification->trackable_id }}</td>
|
||||||
|
<td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td>
|
||||||
|
<td>{{ $modification->attribute }}</td>
|
||||||
|
<td>{{ $modification->old_value ?? '-' }}</td>
|
||||||
|
<td>{{ $modification->new_value ?? '-' }}</td>
|
||||||
|
<td>{{ $modification->user?->nombre_apellido ?? 'Sistema' }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td class="empty" colspan="8">Todavía no hay modificaciones registradas.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
resources/views/pdf/adminapp/sales.blade.php
Normal file
65
resources/views/pdf/adminapp/sales.blade.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 28px 34px 58px; }
|
||||||
|
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 9px; margin: 0; }
|
||||||
|
h1 { font-size: 21px; margin: 0 0 3px; }
|
||||||
|
.subtitle { color: #66736b; margin: 0 0 18px; }
|
||||||
|
.summary { background: #eef5f1; border-left: 4px solid #198754; margin-bottom: 16px; padding: 9px 12px; }
|
||||||
|
.summary strong { font-size: 14px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
th { background: #26382e; color: #fff; font-size: 8px; letter-spacing: .4px; padding: 7px 6px; text-align: left; text-transform: uppercase; }
|
||||||
|
td { border-bottom: 1px solid #dfe7e2; padding: 7px 6px; vertical-align: top; }
|
||||||
|
tbody tr:nth-child(even) { background: #f7f9f8; }
|
||||||
|
.number { text-align: right; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.empty { color: #66736b; padding: 24px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Historial de ventas</h1>
|
||||||
|
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th class="center">Cantidad</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th class="number">Importe</th>
|
||||||
|
<th class="center">Tickets</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($sales as $sale)
|
||||||
|
<tr>
|
||||||
|
<td>#{{ $sale->id }}</td>
|
||||||
|
<td>{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}</td>
|
||||||
|
<td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
|
||||||
|
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
|
||||||
|
<td>{{ match ($sale->status) {
|
||||||
|
'paid' => 'Confirmado',
|
||||||
|
'created', 'pending_payment' => 'Esperando pago',
|
||||||
|
default => 'Anulado',
|
||||||
|
} }}</td>
|
||||||
|
<td class="number">${{ number_format((float) $sale->total, 2, ',', '.') }}</td>
|
||||||
|
<td class="center">{{ (int) ($sale->tickets_count ?? 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td class="empty" colspan="7">No hay ventas para los criterios seleccionados.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,6 +6,7 @@ require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
|||||||
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/MailTest/routes/api.php';
|
require __DIR__.'/../app/Domains/MailTest/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/Sale/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
||||||
|
|||||||
67
tests/Feature/Forms/AdminAppSaleFormControllerTest.php
Normal file
67
tests/Feature/Forms/AdminAppSaleFormControllerTest.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Forms;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppSaleFormControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_is_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/sale')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_adminapp_user_can_get_the_sale_form(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/sale')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(count(Purchase::statuses()), 'data.statuses')
|
||||||
|
->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED)
|
||||||
|
->assertJsonPath('data.statuses.0.name', 'Creada')
|
||||||
|
->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT)
|
||||||
|
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID)
|
||||||
|
->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_customer_cannot_get_the_sale_form(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
'tenant_codigo' => null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/sale')->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tests/Unit/Sale/AdminAppSalePdfServiceTest.php
Normal file
90
tests/Unit/Sale/AdminAppSalePdfServiceTest.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Sale;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Barryvdh\DomPDF\ServiceProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppSalePdfServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->app->register(ServiceProvider::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_the_sales_report_as_a_pdf(): void
|
||||||
|
{
|
||||||
|
$response = app(AdminAppSalePdfService::class)->downloadSales(
|
||||||
|
$this->tenant(),
|
||||||
|
collect([$this->sale()]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('application/pdf', $response->headers->get('content-type'));
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'attachment; filename=ventas_acme_',
|
||||||
|
(string) $response->headers->get('content-disposition'),
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_the_modification_history_as_a_pdf(): void
|
||||||
|
{
|
||||||
|
$sale = $this->sale();
|
||||||
|
$admin = (new User)->forceFill([
|
||||||
|
'id' => 10,
|
||||||
|
'nombre_apellido' => 'Admin Test',
|
||||||
|
'email' => 'admin@example.test',
|
||||||
|
]);
|
||||||
|
$modification = (new ValueChange)->forceFill([
|
||||||
|
'id' => 1,
|
||||||
|
'trackable_id' => $sale->id,
|
||||||
|
'attribute' => 'status',
|
||||||
|
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'new_value' => Purchase::STATUS_PAID,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'actor_type' => 'user',
|
||||||
|
]);
|
||||||
|
$modification->setRelation('trackable', $sale);
|
||||||
|
$modification->setRelation('user', $admin);
|
||||||
|
|
||||||
|
$response = app(AdminAppSalePdfService::class)->downloadModifications(
|
||||||
|
$this->tenant(),
|
||||||
|
collect([$modification]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('application/pdf', $response->headers->get('content-type'));
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'attachment; filename=historial_modificaciones_acme_',
|
||||||
|
(string) $response->headers->get('content-disposition'),
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenant(): Tenant
|
||||||
|
{
|
||||||
|
return (new Tenant)->forceFill([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme Eventos',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sale(): Purchase
|
||||||
|
{
|
||||||
|
return (new Purchase)->forceFill([
|
||||||
|
'id' => 15,
|
||||||
|
'created_at' => now(),
|
||||||
|
'nombre_apellido' => 'Cliente Test',
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => Purchase::STATUS_PAID,
|
||||||
|
'total' => '25000.00',
|
||||||
|
'tickets_count' => 2,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user