协议签署:待处理任务增加删除和确认 #294

Merged
limeng merged 2 commits from feature/limeng-agreement-task-delete into main 2026-10-11 10:37:28 +08:00
6 changed files with 410 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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']);
}
}