协议签署:每条任务支持自主删除

全部状态、两类任务支持确认软删除,保留文件及外部签署流程。相关72项测试通过,权限及公司隔离保持不变。
This commit is contained in:
limeng 2026-10-11 11:24:30 +08:00
parent 766a85b097
commit 48553c001d
6 changed files with 152 additions and 95 deletions

View File

@ -310,27 +310,24 @@ class CourierAgreementSignatures extends Controller
}
public function destroy(Request $request, CourierAgreementSignature $agreementSignature): JsonResponse|RedirectResponse
{
return $this->deleteTask($request, $agreementSignature);
}
public function destroyQuote(Request $request, CourierQuoteSignature $quoteSignature): JsonResponse|RedirectResponse
{
return $this->deleteTask($request, $quoteSignature);
}
private function deleteTask(Request $request, CourierAgreementSignature|CourierQuoteSignature $task): JsonResponse|RedirectResponse
{
$this->ensureCanManage();
abort_unless($agreementSignature->company_id === (int) company_id(), 404);
abort_unless($task->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.
DB::transaction(function () use ($task): void {
$signature = $task::query()->disableCache()->lockForUpdate()->findOrFail($task->id);
// Soft-delete only the local task, retaining its files, remote flow and audit records.
$signature->updated_by = user_id();
$signature->save();
$signature->delete();

View File

@ -119,27 +119,6 @@ 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

@ -20,6 +20,9 @@ final class CourierQuoteSignatureService
public function prepareVersion(CourierQuoteVersion $version, int $userId, ?string $signatureDate = null): CourierQuoteSignature
{
if (CourierQuoteSignature::onlyTrashed()->where('quote_version_id', $version->id)->exists()) {
throw ValidationException::withMessages(['signature' => '该报价版本的签署任务已删除,请使用新报价版本发起新的签署任务。']);
}
$existing = $version->signature;
if ($signatureDate === null && $existing && $this->sourcePdfIsIntact($existing)) {

View File

@ -1397,7 +1397,7 @@
@foreach ($agreementQuoteSignatures as $quoteSignature)
@php($quoteVersion = $quoteSignature->version)
@php($quoteError = $quoteSignature->lastErrorSummary())
<tr>
<tr data-quote-task-row>
<td>{{ $quoteVersion?->version_code ?? ('报价版本 #' . $quoteSignature->quote_version_id) }}</td>
<td>{{ $quoteVersion?->carrier_name ?? $quoteSignature->organization_name }}</td>
<td>报价确认单</td>
@ -1429,6 +1429,12 @@
@if ($quoteSignature->certificate_path)
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.quotes.download', [$quoteSignature, 'certificate']) }}" title="下载签署完成证书"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span></a>
@endif
<form method="POST" action="{{ route('business.courier-agreement-signatures.quotes.destroy', $quoteSignature) }}" data-delete-task-form data-task-number="{{ $quoteVersion?->version_code ?? ('报价版本 #' . $quoteSignature->quote_version_id) }}" data-task-description="{{ $quoteVersion?->carrier_name ?? $quoteSignature->organization_name }} · 报价确认单 · {{ $quoteSignature->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>
</td>
</tr>
@endforeach
@ -1475,14 +1481,12 @@
@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
<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>
</td>
</tr>
@endforeach
@ -2163,7 +2167,8 @@
<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-modal__hint" id="agreement-delete-task-hint">确认后,此任务将从本系统的全部列表移除。原文件和操作记录保留备查。</p>
<p class="agreement-modal__hint">删除本系统任务不会撤销或终止 E 签宝上的签署流程,已签署文件仍然有效。是否删除请自行核对。</p>
<p class="agreement-form-error" role="alert" data-delete-task-error></p>
</div>
<footer class="agreement-modal__footer">
@ -2450,10 +2455,12 @@
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;
var quoteTask = targetForm.closest('tr').hasAttribute('data-quote-task-row');
var pageKey = quoteTask ? 'agreement_quote_page' : 'agreement_task_page';
var taskPage = Number(refreshUrl.searchParams.get(pageKey) || 1);
var previousPage = taskPage > 1 && targetForm.closest('tbody').querySelectorAll(quoteTask ? '[data-quote-task-row]' : '[data-agreement-task-row]').length === 1;
if (previousPage) {
refreshUrl.searchParams.set('agreement_task_page', String(taskPage - 1));
refreshUrl.searchParams.set(pageKey, String(taskPage - 1));
}
deletingTask = false;
closeDeleteTaskModal();

View File

@ -160,6 +160,7 @@ Route::prefix('workspace/courier-payables/agreement-signatures')
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::delete('quote-signatures/{quoteSignature}', [CourierAgreementSignatures::class, 'destroyQuote'])->name('quotes.destroy');
Route::get('quote-signatures/{quoteSignature}/files/{kind}', [CourierAgreementSignatures::class, 'downloadQuote'])->name('quotes.download');
});

View File

@ -5,7 +5,11 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierAgreementSignatureEvent;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
@ -55,68 +59,61 @@ class CourierAgreementTaskDeletionTest extends FeatureTestCase
Http::assertNothingSent();
}
public function test_in_flight_completed_and_ambiguous_tasks_cannot_be_deleted(): void
public function test_every_status_can_be_deleted_without_querying_or_revoking_remote_flows(): void
{
$cases = [
['status' => 'prepared'],
['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'],
['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'revoked-flow'],
['status' => 'expired', 'remote_status' => '5', 'esign_sign_flow_id' => 'expired-flow'],
['status' => 'rejected', 'remote_status' => '7', 'esign_sign_flow_id' => 'rejected-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());
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
$this->assertSoftDeleted($task);
$this->assertSame($task->esign_sign_flow_id, $task->fresh()->esign_sign_flow_id);
}
Http::assertNothingSent();
}
public function test_ended_flows_are_checked_remotely_before_deletion(): void
public function test_signed_files_are_retained_and_callbacks_cannot_restore_deleted_tasks(): 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));
$task = $this->task([
'status' => 'archived', 'remote_status' => '2', 'esign_sign_flow_id' => 'signed-flow',
'completed_at' => now(), 'archived_at' => now(),
'signed_pdf_path' => 'delete-tests/signed.pdf', 'certificate_path' => 'delete-tests/certificate.pdf',
]);
foreach ([$task->source_pdf_path, $task->signed_pdf_path, $task->certificate_path] as $path) {
Storage::disk('uploads')->put($path, '%PDF-retained');
}
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
$this->assertNull(app(CourierAgreementSignatureService::class)->acceptCallback('signed-flow', '2', 'late-callback'));
$this->assertSoftDeleted($task);
foreach ([$task->source_pdf_path, $task->signed_pdf_path, $task->certificate_path] as $path) {
Storage::disk('uploads')->assertExists($path);
}
$this->get($this->pageUrl() . '?agreement_scope=archived')->assertOk()->assertDontSeeText($task->business_no);
Http::assertNothingSent();
}
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
public function test_confirmation_is_required_but_provider_credentials_are_not(): 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());
config(['services.esign.app_id' => '', 'services.esign.app_secret' => '']);
$this->deleteJson($this->deleteUrl($remoteTask), ['delete_confirmed' => true])->assertOk();
$this->assertSoftDeleted($remoteTask);
Http::assertNothingSent();
}
@ -148,7 +145,7 @@ class CourierAgreementTaskDeletionTest extends FeatureTestCase
Http::assertNothingSent();
}
public function test_retrying_a_failed_push_blocks_concurrent_deletion(): void
public function test_deletion_during_a_failed_push_is_not_undone_by_the_failure_update(): void
{
$key = 'retry-request';
$task = $this->task([
@ -158,7 +155,7 @@ class CourierAgreementTaskDeletionTest extends FeatureTestCase
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);
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
return Http::response(['code' => 500, 'message' => 'provider unavailable'], 500);
});
try {
@ -168,18 +165,62 @@ class CourierAgreementTaskDeletionTest extends FeatureTestCase
$this->assertArrayHasKey('signature', $exception->errors());
}
Http::assertSentCount(1);
$this->assertFalse($task->fresh()->trashed());
$this->assertSoftDeleted($task);
$this->assertSame('failed', $task->fresh()->status);
}
public function test_delete_button_is_only_shown_for_safe_tasks_in_the_active_view(): void
public function test_delete_button_is_shown_for_every_agreement_task_in_each_view(): void
{
$safe = $this->task();
$signing = $this->task(['status' => 'signing', 'esign_sign_flow_id' => 'flow-active', 'remote_status' => '1']);
$archived = $this->task(['status' => 'archived']);
$quote = $this->quoteTask();
$archivedQuote = $this->quoteTask(['status' => 'archived']);
$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);
->assertSee($this->deleteUrl($safe), false)->assertSee($this->deleteUrl($signing), false)
->assertSee($this->deleteUrl($quote), false)
->assertDontSee($this->deleteUrl($archived), false);
$this->get($this->pageUrl() . '?agreement_scope=all')->assertOk()
->assertSee($this->deleteUrl($safe), false)->assertSee($this->deleteUrl($signing), false)
->assertSee($this->deleteUrl($quote), false)->assertSee($this->deleteUrl($archivedQuote), false)
->assertSee($this->deleteUrl($archived), false);
$this->get($this->pageUrl() . '?agreement_scope=archived')->assertOk()
->assertSee($this->deleteUrl($archivedQuote), false)
->assertSee($this->deleteUrl($archived), false);
}
public function test_quote_tasks_in_every_status_are_soft_deleted_without_changing_the_approved_quote(): void
{
foreach (['prepared', 'sending', 'signing', 'completed', 'archived', 'revoked', 'expired', 'rejected', 'failed'] as $status) {
$task = $this->quoteTask(['status' => $status, 'esign_sign_flow_id' => 'quote-' . $status]);
Storage::disk('uploads')->put($task->source_pdf_path, '%PDF-source');
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
$this->assertSoftDeleted($task);
$this->assertSame('approved', $task->version->fresh()->status);
Storage::disk('uploads')->assertExists($task->source_pdf_path);
$this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback($task->esign_sign_flow_id, '2', 'late-quote'));
$this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertNotFound();
try {
app(CourierQuoteSignatureService::class)->prepareVersion($task->version->fresh(), $this->user->id);
$this->fail('A deleted quote task must not overwrite retained files.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('signature', $exception->errors());
}
}
Http::assertNothingSent();
}
public function test_quote_deletion_requires_confirmation_company_and_management_permission(): void
{
$task = $this->quoteTask();
$this->loginAs()->deleteJson($this->deleteUrl($task))->assertStatus(422)->assertJsonValidationErrors('delete_confirmed');
$foreign = $this->quoteTask(['company_id' => $this->company->id + 100]);
$this->deleteJson($this->deleteUrl($foreign), ['delete_confirmed' => true])->assertNotFound();
$this->assertFalse($foreign->fresh()->trashed());
$this->user->roles()->detach();
$this->user->unsetRelation('roles');
$this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertForbidden();
$this->assertFalse($task->fresh()->trashed());
}
private function task(array $overrides = []): CourierAgreementSignature
@ -197,9 +238,38 @@ class CourierAgreementTaskDeletionTest extends FeatureTestCase
], $overrides));
}
private function deleteUrl(CourierAgreementSignature $task): string
private function quoteTask(array $overrides = []): CourierQuoteSignature
{
return route('business.courier-agreement-signatures.destroy', $task);
$key = uniqid();
$change = CourierQuoteChange::create([
'company_id' => $this->company->id, 'number' => 'BG-' . $key, 'source_quote_id' => 'QB-' . $key,
'source_sheet_name' => '测试报价', 'carrier_name' => '测试快递', 'quote_type' => '标准报价',
'change_type' => '面单调整', 'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => now()->toDateString(), 'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '删除测试报价', 'created_by' => $this->user->id,
'approved_by' => $this->user->id, 'approved_at' => now(),
]);
$version = CourierQuoteVersion::create([
'company_id' => $this->company->id, 'version_code' => 'QB-' . $key, 'source_quote_id' => 'QB-' . $key,
'source_change_id' => $change->id, 'sheet_name' => '测试报价', 'company_name' => $this->company->name,
'carrier_name' => '测试快递', 'quote_type' => '标准报价', 'effective_date' => now()->toDateString(),
'source_range' => 'A1:B2', 'values_json' => '[["地区","面单"],["广东省",6.5]]', 'formulas_json' => '[[],[]]',
'status' => 'approved', 'signature_status' => '未签署',
'created_by' => $this->user->id, 'approved_by' => $this->user->id,
]);
return CourierQuoteSignature::create(array_merge([
'company_id' => $this->company->id, 'quote_version_id' => $version->id,
'provider' => 'esign', 'environment' => 'sandbox', 'status' => 'signing', 'organization_name' => '测试快递',
'source_pdf_path' => 'delete-tests/quotes/' . $key . '.pdf', 'source_pdf_sha256' => str_repeat('b', 64),
'source_pdf_generated_at' => now(), 'created_by' => $this->user->id, 'updated_by' => $this->user->id,
], $overrides));
}
private function deleteUrl(CourierAgreementSignature|CourierQuoteSignature $task): string
{
$route = $task instanceof CourierQuoteSignature ? 'quotes.destroy' : 'destroy';
return route('business.courier-agreement-signatures.' . $route, $task);
}
private function pageUrl(): string