From 597104430eb283b38952db5899b85d9a4616627a Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sun, 11 Oct 2026 14:26:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E7=AD=BE=E7=BD=B2=EF=BC=9A?= =?UTF-8?q?=E6=AF=8F=E6=9D=A1=E5=85=AC=E5=8F=B8=E4=B8=BB=E4=BD=93=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每条公司主体增加确认删除,保留历史快照,删除默认主体后选择剩余主体,全部删除回退当前公司。相关21项测试246条断言及隔离页面交互通过。 --- .../Business/CourierAgreementSignatures.php | 30 ++++- .../agreement-signing.blade.php | 86 ++++++++++++- routes/admin.php | 1 + .../Business/CourierSigningSubjectTest.php | 115 ++++++++++++++++++ 4 files changed, 228 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php index 5b85ea21..53ea1af5 100644 --- a/app/Http/Controllers/Business/CourierAgreementSignatures.php +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -289,6 +289,32 @@ class CourierAgreementSignatures extends Controller return $this->subjectResponse($subject->fresh(), '默认签署主体已切换。'); } + public function destroySubject(Request $request, CourierSigningSubject $subject): JsonResponse + { + $this->ensureCanManage(); + $this->ensureSubjectBelongsToCurrentCompany($subject); + + if ($response = $this->subjectsTableUnavailable()) { + return $response; + } + + $request->validate(['delete_confirmed' => ['required', 'accepted']]); + + DB::transaction(function () use ($subject): void { + $subject = CourierSigningSubject::query()->disableCache()->lockForUpdate()->findOrFail($subject->id); + $wasDefault = $subject->is_default; + $subject->forceFill(['is_default' => false, 'updated_by' => user_id()])->save(); + $subject->delete(); + + if ($wasDefault) { + $nextDefault = CourierSigningSubject::query()->disableCache()->orderBy('id')->lockForUpdate()->first(); + $nextDefault?->forceFill(['is_default' => true, 'updated_by' => user_id()])->save(); + } + }); + + return $this->subjectResponse(null, '公司主体已删除,历史协议的主体快照保留。'); + } + public function synchronize(CourierAgreementSignature $agreementSignature): RedirectResponse { $this->ensureCanManage(); @@ -576,11 +602,11 @@ class CourierAgreementSignatures extends Controller } } - private function subjectResponse(CourierSigningSubject $subject, string $message): JsonResponse + private function subjectResponse(?CourierSigningSubject $subject, string $message): JsonResponse { return response()->json([ 'message' => $message, - 'subject' => $this->serializeSubject($subject), + 'subject' => $subject ? $this->serializeSubject($subject) : null, 'subjects' => CourierSigningSubject::query() ->orderByDesc('is_default') ->orderBy('name') diff --git a/resources/views/business/pages/courier-payables/agreement-signing.blade.php b/resources/views/business/pages/courier-payables/agreement-signing.blade.php index 6808848d..3aef6e59 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -2125,10 +2125,20 @@
+ +
+ data-default-url-template="{{ route('business.courier-agreement-signatures.subjects.default', ['subject' => '__subject__']) }}" + data-delete-url-template="{{ route('business.courier-agreement-signatures.subjects.destroy', ['subject' => '__subject__']) }}"> @csrf

新增公司主体

@@ -2241,6 +2251,8 @@ var deletingTask = false; var taskLoadRequest = 0; var subjectList = root.querySelector('[data-subject-list]'); + var subjectDeletePanel = root.querySelector('[data-subject-delete-panel]'); + var subjectDeleteTarget = null; var subjectForm = root.querySelector('[data-subject-form]'); var subjectSelect = root.querySelector('[data-signing-subject-select]'); var subjectFormTitle = root.querySelector('[data-subject-form-title]'); @@ -2294,7 +2306,7 @@ var quoteVersionSelect = root.querySelector('[data-quote-version]'); var archiveFieldset = root.querySelector('[data-archive-fieldset]'); var subjects = @json($agreementSubjectPayload); - var fallbackSubjectName = @json($agreementCompanyName); + var fallbackSubjectName = @json(company()?->name ?: '当前公司'); var cooperationSecondaryEsignAccount = @json($agreementCooperationSecondaryEsignAccount); var secondaryEsignAccountInput = root.querySelector('input[name="secondary_esign_account"]'); var lastSubjectTrigger = null; @@ -2645,6 +2657,14 @@ actions.appendChild(makeDefault); } + var remove = document.createElement('button'); + remove.className = 'agreement-button agreement-button--danger'; + remove.type = 'button'; + remove.dataset.subjectDelete = String(subject.id); + remove.setAttribute('aria-label', '删除公司主体“' + subject.name + '”'); + remove.textContent = '删除'; + actions.appendChild(remove); + item.appendChild(info); item.appendChild(actions); subjectList.appendChild(item); @@ -2688,12 +2708,18 @@ subjectModal.hidden = true; subjectModal.setAttribute('aria-hidden', 'true'); + closeSubjectDelete(); resetSubjectForm(); if (lastSubjectTrigger && typeof lastSubjectTrigger.focus === 'function') { lastSubjectTrigger.focus(); } } + function closeSubjectDelete() { + subjectDeletePanel.hidden = true; + subjectDeleteTarget = null; + } + async function requestSubject(url, body) { var response = await fetch(url, { method: 'POST', @@ -3554,6 +3580,22 @@ if (subjectList) { subjectList.addEventListener('click', async function (event) { + var deleteButton = event.target.closest('[data-subject-delete]'); + if (deleteButton) { + subjectDeleteTarget = subjects.find(function (subject) { + return String(subject.id) === deleteButton.dataset.subjectDelete; + }); + if (!subjectDeleteTarget) return; + subjectDeletePanel.querySelector('[data-subject-delete-name]').textContent = subjectDeleteTarget.name; + subjectDeletePanel.querySelector('[data-subject-delete-hint]').textContent = subjects.length === 1 + ? '删除后将使用当前公司作为签署主体。' + : (subjectDeleteTarget.is_default ? '删除后将自动选择剩余主体作为默认主体。' : ''); + subjectDeletePanel.querySelector('[data-subject-delete-error]').textContent = ''; + subjectDeletePanel.hidden = false; + subjectDeletePanel.querySelector('[data-subject-delete-cancel]').focus(); + return; + } + var editButton = event.target.closest('[data-subject-edit]'); if (editButton) { openSubjectModal(editButton.dataset.subjectEdit); @@ -3584,6 +3626,46 @@ }); } + subjectDeletePanel.querySelector('[data-subject-delete-cancel]').addEventListener('click', function () { + var targetId = subjectDeleteTarget && String(subjectDeleteTarget.id); + closeSubjectDelete(); + var trigger = subjectList.querySelector('[data-subject-delete="' + targetId + '"]'); + if (trigger) trigger.focus(); + }); + + subjectDeletePanel.querySelector('[data-subject-delete-confirm]').addEventListener('click', async function () { + if (!subjectDeleteTarget) return; + var deletedId = String(subjectDeleteTarget.id); + var confirmButton = subjectDeletePanel.querySelector('[data-subject-delete-confirm]'); + var cancelButton = subjectDeletePanel.querySelector('[data-subject-delete-cancel]'); + var errorElement = subjectDeletePanel.querySelector('[data-subject-delete-error]'); + var body = new FormData(); + body.append('_token', subjectForm.querySelector('[name="_token"]').value); + body.append('_method', 'DELETE'); + body.append('delete_confirmed', '1'); + confirmButton.disabled = cancelButton.disabled = true; + confirmButton.textContent = '删除中…'; + errorElement.textContent = ''; + try { + var payload = await requestSubject(subjectUrl(subjectForm.dataset.deleteUrlTemplate, deletedId), body); + subjects = payload.subjects; + if (subjectIdInput.value === deletedId) resetSubjectForm(); + renderSubjectList(); + renderSubjectSelect(); + var selectedOption = subjectSelect && subjectSelect.options[subjectSelect.selectedIndex]; + syncSubjectLinkedNames(selectedOption && selectedOption.value); + refreshPreview(); + closeSubjectDelete(); + subjectNameInput.focus(); + announce(payload.message || '公司主体已删除'); + } catch (error) { + errorElement.textContent = error.message; + } finally { + confirmButton.disabled = cancelButton.disabled = false; + confirmButton.textContent = '确认删除'; + } + }); + if (subjectFormCancel) { subjectFormCancel.addEventListener('click', resetSubjectForm); } diff --git a/routes/admin.php b/routes/admin.php index dc9877d6..a08f67c1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -154,6 +154,7 @@ Route::prefix('workspace/courier-payables/agreement-signatures') Route::post('subjects', [CourierAgreementSignatures::class, 'storeSubject'])->name('subjects.store'); Route::post('subjects/{subject}/update', [CourierAgreementSignatures::class, 'updateSubject'])->name('subjects.update'); Route::post('subjects/{subject}/default', [CourierAgreementSignatures::class, 'setDefaultSubject'])->name('subjects.default'); + Route::delete('subjects/{subject}', [CourierAgreementSignatures::class, 'destroySubject'])->name('subjects.destroy'); 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'); diff --git a/tests/Feature/Business/CourierSigningSubjectTest.php b/tests/Feature/Business/CourierSigningSubjectTest.php index ecf7ed2f..e73826fa 100644 --- a/tests/Feature/Business/CourierSigningSubjectTest.php +++ b/tests/Feature/Business/CourierSigningSubjectTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierSigningSubject; use Tests\Feature\FeatureTestCase; @@ -71,4 +72,118 @@ class CourierSigningSubjectTest extends FeatureTestCase )->assertStatus(422) ->assertJsonValidationErrors(['name']); } + + public function testDeletingAnOrdinarySubjectPreservesTheDefaultAndHistoricalAgreement(): void + { + $this->grantWorkspaceAccess(); + $default = $this->subject('默认公司', true); + $subject = $this->subject('历史签署公司'); + $task = CourierAgreementSignature::create([ + 'company_id' => $this->company->id, + 'business_no' => 'AGR-SUBJECT-DELETE', + 'idempotency_key' => 'subject-delete-snapshot', + 'document_type' => 'account-proof', + 'template_code' => 'TPL-ACCOUNT-CERT-V2', + 'template_version' => '2', + 'source_file_name' => '收款账户证明文件.doc', + 'status' => CourierAgreementSignature::STATUS_ARCHIVED, + 'courier_name' => '测试快递', + 'company_full_name' => $subject->name, + 'organization_name' => '测试快递有限公司', + 'payload' => ['signing_subject' => $subject->name], + 'environment' => 'sandbox', + 'source_pdf_path' => 'subject-delete/source.pdf', + 'source_pdf_sha256' => str_repeat('a', 64), + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ]); + + $this->loginAs()->deleteJson($this->deleteUrl($subject), ['delete_confirmed' => true]) + ->assertOk()->assertJsonCount(1, 'subjects') + ->assertJsonPath('subjects.0.id', $default->id) + ->assertJsonPath('subjects.0.is_default', true); + + $this->assertSoftDeleted($subject); + $this->assertSame($this->user->id, $subject->fresh()->updated_by); + $this->assertSame($subject->name, $task->fresh()->company_full_name); + $this->assertSame($subject->name, $task->fresh()->payload['signing_subject']); + $this->assertFalse($task->fresh()->trashed()); + $this->withExceptionHandling()->deleteJson($this->deleteUrl($subject), ['delete_confirmed' => true])->assertNotFound(); + } + + public function testDeletingTheDefaultSelectsOneRemainingSubjectInTheSameCompany(): void + { + $this->grantWorkspaceAccess(); + $default = $this->subject('默认公司', true); + $next = $this->subject('剩余公司一'); + $other = $this->subject('剩余公司二'); + $foreign = $this->subject('其他租户公司', true, $this->company->id + 100); + + $this->loginAs()->deleteJson($this->deleteUrl($default), ['delete_confirmed' => true]) + ->assertOk()->assertJsonCount(2, 'subjects') + ->assertJsonPath('subjects.0.id', $next->id) + ->assertJsonPath('subjects.0.is_default', true); + + $this->assertSoftDeleted($default); + $this->assertTrue($next->fresh()->is_default); + $this->assertFalse($other->fresh()->is_default); + $this->assertTrue($foreign->fresh()->is_default); + $this->assertSame(1, CourierSigningSubject::query()->where('is_default', true)->count()); + } + + public function testLastSubjectCanBeDeletedAndANewSubjectBecomesDefault(): void + { + $this->grantWorkspaceAccess(); + $subject = $this->subject('唯一公司', true); + + $this->loginAs()->postJson($this->deleteUrl($subject), ['_method' => 'DELETE', 'delete_confirmed' => '1']) + ->assertOk()->assertJsonCount(0, 'subjects')->assertJsonPath('subject', null); + + $this->assertSoftDeleted($subject); + $this->postJson(route('business.courier-agreement-signatures.subjects.store'), ['name' => '唯一公司']) + ->assertOk()->assertJsonPath('subject.is_default', true); + } + + public function testSubjectDeletionRequiresConfirmation(): void + { + $this->grantWorkspaceAccess(); + $subject = $this->subject('保留公司', true); + + $this->withExceptionHandling()->loginAs()->deleteJson($this->deleteUrl($subject)) + ->assertStatus(422)->assertJsonValidationErrors(['delete_confirmed']); + + $this->assertFalse($subject->fresh()->trashed()); + } + + public function testSubjectDeletionEnforcesCompanyAndManagementPermission(): void + { + $this->grantWorkspaceAccess(); + $subject = $this->subject('本租户公司', true); + $foreign = $this->subject('其他租户公司', true, $this->company->id + 100); + + $this->withExceptionHandling()->loginAs()->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($subject), ['delete_confirmed' => true])->assertForbidden(); + $this->assertFalse($subject->fresh()->trashed()); + } + + private function subject(string $name, bool $default = false, ?int $companyId = null): CourierSigningSubject + { + return CourierSigningSubject::create([ + 'company_id' => $companyId ?? $this->company->id, + 'name' => $name, + 'is_default' => $default, + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ]); + } + + private function deleteUrl(CourierSigningSubject $subject): string + { + return route('business.courier-agreement-signatures.subjects.destroy', $subject); + } }