协议签署:增加合作方类型与供应商待接入展示

合作方类型与供应商待接入展示;53 项测试、825 条断言通过。
This commit is contained in:
limeng 2026-10-11 11:55:11 +08:00
parent 48553c001d
commit ad6d70a0a2
5 changed files with 158 additions and 10 deletions

View File

@ -37,6 +37,13 @@ class CourierAgreementSignatures extends Controller
public function store(Request $request): RedirectResponse
{
$this->ensureCanManage();
$request->merge(['partner_type' => $request->input('partner_type', 'courier')]);
$request->validate(['partner_type' => ['required', 'in:courier,supplier']]);
if ($request->input('partner_type') === 'supplier') {
throw ValidationException::withMessages([
'partner_type' => '供应商资料尚未接入,暂不能发起供应商签署。请在资料接入后选择供应商。',
]);
}
abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$requestedType = (string) $request->input('document_type');
if ($requestedType === 'quote-confirmation') {
@ -405,6 +412,7 @@ class CourierAgreementSignatures extends Controller
$date = ['required', 'date', 'after_or_equal:today', 'before_or_equal:' . now()->addDays(90)->toDateString()];
return [
'partner_type' => ['required', 'in:courier,supplier'],
'idempotency_key' => ['required', 'string', 'max:80', 'regex:/^[A-Za-z0-9_-]+$/'],
'courier_name' => ['required', 'string', 'max:255'],
'document_type' => ['required', 'in:' . implode(',', array_keys(CourierAgreementSignature::DOCUMENTS))],

View File

@ -4,6 +4,7 @@ namespace App\Models\Business;
use App\Abstracts\Model;
use App\Support\EsignErrorMessage;
use Illuminate\Contracts\Encryption\DecryptException;
class CourierAgreementSignature extends Model
{
@ -86,6 +87,19 @@ class CourierAgreementSignature extends Model
'operator_account' => 'encrypted',
];
public function partnerTypeLabel(): string
{
// Records created before partner types were introduced are courier tasks.
try {
$partnerType = $this->payload['partner_type'] ?? 'courier';
} catch (DecryptException) {
// Keep legacy rows with unreadable encrypted payloads renderable.
$partnerType = 'courier';
}
return $partnerType === 'supplier' ? '供应商' : '快递公司';
}
public function statusLabel(): string
{
return match ($this->status) {

View File

@ -937,6 +937,17 @@
font-size: 17px;
}
.agreement-partner-types {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.agreement-partner-types legend {
margin-bottom: 0.5rem;
}
.agreement-archive-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -1334,7 +1345,7 @@
<label for="agreement-task-search">搜索任务</label>
<div class="agreement-search">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<input class="agreement-control" id="agreement-task-search" name="agreement_search" type="search" value="{{ $agreementSearch }}" placeholder="任务编号、快递公司或签署人" autocomplete="off" data-task-filter>
<input class="agreement-control" id="agreement-task-search" name="agreement_search" type="search" value="{{ $agreementSearch }}" placeholder="任务编号、合作方名称或签署人" autocomplete="off" data-task-filter>
</div>
</div>
<div class="agreement-field">
@ -1385,7 +1396,8 @@
<thead>
<tr>
<th scope="col">任务编号</th>
<th scope="col">快递物流商</th>
<th scope="col">合作方类型</th>
<th scope="col">合作方名称</th>
<th scope="col">业务类型</th>
<th scope="col">签署状态</th>
<th scope="col">发起人</th>
@ -1399,6 +1411,7 @@
@php($quoteError = $quoteSignature->lastErrorSummary())
<tr data-quote-task-row>
<td>{{ $quoteVersion?->version_code ?? ('报价版本 #' . $quoteSignature->quote_version_id) }}</td>
<td>快递公司</td>
<td>{{ $quoteVersion?->carrier_name ?? $quoteSignature->organization_name }}</td>
<td>报价确认单</td>
<td class="agreement-status-cell">
@ -1443,6 +1456,7 @@
@php($taskFailed = $task->status === 'failed' || ($task->status === 'unknown' && $task->hasDefinitiveFailure()))
<tr data-agreement-task-row>
<td>{{ $task->business_no }}</td>
<td>{{ $task->partnerTypeLabel() }}</td>
<td>{{ $task->courier_name }}</td>
<td>{{ $task->documentLabel() }}</td>
<td class="agreement-status-cell">
@ -1492,7 +1506,7 @@
@endforeach
@if ($agreementTasks->isEmpty() && $agreementQuoteSignatures->isEmpty())
<tr class="agreement-empty-row">
<td colspan="7">
<td colspan="8">
<div class="agreement-empty">
<span class="agreement-empty__icon">
<span class="material-icons-outlined" aria-hidden="true">assignment_late</span>
@ -1549,8 +1563,15 @@
<section class="agreement-form-section" aria-labelledby="agreement-base-heading">
<h3 class="agreement-section-heading" id="agreement-base-heading">基础信息</h3>
<div class="agreement-form-grid">
<fieldset class="agreement-field agreement-field--full agreement-partner-types">
<legend class="agreement-field-label">合作方类型 <span class="agreement-required" aria-hidden="true">*</span></legend>
<div class="agreement-archive-options">
<label class="agreement-archive-choice"><input type="radio" name="partner_type" value="courier" checked data-partner-type><span><strong>快递公司</strong><span>选择快递资料,发起快递合作相关文件</span></span></label>
<label class="agreement-archive-choice"><input type="radio" name="partner_type" value="supplier" data-partner-type><span><strong>供应商</strong><span>按供应商简称、企业全称或编码查找</span></span></label>
</div>
</fieldset>
<div class="agreement-field">
<label for="agreement-courier">快递公司 <span class="agreement-required" aria-hidden="true">*</span></label>
<label for="agreement-courier"><span data-partner-name-label>快递公司</span> <span class="agreement-required" aria-hidden="true">*</span></label>
<div class="agreement-courier-search-wrap" data-courier-control>
<select class="agreement-control" id="agreement-courier" name="courier_name" data-courier-select required>
<option value="">请选择</option>
@ -1566,13 +1587,17 @@
<div class="agreement-courier-options" id="agreement-courier-options" role="listbox" aria-label="快递资料" hidden data-courier-options></div>
</div>
</div>
<div data-supplier-control hidden>
<input class="agreement-control" id="agreement-supplier-search" type="search" placeholder="输入供应商简称、全称或编码搜索" aria-describedby="agreement-supplier-help" disabled>
<p class="agreement-help" id="agreement-supplier-help">供应商资料尚未接入,暂不能选择或发起签署。接入后显示供应商简称,并在下方展示企业全称和编码供核对。</p>
</div>
</div>
<div class="agreement-field">
<label for="agreement-document-type">文件类型 <span class="agreement-required" aria-hidden="true">*</span></label>
<select class="agreement-control" id="agreement-document-type" name="document_type" data-document-type required>
<option value="account-proof" data-title="收款帐户资料证明" data-name="收款账户证明" data-code="TPL-ACCOUNT-CERT-V2" data-file="收款账户证明文件.doc" data-description="原文件:收款账户证明文件.doc;快递公司盖章" selected>收款账户证明</option>
<option value="account-change" data-title="收款帐户资料变更证明" data-name="收款账户变更" data-code="TPL-ACCOUNT-CHANGE-V1" data-file="收款账户变更证明.doc" data-description="原文件:收款账户变更证明.doc;快递公司盖章">收款账户变更</option>
<option value="entity-change" data-title="公司主体变更证明" data-name="公司主体变更" data-code="TPL-ENTITY-CHANGE-V1" data-file="公司主体变更证明.docx" data-description="原文件:公司主体变更证明.docx;变更前、变更后公司分别签字盖章">公司主体变更</option>
<option value="account-proof" data-partner-types="courier supplier" data-title="收款帐户资料证明" data-name="收款账户证明" data-code="TPL-ACCOUNT-CERT-V2" data-file="收款账户证明文件.doc" data-description="原文件:收款账户证明文件.doc;对方企业盖章或个人签字" selected>收款账户证明</option>
<option value="account-change" data-partner-types="courier supplier" data-title="收款帐户资料变更证明" data-name="收款账户变更" data-code="TPL-ACCOUNT-CHANGE-V1" data-file="收款账户变更证明.doc" data-description="原文件:收款账户变更证明.doc;对方企业盖章或个人签字">收款账户变更</option>
<option value="entity-change" data-partner-types="courier supplier" data-title="公司主体变更证明" data-name="公司主体变更" data-code="TPL-ENTITY-CHANGE-V1" data-file="公司主体变更证明.docx" data-description="原文件:公司主体变更证明.docx;变更前、变更后公司分别签字盖章">公司主体变更</option>
<option value="shared-contract" data-title="证明" data-name="共用合同证明" data-code="TPL-SHARED-CONTRACT-V1" data-file="共用合同证明.docx" data-description="物流商归属企业盖章确认共用合同及结算主体;也可选择个人实名签署">共用合同证明</option>
<option value="cooperation-contract" data-title="服务协议" data-name="合作合同" data-code="TPL-COOPERATION-CONTRACT-V1" data-file="保丟不保损-合同发快递2024.8.doc" data-description="原文件:保丟不保损-合同发快递2024.8.doc;甲乙双方签署盖章">合作合同</option>
<option value="authorization" data-title="授权委托书" data-name="授权委托书" data-code="TPL-AUTHORIZATION-V1" data-file="授权委托书(快递).docx" data-description="原文件:授权委托书(快递).docx;委托方和受托方签署">授权委托书</option>
@ -1582,7 +1607,7 @@
<div class="agreement-template-note" role="status">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<div><strong><span data-template-name>收款账户证明</span> <span data-template-code>TPL-ACCOUNT-CERT-V2</span></strong><p data-template-description>原文件:收款账户证明文件.doc;快递公司盖章</p></div>
<div><strong><span data-template-name>收款账户证明</span> <span data-template-code>TPL-ACCOUNT-CERT-V2</span></strong><p data-template-description>原文件:收款账户证明文件.doc;对方企业盖章或个人签字</p></div>
</div>
<div class="agreement-field">
@ -1618,7 +1643,7 @@
<div class="agreement-field">
<label for="agreement-esign-account"><span data-primary-esign-label>对方 E 签宝账号</span></label>
<input class="agreement-control" id="agreement-esign-account" name="esign_account" type="text" value="" data-esign-account placeholder="手机号或邮箱" autocomplete="email" required>
<p class="agreement-help">选择快递公司后自动带出工作平台的“电子签章经办人”(dzqzjbr);如资料未维护,可手工填写对方在 E 签宝实名绑定的手机号或邮箱。</p>
<p class="agreement-help" data-partner-account-help>选择快递公司后自动带出工作平台的“电子签章经办人”(dzqzjbr);如资料未维护,可手工填写对方在 E 签宝实名绑定的手机号或邮箱。</p>
</div>
<div class="agreement-field agreement-field--full" data-account-signer-type-field>
<span class="agreement-field-label" data-account-signer-type-label>对方签署主体类型 <span class="agreement-required" aria-hidden="true">*</span></span>
@ -2222,6 +2247,13 @@
var courierOptionsList = root.querySelector('[data-courier-options]');
var courierControl = root.querySelector('[data-courier-control]');
var courierLabel = root.querySelector('label[for="agreement-courier"]');
var partnerTypeInputs = Array.prototype.slice.call(root.querySelectorAll('[data-partner-type]'));
var supplierControl = root.querySelector('[data-supplier-control]');
var documentSelect = form.querySelector('[data-document-type]');
var documentOptions = Array.prototype.slice.call(documentSelect.options);
var currentPartnerType = 'courier';
var sendButton = root.querySelector('[data-sign-request-send]');
var esignAvailable = @json($esignConfigured);
var courierOptions = courierSelect ? Array.prototype.filter.call(courierSelect.options, function (option) {
return option.value !== '' && !option.disabled;
}).map(function (option) {
@ -2712,6 +2744,49 @@
return select.options[select.selectedIndex];
}
function syncPartnerType() {
var selected = partnerTypeInputs.find(function (input) { return input.checked; });
var type = selected ? selected.value : 'courier';
var supplier = type === 'supplier';
if (type !== currentPartnerType) {
closeCourierOptions();
courierSelect.value = '';
courierSearch.value = '';
esignAccountInput.value = '';
delete esignAccountInput.dataset.autoFilled;
accountOrganizationInput.value = '';
operatorNameInput.value = '';
secondaryEsignAccountInput.value = '';
secondaryOperatorInput.value = '';
root.querySelector('[data-request-error]').textContent = '';
}
currentPartnerType = type;
courierControl.hidden = supplier;
courierSelect.disabled = supplier;
courierSearch.disabled = supplier;
supplierControl.hidden = !supplier;
courierLabel.htmlFor = supplier ? 'agreement-supplier-search' : courierSearch.id;
root.querySelector('[data-partner-name-label]').textContent = supplier ? '供应商名称' : '快递公司';
root.querySelector('[data-partner-account-help]').textContent = supplier
? '供应商资料接入后可带出签署账号和经办人;发送前需核对对方 E 签宝实名信息。'
: '选择快递公司后自动带出工作平台的“电子签章经办人”(dzqzjbr);如资料未维护,可手工填写对方在 E 签宝实名绑定的手机号或邮箱。';
// Rebuild the small template list so unavailable options are absent on every browser.
var previousDocument = documentSelect.value;
documentSelect.replaceChildren();
documentOptions.forEach(function (option) {
var types = (option.dataset.partnerTypes || 'courier').split(' ');
if (types.indexOf(type) !== -1) documentSelect.appendChild(option);
});
if (Array.prototype.some.call(documentSelect.options, function (option) { return option.value === previousDocument; })) {
documentSelect.value = previousDocument;
} else {
documentSelect.value = 'account-proof';
}
sendButton.disabled = supplier || !esignAvailable;
sendButton.title = supplier ? '供应商资料尚未接入,暂不能发起签署' : (esignAvailable ? '推送至E签宝' : 'E签宝沙箱应用尚未配置');
sendButton.querySelector('span:last-child').textContent = supplier ? '供应商资料待接入' : '推送至E签宝';
}
function fieldValue(selector, fallback) {
var field = form.querySelector(selector);
return field && field.value.trim() ? field.value.trim() : fallback;
@ -3159,7 +3234,7 @@
function refreshPreview() {
var documentOption = selectedDocument();
var templateType = documentOption.value;
var courier = fieldValue('[name="courier_name"]', '快递公司');
var courier = currentPartnerType === 'supplier' ? '供应商' : fieldValue('[name="courier_name"]', '快递公司');
var companyName = fieldValue('[name="company_full_name"]', '待填写公司全称');
var accountAgreement = templateType === 'account-proof' || templateType === 'account-change';
var entityChange = templateType === 'entity-change';
@ -3575,12 +3650,25 @@
announce('协议预览已刷新');
});
partnerTypeInputs.forEach(function (input) {
input.addEventListener('change', function () {
syncPartnerType();
refreshPreview();
announce(currentPartnerType === 'supplier' ? '已切换到供应商,资料尚未接入' : '已切换到快递公司');
});
});
form.querySelectorAll('input, select, textarea').forEach(function (control) {
control.addEventListener('input', refreshPreview);
control.addEventListener('change', refreshPreview);
});
form.addEventListener('submit', function (event) {
if (currentPartnerType === 'supplier') {
event.preventDefault();
root.querySelector('[data-request-error]').textContent = '供应商资料尚未接入,暂不能发起供应商签署。';
return;
}
refreshPreview();
if (!form.checkValidity()) {
event.preventDefault();
@ -3652,6 +3740,7 @@
var initialUrl = new URL(window.location.href);
var initialView = initialUrl.searchParams.get('agreement_view') || 'tasks';
initializeCourierSearch();
syncPartnerType();
renderSubjectSelect();
renderSubjectList();
showTab(initialView, { updateUrl: false });

View File

@ -62,6 +62,10 @@ class AgreementSigningPageTest extends FeatureTestCase
->assertSee('data-sign-request-send', false)
->assertDontSee('data-agreement-step-panel', false)
->assertSeeText('发起签章请求')
->assertSeeText('合作方类型')
->assertSee('name="partner_type" value="courier" checked', false)
->assertSee('name="partner_type" value="supplier"', false)
->assertSeeText('供应商资料尚未接入,暂不能选择或发起签署。')
->assertSeeText('快递公司')
->assertSee('data-courier-search', false)
->assertSee('placeholder="输入快递名称搜索"', false)
@ -165,6 +169,9 @@ class AgreementSigningPageTest extends FeatureTestCase
]))
->assertOk()
->assertSee('<td>' . e($this->company->name) . '</td>', false)
->assertSeeText('合作方类型')
->assertSeeText('合作方名称')
->assertSee('<td>快递公司</td>', false)
->assertDontSee('<td>张经理</td>', false);
}

View File

@ -53,6 +53,8 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
$this->assertSame('flow-agreement-001', $signature->esign_sign_flow_id);
$this->assertSame('新北中通快递有限公司', $signature->organization_name);
$this->assertSame('2026-09-21', $signature->payload['signature_date']);
$this->assertSame('courier', $signature->payload['partner_type']);
$this->assertSame('快递公司', $signature->partnerTypeLabel());
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
$this->assertSame('新北中通快递有限公司', $signature->payload['account_proof_account_name']);
@ -71,6 +73,34 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
$this->assertSame(1, CourierAgreementSignature::query()->count());
}
public function test_supplier_requests_are_rejected_before_creating_or_sending_any_document(): void
{
Http::preventStrayRequests();
$this->withExceptionHandling();
$payload = $this->accountProofPayload('supplier-not-connected');
$payload['partner_type'] = 'supplier';
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
->assertSessionHasErrors(['partner_type' => '供应商资料尚未接入,暂不能发起供应商签署。请在资料接入后选择供应商。']);
$this->assertSame(0, CourierAgreementSignature::query()->count());
Http::assertNothingSent();
}
public function test_unknown_partner_type_is_rejected_before_sending(): void
{
Http::preventStrayRequests();
$this->withExceptionHandling();
$payload = $this->accountProofPayload('unknown-partner-type');
$payload['partner_type'] = 'unknown';
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
->assertSessionHasErrors('partner_type');
$this->assertSame(0, CourierAgreementSignature::query()->count());
Http::assertNothingSent();
}
public function test_account_proof_accepts_private_account_holder_name_different_from_company_name(): void
{
$this->fakeEsign();