From c2233389d0a14263058dd953fd073bff3bfa4533 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 3 Sep 2026 08:39:45 -0300 Subject: [PATCH] feat(sale): implement modifications filtering and validation for admin sales --- .../Controllers/AdminApp/SaleController.php | 13 ++- .../AdminAppSaleModificationIndexRequest.php | 16 ++++ .../AdminAppSaleModificationPdfRequest.php | 9 +- .../Sale/Services/AdminAppSaleService.php | 60 ++++++++++-- app/Domains/Sale/documentacion/README.md | 5 +- .../Sale/AdminAppSaleControllerTest.php | 92 +++++++++++++++++++ 6 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index 22bcfe1..383f700 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -3,6 +3,7 @@ namespace App\Domains\Sale\Controllers\AdminApp; use App\Domains\Sale\Requests\AdminAppSaleIndexRequest; +use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest; use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest; use App\Domains\Sale\Requests\AdminAppSalePdfRequest; use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; @@ -67,11 +68,13 @@ class SaleController extends Controller return new SaleResource($this->saleService->cancel($tenant, $sale)); } - public function modifications(Request $request): AnonymousResourceCollection - { + public function modifications( + AdminAppSaleModificationIndexRequest $request, + ): AnonymousResourceCollection { return SaleModificationResource::collection( $this->saleService->modifications( - $request->user()->tenant()->firstOrFail() + $request->user()->tenant()->firstOrFail(), + $request->validated(), ) ); } @@ -93,7 +96,7 @@ class SaleController extends Controller return $this->salePdfService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant), + $this->saleService->modificationsForExport($tenant, $request->validated()), $request->validated('timezone'), ); } @@ -116,7 +119,7 @@ class SaleController extends Controller return $this->saleExcelService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant), + $this->saleService->modificationsForExport($tenant, $request->validated()), $request->validated('timezone'), ); } diff --git a/app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php b/app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php new file mode 100644 index 0000000..a2d21d5 --- /dev/null +++ b/app/Domains/Sale/Requests/AdminAppSaleModificationIndexRequest.php @@ -0,0 +1,16 @@ +> */ + public function rules(): array + { + $rules = parent::rules(); + + unset($rules['sort_by'], $rules['sort_direction']); + + return $rules; + } +} diff --git a/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php b/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php index db42725..8ad2fd5 100644 --- a/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php +++ b/app/Domains/Sale/Requests/AdminAppSaleModificationPdfRequest.php @@ -3,19 +3,14 @@ namespace App\Domains\Sale\Requests; use App\Domains\Shared\Rules\ValidTimezone; -use Illuminate\Foundation\Http\FormRequest; -class AdminAppSaleModificationPdfRequest extends FormRequest +class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest { - public function authorize(): bool - { - return true; - } - /** @return array> */ public function rules(): array { return [ + ...parent::rules(), 'timezone' => ['required', 'string', new ValidTimezone], ]; } diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index 061c83e..eecd5fc 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -92,18 +92,24 @@ class AdminAppSaleService return $this->salesQuery($tenant, $filters)->get(); } - /** @return LengthAwarePaginator */ - public function modifications(Tenant $tenant): LengthAwarePaginator + /** + * @param array $filters + * @return LengthAwarePaginator + */ + public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator { - return $this->modificationsQuery($tenant) + return $this->modificationsQuery($tenant, $filters) ->paginateFromRequest() ->withQueryString(); } - /** @return Collection */ - public function modificationsForExport(Tenant $tenant): Collection + /** + * @param array $filters + * @return Collection + */ + public function modificationsForExport(Tenant $tenant, array $filters = []): Collection { - return $this->modificationsQuery($tenant)->get(); + return $this->modificationsQuery($tenant, $filters)->get(); } /** @param array $filters */ @@ -159,12 +165,50 @@ class AdminAppSaleService ->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id')); } - /** @return Builder */ - protected function modificationsQuery(Tenant $tenant): Builder + /** + * @param array $filters + * @return Builder + */ + protected function modificationsQuery(Tenant $tenant, array $filters): Builder { return ValueChange::query() ->where('tenant_code', $tenant->codigo) ->where('trackable_type', (new Purchase)->getMorphClass()) + ->when($filters['q'] ?? null, function (Builder $query, string $search): void { + $term = trim($search); + + $query->where(function (Builder $query) use ($term): void { + $query + ->where('trackable_id', 'like', "%{$term}%") + ->orWhereHasMorph( + 'trackable', + [Purchase::class], + function (Builder $sales) use ($term): void { + $sales + ->where('nombre_apellido', 'like', "%{$term}%") + ->orWhere('created_at', 'like', "%{$term}%"); + }, + ); + }); + }) + ->when( + $filters['id'] ?? null, + fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id) + ) + ->when( + $filters['sale_date'] ?? null, + fn (Builder $query, string $date): Builder => $query->whereHasMorph( + 'trackable', + [Purchase::class], + fn (Builder $sales): Builder => $sales->whereDate('created_at', $date), + ) + ) + ->when( + $filters['status'] ?? null, + fn (Builder $query, string $status): Builder => $query + ->where('attribute', 'status') + ->whereIn('new_value', Purchase::realStatusesForAdminStatus($status)) + ) ->with(['trackable', 'user']) ->orderByDesc('changed_at') ->orderByDesc('id'); diff --git a/app/Domains/Sale/documentacion/README.md b/app/Domains/Sale/documentacion/README.md index 7446341..2c1b5fe 100644 --- a/app/Domains/Sale/documentacion/README.md +++ b/app/Domains/Sale/documentacion/README.md @@ -9,7 +9,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además - `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones. - `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios. - `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios. -- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación. +- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación de ventas. +- `AdminAppSaleModificationIndexRequest`: valida los filtros compartidos por el historial y sus exportaciones. - `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp. - `SaleController`: entrada HTTP del panel. @@ -27,3 +28,5 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d ## Consideraciones La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel. + +El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 5c118c5..1cc0d7f 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -11,6 +11,8 @@ use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; +use App\Domains\Logging\Enums\ValueChangeActorType; +use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; @@ -36,6 +38,96 @@ class AdminAppSaleControllerTest extends TestCase WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); } + public function test_sale_modifications_use_sale_filters_and_resulting_status(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $sale = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'Ana Pérez', + 'status' => Purchase::STATUS_CANCELLED, + 'total' => '10000.00', + ]); + Purchase::query()->whereKey($sale->id)->update([ + 'created_at' => '2026-08-04 10:00:00', + ]); + + $otherSale = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'Otro cliente', + 'status' => Purchase::STATUS_PAID, + 'total' => '20000.00', + ]); + Purchase::query()->whereKey($otherSale->id)->update([ + 'created_at' => '2026-08-05 10:00:00', + ]); + + $confirmedChange = ValueChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'trackable_type' => $sale->getMorphClass(), + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => '2026-08-06 10:00:00', + 'actor_type' => ValueChangeActorType::User, + 'user_id' => $admin->id, + ]); + ValueChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'trackable_type' => $sale->getMorphClass(), + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PAID, + 'new_value' => Purchase::STATUS_CANCELLED, + 'changed_at' => '2026-08-07 10:00:00', + 'actor_type' => ValueChangeActorType::User, + 'user_id' => $admin->id, + ]); + ValueChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'trackable_type' => $otherSale->getMorphClass(), + 'trackable_id' => $otherSale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => '2026-08-08 10:00:00', + 'actor_type' => ValueChangeActorType::System, + ]); + ValueChange::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'trackable_type' => $sale->getMorphClass(), + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => '2026-08-09 10:00:00', + 'actor_type' => ValueChangeActorType::System, + ]); + + $this->getJson('/api/v1/adminapp/tenant/sales/modifications?'.http_build_query([ + 'q' => 'Ana', + 'id' => $sale->id, + 'sale_date' => '2026-08-04', + 'status' => Purchase::ADMIN_STATUS_CONFIRMED, + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $confirmedChange->id) + ->assertJsonPath('data.0.new_value', Purchase::STATUS_PAID) + ->assertJsonPath('data.0.admin_status', Purchase::ADMIN_STATUS_CONFIRMED); + + $this->getJson('/api/v1/adminapp/tenant/sales/modifications?status='. + Purchase::ADMIN_STATUS_CANCELLED) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.sale_id', $sale->id) + ->assertJsonPath('data.0.new_value', Purchase::STATUS_CANCELLED); + } + public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void { $tenant = $this->createTenant('acme');