协议签署:每条公司主体增加删除功能 #304

Merged
limeng merged 2 commits from feature/limeng-signing-subject-delete into main 2026-10-11 14:26:32 +08:00
4 changed files with 228 additions and 4 deletions

View File

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

View File

@ -2125,10 +2125,20 @@
<div class="agreement-subject-list" data-subject-list aria-live="polite"></div>
<div data-subject-delete-panel hidden>
<p class="agreement-modal__hint"><span>确认删除公司主体“<strong data-subject-delete-name></strong>”?<span data-subject-delete-hint></span>历史协议的主体名称快照会保留。</span></p>
<p class="agreement-subject-form__error" role="alert" data-subject-delete-error></p>
<div class="agreement-subject-form__actions">
<button class="agreement-button" type="button" data-subject-delete-cancel>取消</button>
<button class="agreement-button agreement-button--danger" type="button" data-subject-delete-confirm>确认删除</button>
</div>
</div>
<form class="agreement-subject-form" method="POST" novalidate data-subject-form
data-store-url="{{ route('business.courier-agreement-signatures.subjects.store') }}"
data-update-url-template="{{ route('business.courier-agreement-signatures.subjects.update', ['subject' => '__subject__']) }}"
data-default-url-template="{{ route('business.courier-agreement-signatures.subjects.default', ['subject' => '__subject__']) }}">
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
<input type="hidden" name="subject_id" data-subject-id>
<h3 class="agreement-subject-form__title" data-subject-form-title>新增公司主体</h3>
@ -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);
}

View File

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

View File

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