c34d4009d1
Soft-delete был half-done: пользователь не мог отменить случайное удаление.
Теперь после bulk-delete показывается snackbar «Удалено N · Восстановить»
на 8 секунд.
Backend (DealController::restore):
- POST /api/deals/restore {tenant_id, ids: [1..1000 ints]}.
- withTrashed() обходит global scope SoftDeletes + явный
whereNotNull('deleted_at') для NO-OP idempotency на живых.
- RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.restored, context.source='bulk' для каждой
ВОССТАНОВЛЕННОЙ. Константа EVENT_DEAL_RESTORED добавлена в модель.
Pest +7 (DealRestoreTest):
- 422/404 базовые / soft-delete + restore + audit / NO-OP на живых
не пишет audit / defense-in-depth (свой restored, чужой остался) /
после restore видна в GET /api/deals / 422 пустой массив.
Frontend:
- dealsApi.bulkRestoreDeals — POST-helper.
- DealsView::applyBulkDelete: snapshot удалённых сделок (deep-clone
manager.*) сохраняется в lastDeletedSnapshot ref.
- undoBulkDelete() async: optimistic re-insert + bulkRestoreDeals если
auth.user; success → toast «Восстановлено N»; fail → warning.
- v-snackbar bulk-delete: 3→8 сек timeout + #actions слот с кнопкой
«Восстановить» (показ только при snapshot.length > 0). После undo
snapshot очищается → кнопка пропадает.
Vitest +3 (DealsListIntegration):
- bulk-delete + undo восстанавливает обе + bulkRestoreDeals + cleanup
snapshot.
- Undo без tenant_id — НЕ вызывает API + только локально.
- Undo reject → warning toast + локальное восстановление остаётся.
PHPStan baseline регенерирован. cspell-glossary +unshift +партиальный.
Регресс:
- Lint+type-check+format passed.
- Vitest 311/311 за 18.71 сек (+3 от 308).
- Vite build 877 ms.
- Pint + PHPStan passed.
- Pest 263/263 за 27.68 сек (+7 от 256, 998 assertions).
Реестр v1.69→v1.70 / CLAUDE.md v1.60→v1.61.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 lines
1.8 KiB
PHP
73 lines
1.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
/**
|
||
* Лог событий по сделке (примеры event: deal.created, deal.status_changed).
|
||
*
|
||
* Tenant-aware с RLS. deal_id — без FK (deals партиционирована),
|
||
* целостность на уровне приложения. log_hash заполняется триггером
|
||
* audit_chain_hash() BEFORE INSERT (OPEN-И-15).
|
||
*
|
||
* Источник: db/schema.sql v8.7 §6, table `activity_log`.
|
||
*
|
||
* @mixin IdeHelperActivityLog
|
||
*/
|
||
class ActivityLog extends Model
|
||
{
|
||
public const EVENT_DEAL_CREATED = 'deal.created';
|
||
|
||
public const EVENT_DEAL_STATUS_CHANGED = 'deal.status_changed';
|
||
|
||
public const EVENT_DEAL_ASSIGNED = 'deal.assigned';
|
||
|
||
public const EVENT_DEAL_DELETED = 'deal.deleted';
|
||
|
||
public const EVENT_DEAL_RESTORED = 'deal.restored';
|
||
|
||
public $timestamps = false;
|
||
|
||
protected $table = 'activity_log';
|
||
|
||
protected $fillable = [
|
||
'tenant_id',
|
||
'user_id',
|
||
'deal_id',
|
||
'event',
|
||
'old_value',
|
||
'new_value',
|
||
'context',
|
||
'ip_address',
|
||
'user_agent',
|
||
'created_at',
|
||
];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'tenant_id' => 'integer',
|
||
'user_id' => 'integer',
|
||
'deal_id' => 'integer',
|
||
'context' => 'array',
|
||
'created_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
/** @return BelongsTo<Tenant, $this> */
|
||
public function tenant(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Tenant::class);
|
||
}
|
||
|
||
/** @return BelongsTo<User, $this> */
|
||
public function user(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
}
|