feat(sale): implement modifications filtering and validation for admin sales
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Sale\Controllers\AdminApp;
|
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||||
|
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||||
|
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
||||||
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||||
@@ -67,11 +68,13 @@ class SaleController extends Controller
|
|||||||
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function modifications(Request $request): AnonymousResourceCollection
|
public function modifications(
|
||||||
{
|
AdminAppSaleModificationIndexRequest $request,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
return SaleModificationResource::collection(
|
return SaleModificationResource::collection(
|
||||||
$this->saleService->modifications(
|
$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(
|
return $this->salePdfService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant),
|
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,7 +119,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadModifications(
|
return $this->saleExcelService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant),
|
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
|
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
|
||||||
|
{
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$rules = parent::rules();
|
||||||
|
|
||||||
|
unset($rules['sort_by'], $rules['sort_direction']);
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,14 @@
|
|||||||
namespace App\Domains\Sale\Requests;
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
use App\Domains\Shared\Rules\ValidTimezone;
|
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<string, list<string>> */
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
...parent::rules(),
|
||||||
'timezone' => ['required', 'string', new ValidTimezone],
|
'timezone' => ['required', 'string', new ValidTimezone],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,24 @@ class AdminAppSaleService
|
|||||||
return $this->salesQuery($tenant, $filters)->get();
|
return $this->salesQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return LengthAwarePaginator<ValueChange> */
|
/**
|
||||||
public function modifications(Tenant $tenant): LengthAwarePaginator
|
* @param array<string, mixed> $filters
|
||||||
|
* @return LengthAwarePaginator<ValueChange>
|
||||||
|
*/
|
||||||
|
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant)
|
return $this->modificationsQuery($tenant, $filters)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, ValueChange> */
|
/**
|
||||||
public function modificationsForExport(Tenant $tenant): Collection
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, ValueChange>
|
||||||
|
*/
|
||||||
|
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant)->get();
|
return $this->modificationsQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $filters */
|
/** @param array<string, mixed> $filters */
|
||||||
@@ -159,12 +165,50 @@ class AdminAppSaleService
|
|||||||
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Builder<ValueChange> */
|
/**
|
||||||
protected function modificationsQuery(Tenant $tenant): Builder
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Builder<ValueChange>
|
||||||
|
*/
|
||||||
|
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
|
||||||
{
|
{
|
||||||
return ValueChange::query()
|
return ValueChange::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('trackable_type', (new Purchase)->getMorphClass())
|
->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'])
|
->with(['trackable', 'user'])
|
||||||
->orderByDesc('changed_at')
|
->orderByDesc('changed_at')
|
||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
|
|||||||
@@ -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.
|
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
||||||
- `AdminAppSaleExcelService`: genera descargas Excel 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.
|
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||||
- `SaleController`: entrada HTTP del panel.
|
- `SaleController`: entrada HTTP del panel.
|
||||||
|
|
||||||
@@ -27,3 +28,5 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
|||||||
## Consideraciones
|
## Consideraciones
|
||||||
|
|
||||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
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.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use App\Domains\Cart\Models\CartItem;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
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\Events\PurchasePaid;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
@@ -36,6 +38,96 @@ class AdminAppSaleControllerTest extends TestCase
|
|||||||
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
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
|
public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
|||||||
Reference in New Issue
Block a user