Compare commits

...

3 Commits

8 changed files with 195 additions and 31 deletions

View File

@@ -43,6 +43,11 @@ class ReleaseCheckoutService
): Purchase { ): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus === Purchase::STATUS_CANCELLED) {
return $this->loadPurchase($purchase);
}
if ($purchase->status === Purchase::STATUS_EXPIRED if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus !== Purchase::STATUS_EXPIRED) { && $targetStatus !== Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException; throw new PurchaseExpiredException;
@@ -72,6 +77,14 @@ class ReleaseCheckoutService
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
// Leaving checkout is allowed at the exact instant the reservation
// expires. Finish the expiration while holding the purchase lock so
// the request is idempotent with the scheduled expiration job.
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $this->hasOverdueActiveReservation($purchase)) {
$targetStatus = Purchase::STATUS_EXPIRED;
}
if ($targetStatus === Purchase::STATUS_CANCELLED if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active' && $cart?->status === 'active'
&& in_array($purchase->status, [ && in_array($purchase->status, [

View File

@@ -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'),
); );
} }

View File

@@ -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;
}
}

View File

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

View File

@@ -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');

View File

@@ -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.

View File

@@ -10,6 +10,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
@@ -714,11 +715,11 @@ class StorePurchaseTest extends TestCase
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
->assertUnprocessable() ->assertOk()
->assertJsonPath('code', 'purchase.expired'); ->assertJsonPath('data.status', Purchase::STATUS_EXPIRED);
} }
public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): void public function test_leaving_an_overdue_purchase_expires_it_before_the_expiration_job_runs(): void
{ {
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create(); $user = User::factory()->create();
@@ -728,20 +729,17 @@ class StorePurchaseTest extends TestCase
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
->assertUnprocessable() ->assertOk()
->assertExactJson([ ->assertJsonPath('data.status', Purchase::STATUS_EXPIRED);
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
]);
$this->assertDatabaseHas('compras', [ $this->assertDatabaseHas('compras', [
'id' => $purchase->id, 'id' => $purchase->id,
'status' => Purchase::STATUS_CREATED, 'status' => Purchase::STATUS_EXPIRED,
'stock_reservation_id' => $purchase->stock_reservation_id, 'stock_reservation_id' => $purchase->stock_reservation_id,
]); ]);
$this->assertDatabaseHas('stock_reservations', [ $this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id, 'id' => $purchase->stock_reservation_id,
'status' => 'active', 'status' => StockReservation::STATUS_EXPIRED,
]); ]);
} }

View File

@@ -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');