协议签署:每条公司主体增加删除功能 (#304)
每条公司主体增加确认删除,保留历史快照,删除默认主体后选择剩余主体,全部删除回退当前公司。相关21项测试246条断言及隔离页面交互通过。
This commit is contained in:
parent
976db7e4f1
commit
597104430e
@ -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')
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user