协议签署:待处理任务增加删除和确认 #294
@ -309,6 +309,41 @@ class CourierAgreementSignatures extends Controller
|
||||
->with('success', "{$signature->documentLabel()}已人工确认并归档。");
|
||||
}
|
||||
|
||||
public function destroy(Request $request, CourierAgreementSignature $agreementSignature): JsonResponse|RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
abort_unless($agreementSignature->company_id === (int) company_id(), 404);
|
||||
$request->validate(['delete_confirmed' => ['required', 'accepted']]);
|
||||
|
||||
DB::transaction(function () use ($agreementSignature): void {
|
||||
$signature = CourierAgreementSignature::query()->disableCache()->lockForUpdate()->findOrFail($agreementSignature->id);
|
||||
abort_unless($signature->canDelete(), 422, '仅可删除未推送成功、已撤销、已过期或已拒签的任务;签署中、已签署或状态不明的任务不能删除。');
|
||||
|
||||
if (filled($signature->esign_sign_flow_id)) {
|
||||
abort_unless(EsignClient::configured()
|
||||
&& $signature->environment === config('services.esign.environment', 'sandbox'), 422, '无法核对 E 签宝流程状态,任务未删除。');
|
||||
try {
|
||||
$detail = app(EsignClient::class)->signFlowDetail($signature->esign_sign_flow_id);
|
||||
} catch (\Throwable) {
|
||||
throw ValidationException::withMessages(['signature' => 'E 签宝状态查询失败,任务未删除,请稍后重试。']);
|
||||
}
|
||||
abort_unless(in_array((string) ($detail['signFlowStatus'] ?? $detail['status'] ?? ''), ['3', '5', '7'], true), 422, 'E 签宝流程尚未结束或已完成签署,任务未删除,请先同步状态。');
|
||||
}
|
||||
|
||||
// Keep the original files and callback audit records for traceability.
|
||||
$signature->updated_by = user_id();
|
||||
$signature->save();
|
||||
$signature->delete();
|
||||
});
|
||||
|
||||
$message = '签署任务已删除。';
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message])
|
||||
: redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
|
||||
@ -119,6 +119,27 @@ class CourierAgreementSignature extends Model
|
||||
&& blank($this->esign_sign_flow_id);
|
||||
}
|
||||
|
||||
public function canDelete(): bool
|
||||
{
|
||||
if ($this->trashed() || $this->completed_at || $this->archived_at
|
||||
|| filled($this->signed_pdf_path) || filled($this->certificate_path)
|
||||
|| in_array((string) $this->remote_status, ['1', '2'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blank($this->esign_sign_flow_id)) {
|
||||
return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED, self::STATUS_REVOKED, self::STATUS_EXPIRED, self::STATUS_REJECTED], true)
|
||||
|| ($this->status === self::STATUS_UNKNOWN && $this->hasDefinitiveFailure());
|
||||
}
|
||||
|
||||
return match ($this->status) {
|
||||
self::STATUS_REVOKED => $this->remote_status === '3',
|
||||
self::STATUS_EXPIRED => $this->remote_status === '5',
|
||||
self::STATUS_REJECTED => $this->remote_status === '7',
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function documentLabel(): string
|
||||
{
|
||||
return self::DOCUMENTS[$this->document_type]['name'] ?? $this->document_type;
|
||||
|
||||
@ -35,12 +35,16 @@ final class CourierAgreementSignatureService
|
||||
|
||||
$signature = DB::transaction(function () use ($companyId, $document, $data, $payload, $idempotencyKey, $businessNo, $userId): CourierAgreementSignature {
|
||||
$existing = CourierAgreementSignature::allCompanies()
|
||||
->withTrashed()
|
||||
->where('company_id', $companyId)
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->trashed()) {
|
||||
throw ValidationException::withMessages(['signature' => '该签署请求已删除,请重新打开发起签章页面后提交新的请求。']);
|
||||
}
|
||||
if ($existing->esign_sign_flow_id || in_array($existing->status, [CourierAgreementSignature::STATUS_SENDING, CourierAgreementSignature::STATUS_SIGNING, CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true)) {
|
||||
return $existing;
|
||||
}
|
||||
@ -49,6 +53,11 @@ final class CourierAgreementSignatureService
|
||||
throw ValidationException::withMessages(['signature' => '该协议已进入不可重复发起状态。']);
|
||||
}
|
||||
|
||||
$existing->update([
|
||||
'status' => CourierAgreementSignature::STATUS_SENDING,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
|
||||
@ -447,6 +447,15 @@
|
||||
background: var(--agreement-accent-hover);
|
||||
}
|
||||
|
||||
.agreement-button--danger {
|
||||
color: var(--agreement-danger, #B80000);
|
||||
}
|
||||
|
||||
.agreement-button--danger:hover {
|
||||
border-color: var(--agreement-danger, #B80000);
|
||||
background: #FFF5F5;
|
||||
}
|
||||
|
||||
.agreement-button:disabled,
|
||||
.agreement-icon-button:disabled {
|
||||
border-color: var(--agreement-border);
|
||||
@ -1426,7 +1435,7 @@
|
||||
@foreach ($agreementTasks as $task)
|
||||
@php($taskError = $task->lastErrorSummary())
|
||||
@php($taskFailed = $task->status === 'failed' || ($task->status === 'unknown' && $task->hasDefinitiveFailure()))
|
||||
<tr>
|
||||
<tr data-agreement-task-row>
|
||||
<td>{{ $task->business_no }}</td>
|
||||
<td>{{ $task->courier_name }}</td>
|
||||
<td>{{ $task->documentLabel() }}</td>
|
||||
@ -1466,6 +1475,14 @@
|
||||
@if ($task->certificate_path)
|
||||
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.download', [$task, 'certificate']) }}" title="下载签署完成证书"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span></a>
|
||||
@endif
|
||||
@if ($agreementScope === 'active' && $task->canDelete())
|
||||
<form method="POST" action="{{ route('business.courier-agreement-signatures.destroy', $task) }}" data-delete-task-form data-task-number="{{ $task->business_no }}" data-task-description="{{ $task->courier_name }} · {{ $task->documentLabel() }} · {{ $task->statusLabel() }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="delete_confirmed" value="1">
|
||||
<button class="agreement-button agreement-button--compact agreement-button--danger" type="button" data-delete-task-open title="删除签署任务"><span class="material-icons-outlined" aria-hidden="true">delete_outline</span>删除</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@ -2136,6 +2153,26 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="agreement-modal" role="presentation" aria-hidden="true" data-delete-task-modal hidden>
|
||||
<div class="agreement-modal__backdrop" data-delete-task-close></div>
|
||||
<section class="agreement-modal__dialog agreement-archive-dialog" role="dialog" aria-modal="true" aria-labelledby="agreement-delete-task-title" aria-describedby="agreement-delete-task-hint">
|
||||
<header class="agreement-modal__header">
|
||||
<h2 class="agreement-modal__title" id="agreement-delete-task-title"><span class="material-icons-outlined" aria-hidden="true">delete_outline</span>删除签署任务</h2>
|
||||
<button class="agreement-modal-close" type="button" aria-label="关闭删除确认" data-delete-task-close><span class="material-icons-outlined" aria-hidden="true">close</span></button>
|
||||
</header>
|
||||
<div class="agreement-modal__body">
|
||||
<strong data-delete-task-number></strong>
|
||||
<p class="agreement-modal__hint" data-delete-task-description></p>
|
||||
<p class="agreement-modal__hint" id="agreement-delete-task-hint">删除后,此任务将从列表移除,可重新发起签章。原文件和操作记录保留备查。</p>
|
||||
<p class="agreement-form-error" role="alert" data-delete-task-error></p>
|
||||
</div>
|
||||
<footer class="agreement-modal__footer">
|
||||
<button class="agreement-button" type="button" data-delete-task-close>取消</button>
|
||||
<button class="agreement-button agreement-button--danger" type="button" data-delete-task-confirm><span class="material-icons-outlined" aria-hidden="true">delete_outline</span><span data-delete-task-confirm-label>确认删除</span></button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="sr-only" role="status" aria-live="polite" data-agreement-announcer></p>
|
||||
|
||||
<script>
|
||||
@ -2159,6 +2196,10 @@
|
||||
var archiveConfirmForm = root.querySelector('[data-archive-confirm-form]');
|
||||
var archiveTargetForm = null;
|
||||
var lastArchiveTrigger = null;
|
||||
var deleteTaskModal = root.querySelector('[data-delete-task-modal]');
|
||||
var deleteTaskTargetForm = null;
|
||||
var lastDeleteTaskTrigger = null;
|
||||
var deletingTask = false;
|
||||
var taskLoadRequest = 0;
|
||||
var subjectList = root.querySelector('[data-subject-list]');
|
||||
var subjectForm = root.querySelector('[data-subject-form]');
|
||||
@ -2239,6 +2280,29 @@
|
||||
archiveConfirmForm.querySelector('input:checked').focus();
|
||||
}
|
||||
|
||||
function closeDeleteTaskModal() {
|
||||
if (deletingTask) {
|
||||
return;
|
||||
}
|
||||
deleteTaskModal.hidden = true;
|
||||
deleteTaskModal.setAttribute('aria-hidden', 'true');
|
||||
deleteTaskTargetForm = null;
|
||||
if (lastDeleteTaskTrigger && lastDeleteTaskTrigger.isConnected) {
|
||||
lastDeleteTaskTrigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteTaskModal(button) {
|
||||
deleteTaskTargetForm = button.closest('[data-delete-task-form]');
|
||||
lastDeleteTaskTrigger = button;
|
||||
deleteTaskModal.querySelector('[data-delete-task-number]').textContent = deleteTaskTargetForm.dataset.taskNumber;
|
||||
deleteTaskModal.querySelector('[data-delete-task-description]').textContent = deleteTaskTargetForm.dataset.taskDescription;
|
||||
deleteTaskModal.querySelector('[data-delete-task-error]').textContent = '';
|
||||
deleteTaskModal.hidden = false;
|
||||
deleteTaskModal.setAttribute('aria-hidden', 'false');
|
||||
deleteTaskModal.querySelector('button[data-delete-task-close]').focus();
|
||||
}
|
||||
|
||||
async function loadTasks(url, options) {
|
||||
var requestId = ++taskLoadRequest;
|
||||
var panel = root.querySelector('[data-agreement-panel="tasks"]');
|
||||
@ -2298,6 +2362,15 @@
|
||||
closeArchiveModal();
|
||||
return;
|
||||
}
|
||||
var deleteTaskOpen = event.target.closest('[data-delete-task-open]');
|
||||
if (deleteTaskOpen) {
|
||||
openDeleteTaskModal(deleteTaskOpen);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-delete-task-close]')) {
|
||||
closeDeleteTaskModal();
|
||||
return;
|
||||
}
|
||||
var panel = event.target.closest('[data-agreement-panel="tasks"]');
|
||||
if (!panel) {
|
||||
return;
|
||||
@ -2354,6 +2427,50 @@
|
||||
archiveTargetForm.requestSubmit();
|
||||
});
|
||||
|
||||
deleteTaskModal.querySelector('[data-delete-task-confirm]').addEventListener('click', async function () {
|
||||
if (!deleteTaskTargetForm || deletingTask) {
|
||||
return;
|
||||
}
|
||||
var targetForm = deleteTaskTargetForm;
|
||||
var errorElement = deleteTaskModal.querySelector('[data-delete-task-error]');
|
||||
var confirmLabel = deleteTaskModal.querySelector('[data-delete-task-confirm-label]');
|
||||
deletingTask = true;
|
||||
errorElement.textContent = '';
|
||||
confirmLabel.textContent = '删除中…';
|
||||
deleteTaskModal.querySelectorAll('button').forEach(function (button) { button.disabled = true; });
|
||||
try {
|
||||
var response = await fetch(targetForm.action, {
|
||||
method: 'POST', credentials: 'same-origin', body: new FormData(targetForm),
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
var result = await response.json().catch(function () {
|
||||
throw new Error('无法确认删除结果,请重新查询任务列表后再试。');
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '删除失败,任务已保留,请稍后重试。');
|
||||
}
|
||||
var refreshUrl = new URL(window.location.href);
|
||||
var taskPage = Number(refreshUrl.searchParams.get('agreement_task_page') || 1);
|
||||
var previousPage = taskPage > 1 && targetForm.closest('tbody').querySelectorAll('[data-agreement-task-row]').length === 1;
|
||||
if (previousPage) {
|
||||
refreshUrl.searchParams.set('agreement_task_page', String(taskPage - 1));
|
||||
}
|
||||
deletingTask = false;
|
||||
closeDeleteTaskModal();
|
||||
// Remove the deleted row even if refreshing the list fails.
|
||||
targetForm.closest('tr').remove();
|
||||
await loadTasks(refreshUrl, { updateUrl: previousPage });
|
||||
root.querySelector('[data-task-filter-form] button[type="submit"]').focus();
|
||||
announce(result.message);
|
||||
} catch (error) {
|
||||
errorElement.textContent = error.name === 'TypeError' ? '网络连接失败,无法确认删除结果,请重新查询任务列表。' : (error.message || '删除失败,任务已保留,请稍后重试。');
|
||||
} finally {
|
||||
deletingTask = false;
|
||||
confirmLabel.textContent = '确认删除';
|
||||
deleteTaskModal.querySelectorAll('button').forEach(function (button) { button.disabled = false; });
|
||||
}
|
||||
});
|
||||
|
||||
function subjectUrl(template, id) {
|
||||
return template.replace('__subject__', encodeURIComponent(String(id)));
|
||||
}
|
||||
@ -3484,6 +3601,23 @@
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', function (event) {
|
||||
if (!deleteTaskModal.hidden) {
|
||||
if (event.key === 'Escape') {
|
||||
closeDeleteTaskModal();
|
||||
} else if (event.key === 'Tab') {
|
||||
var deleteControls = Array.prototype.filter.call(deleteTaskModal.querySelectorAll('button'), function (control) { return !control.disabled; });
|
||||
if (deleteControls.length === 0) {
|
||||
event.preventDefault();
|
||||
} else if (event.shiftKey && document.activeElement === deleteControls[0]) {
|
||||
event.preventDefault();
|
||||
deleteControls[deleteControls.length - 1].focus();
|
||||
} else if (!event.shiftKey && document.activeElement === deleteControls[deleteControls.length - 1]) {
|
||||
event.preventDefault();
|
||||
deleteControls[0].focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!archiveModal.hidden) {
|
||||
if (event.key === 'Escape') {
|
||||
closeArchiveModal();
|
||||
|
||||
@ -157,6 +157,7 @@ Route::prefix('workspace/courier-payables/agreement-signatures')
|
||||
Route::get('quote-versions/{quoteVersion}/preview', [CourierAgreementSignatures::class, 'previewQuote'])->name('quotes.preview');
|
||||
Route::post('{agreementSignature}/synchronize', [CourierAgreementSignatures::class, 'synchronize'])->name('synchronize');
|
||||
Route::post('{agreementSignature}/archive', [CourierAgreementSignatures::class, 'archive'])->name('archive');
|
||||
Route::delete('{agreementSignature}', [CourierAgreementSignatures::class, 'destroy'])->name('destroy');
|
||||
Route::get('{agreementSignature}/files/{kind}', [CourierAgreementSignatures::class, 'download'])->name('download');
|
||||
Route::post('quote-signatures/{quoteSignature}/synchronize', [CourierAgreementSignatures::class, 'synchronizeQuote'])->name('quotes.synchronize');
|
||||
Route::get('quote-signatures/{quoteSignature}/files/{kind}', [CourierAgreementSignatures::class, 'downloadQuote'])->name('quotes.download');
|
||||
|
||||
209
tests/Feature/Business/CourierAgreementTaskDeletionTest.php
Normal file
209
tests/Feature/Business/CourierAgreementTaskDeletionTest.php
Normal file
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Models\Business\CourierAgreementSignatureEvent;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierAgreementTaskDeletionTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withExceptionHandling();
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
Storage::fake('uploads');
|
||||
Http::preventStrayRequests();
|
||||
config([
|
||||
'services.esign.environment' => 'sandbox',
|
||||
'services.esign.base_url' => 'https://smlopenapi.esign.test',
|
||||
'services.esign.app_id' => 'sandbox-app-id',
|
||||
'services.esign.app_secret' => 'sandbox-app-secret',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_failed_and_unpushed_tasks_are_soft_deleted_and_files_and_audit_are_retained(): void
|
||||
{
|
||||
foreach (['prepared', 'failed', 'unknown'] as $status) {
|
||||
$task = $this->task(['status' => $status, 'last_error' => 'E 签宝接口返回业务错误(1437328):签署人信息不一致']);
|
||||
Storage::disk('uploads')->put($task->source_pdf_path, '%PDF-source');
|
||||
$audit = CourierAgreementSignatureEvent::create([
|
||||
'company_id' => $this->company->id, 'agreement_signature_id' => $task->id,
|
||||
'event_key' => 'audit-' . $task->id, 'action' => 'TEST_EVENT',
|
||||
'esign_sign_flow_id' => 'test-' . $task->id, 'event_timestamp' => now()->timestamp,
|
||||
'raw_body' => '{}', 'received_at' => now(),
|
||||
]);
|
||||
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])
|
||||
->assertOk()->assertJson(['message' => '签署任务已删除。']);
|
||||
|
||||
$this->assertSoftDeleted($task);
|
||||
$this->assertSame($this->user->id, $task->fresh()->updated_by);
|
||||
Storage::disk('uploads')->assertExists($task->source_pdf_path);
|
||||
$this->assertNotNull($audit->fresh());
|
||||
$this->assertNull(CourierAgreementSignature::find($task->id));
|
||||
$this->get($this->pageUrl())->assertOk()->assertDontSeeText($task->business_no);
|
||||
$this->get($this->pageUrl() . '?agreement_scope=all')->assertOk()->assertDontSeeText($task->business_no);
|
||||
$this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertNotFound();
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_in_flight_completed_and_ambiguous_tasks_cannot_be_deleted(): void
|
||||
{
|
||||
$cases = [
|
||||
['status' => 'sending'],
|
||||
['status' => 'signing', 'esign_sign_flow_id' => 'active-flow', 'remote_status' => '1'],
|
||||
['status' => 'completed'],
|
||||
['status' => 'archived'],
|
||||
['status' => 'unknown', 'last_error' => 'Connection timed out'],
|
||||
['status' => 'failed', 'esign_sign_flow_id' => 'failed-after-flow'],
|
||||
['completed_at' => now()], ['archived_at' => now()],
|
||||
['signed_pdf_path' => 'signed.pdf'], ['certificate_path' => 'certificate.pdf'],
|
||||
['remote_status' => '2'], ['remote_status' => '1'],
|
||||
];
|
||||
foreach ($cases as $case) {
|
||||
$task = $this->task($case);
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertStatus(422);
|
||||
$this->assertFalse($task->fresh()->trashed());
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_ended_flows_are_checked_remotely_before_deletion(): void
|
||||
{
|
||||
Http::fakeSequence()->push(['code' => 0, 'data' => ['signFlowStatus' => '3']])
|
||||
->push(['code' => 0, 'data' => ['signFlowStatus' => '5']])
|
||||
->push(['code' => 0, 'data' => ['signFlowStatus' => '7']]);
|
||||
$checked = 0;
|
||||
foreach (['revoked' => '3', 'expired' => '5', 'rejected' => '7'] as $status => $remoteStatus) {
|
||||
$task = $this->task(['status' => $status, 'remote_status' => $remoteStatus, 'esign_sign_flow_id' => 'flow-' . $status]);
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
|
||||
$this->assertSoftDeleted($task);
|
||||
Http::assertSentCount(++$checked);
|
||||
Http::assertSent(fn ($request) => $request->method() === 'GET' && str_contains($request->url(), $task->esign_sign_flow_id));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_remote_errors_and_stale_terminal_states_preserve_the_task(): void
|
||||
{
|
||||
$sequence = Http::fakeSequence();
|
||||
foreach (['1', '2', '999', ''] as $remoteStatus) {
|
||||
$sequence->push(['code' => 0, 'data' => ['signFlowStatus' => $remoteStatus]]);
|
||||
}
|
||||
$sequence->push(['code' => 500, 'message' => 'provider unavailable'], 500);
|
||||
foreach (['1', '2', '999', ''] as $remoteStatus) {
|
||||
$task = $this->task(['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'flow-' . uniqid()]);
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertStatus(422);
|
||||
$this->assertFalse($task->fresh()->trashed());
|
||||
}
|
||||
$task = $this->task(['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'flow-error']);
|
||||
$this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])
|
||||
->assertStatus(422)->assertJsonValidationErrors('signature');
|
||||
$this->assertFalse($task->fresh()->trashed());
|
||||
}
|
||||
|
||||
public function test_environment_mismatch_and_missing_confirmation_block_deletion(): void
|
||||
{
|
||||
$task = $this->task();
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task))->assertStatus(422)->assertJsonValidationErrors('delete_confirmed');
|
||||
$this->assertFalse($task->fresh()->trashed());
|
||||
$remoteTask = $this->task(['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'flow-production', 'environment' => 'production']);
|
||||
$this->deleteJson($this->deleteUrl($remoteTask), ['delete_confirmed' => true])->assertStatus(422);
|
||||
$this->assertFalse($remoteTask->fresh()->trashed());
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_deletion_does_not_cross_company_or_management_permissions(): void
|
||||
{
|
||||
$foreignTask = $this->task(['company_id' => $this->company->id + 100]);
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($foreignTask), ['delete_confirmed' => true])->assertNotFound();
|
||||
$this->assertFalse($foreignTask->fresh()->trashed());
|
||||
$task = $this->task();
|
||||
$this->user->roles()->detach();
|
||||
$this->user->unsetRelation('roles');
|
||||
$this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertForbidden();
|
||||
$this->assertFalse($task->fresh()->trashed());
|
||||
}
|
||||
|
||||
public function test_deleted_request_cannot_be_replayed_or_recreated_with_the_same_idempotency_key(): void
|
||||
{
|
||||
$key = 'deleted-request';
|
||||
$task = $this->task(['idempotency_key' => 'agreement-' . hash('sha256', $this->company->id . '|' . $key)]);
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
|
||||
try {
|
||||
app(CourierAgreementSignatureService::class)->send(['document_type' => 'account-proof', 'idempotency_key' => $key], $this->user->id);
|
||||
$this->fail('Deleted signing request must not be replayed.');
|
||||
} catch (ValidationException $exception) {
|
||||
$this->assertArrayHasKey('signature', $exception->errors());
|
||||
}
|
||||
$this->assertSame(1, CourierAgreementSignature::withTrashed()->count());
|
||||
$this->assertSoftDeleted($task);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_retrying_a_failed_push_blocks_concurrent_deletion(): void
|
||||
{
|
||||
$key = 'retry-request';
|
||||
$task = $this->task([
|
||||
'idempotency_key' => 'agreement-' . hash('sha256', $this->company->id . '|' . $key),
|
||||
'operator_name' => '测试人员', 'operator_account' => '13800138000',
|
||||
]);
|
||||
Storage::disk('uploads')->put($task->source_pdf_path, '%PDF-source');
|
||||
Http::fake(function () use ($task) {
|
||||
$this->assertSame('sending', $task->fresh()->status);
|
||||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertStatus(422);
|
||||
return Http::response(['code' => 500, 'message' => 'provider unavailable'], 500);
|
||||
});
|
||||
try {
|
||||
app(CourierAgreementSignatureService::class)->send(['document_type' => 'account-proof', 'idempotency_key' => $key], $this->user->id);
|
||||
$this->fail('Fake provider should reject the retry.');
|
||||
} catch (ValidationException $exception) {
|
||||
$this->assertArrayHasKey('signature', $exception->errors());
|
||||
}
|
||||
Http::assertSentCount(1);
|
||||
$this->assertFalse($task->fresh()->trashed());
|
||||
$this->assertSame('failed', $task->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_delete_button_is_only_shown_for_safe_tasks_in_the_active_view(): void
|
||||
{
|
||||
$safe = $this->task();
|
||||
$signing = $this->task(['status' => 'signing', 'esign_sign_flow_id' => 'flow-active', 'remote_status' => '1']);
|
||||
$this->loginAs()->get($this->pageUrl())->assertOk()
|
||||
->assertSee($this->deleteUrl($safe), false)->assertDontSee($this->deleteUrl($signing) . '"', false);
|
||||
$this->get($this->pageUrl() . '?agreement_scope=all')->assertOk()->assertDontSee('data-delete-task-form data-task-number=', false);
|
||||
$this->get($this->pageUrl() . '?agreement_scope=archived')->assertOk()->assertDontSee('data-delete-task-form data-task-number=', false);
|
||||
}
|
||||
|
||||
private function task(array $overrides = []): CourierAgreementSignature
|
||||
{
|
||||
$key = uniqid();
|
||||
return CourierAgreementSignature::create(array_merge([
|
||||
'company_id' => $this->company->id, 'business_no' => 'AGR-DELETE-' . $key,
|
||||
'idempotency_key' => 'delete-' . $key, 'document_type' => 'account-proof',
|
||||
'template_code' => 'TPL-ACCOUNT-CERT-V2', 'template_version' => '2',
|
||||
'source_file_name' => '收款账户证明文件.doc', 'status' => 'failed',
|
||||
'courier_name' => '测试快递', 'company_full_name' => $this->company->name,
|
||||
'organization_name' => '测试快递有限公司', 'payload' => [], 'environment' => 'sandbox',
|
||||
'source_pdf_path' => 'delete-tests/' . $key . '/source.pdf', 'source_pdf_sha256' => str_repeat('a', 64),
|
||||
'created_by' => $this->user->id, 'updated_by' => $this->user->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function deleteUrl(CourierAgreementSignature $task): string
|
||||
{
|
||||
return route('business.courier-agreement-signatures.destroy', $task);
|
||||
}
|
||||
|
||||
private function pageUrl(): string
|
||||
{
|
||||
return route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user