feat(协议签署): 接入供应商代发、代销和生产合作合同模板 #306

Merged
limeng merged 1 commits from feature/limeng-supplier-contract-templates into main 2026-10-12 10:48:05 +08:00
17 changed files with 9608 additions and 40 deletions

View File

@ -11,6 +11,7 @@ use App\Support\CourierAgreementFileArchive;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteConfirmation;
use App\Support\CourierQuoteSignatureService;
use App\Support\SupplierCooperationTemplates;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
@ -19,6 +20,7 @@ use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Rule;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -41,12 +43,10 @@ class CourierAgreementSignatures extends Controller
$request->validate(['partner_type' => ['required', 'in:courier,supplier']]);
if ($request->input('partner_type') === 'supplier') {
if ($request->input('document_type') === 'cooperation-contract') {
throw ValidationException::withMessages([
'document_type' => '供应商合作合同模板尚未提供,暂不能发起签署。',
]);
$request->validate(['supplier_contract_template' => ['required', Rule::in(array_keys(SupplierCooperationTemplates::TEMPLATES))]]);
}
$request->validate(['supplier_id' => ['required', 'integer', 'min:1'],
'document_type' => ['required', 'in:account-proof,account-change,entity-change,authorization']]);
'document_type' => ['required', 'in:account-proof,account-change,entity-change,authorization,cooperation-contract']]);
$supplier = app(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class)
->signingSupplier((int) $request->input('supplier_id'));
if (! $supplier) throw ValidationException::withMessages(['supplier_id' => '供应商资料暂不可用,请重新选择供应商。']);
@ -65,7 +65,7 @@ class CourierAgreementSignatures extends Controller
abort_unless(Schema::hasTable('business_courier_agreement_signatures'), 503, '协议签署数据表尚未完成迁移,请先执行数据库迁移。');
}
if ($request->input('document_type') === 'cooperation-contract') {
if ($request->input('document_type') === 'cooperation-contract' && $request->input('partner_type') === 'courier') {
// 合作合同统一由我方固定 E 签宝账号完成第二签署方签署。
$request->merge([
'secondary_esign_account' => self::COOPERATION_SECONDARY_ESIGN_ACCOUNT,
@ -149,7 +149,8 @@ class CourierAgreementSignatures extends Controller
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = trim((string) $data['cooperation_party_b_name']);
} elseif (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => '请填写快递方企业经办人的 E 签宝实名认证姓名。']);
$party = $data['partner_type'] === 'supplier' ? '供应商' : '快递方';
throw ValidationException::withMessages(['operator_name' => "请填写{$party}企业经办人的 E 签宝实名认证姓名。"]);
}
if (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写我方 E 签宝账号绑定的实名认证姓名。']);
@ -158,7 +159,7 @@ class CourierAgreementSignatures extends Controller
if ($data['counterparty_signer_type'] === 'organization') {
$companyNames[] = trim($data['cooperation_party_b_name']);
}
foreach (['operator_name' => '快递方', 'secondary_operator_name' => '我方'] as $field => $party) {
foreach (['operator_name' => $data['partner_type'] === 'supplier' ? '供应商' : '快递方', 'secondary_operator_name' => '我方'] as $field => $party) {
$data[$field] = trim($data[$field]);
if (in_array($data[$field], $companyNames, true)) {
throw ValidationException::withMessages([
@ -446,7 +447,21 @@ class CourierAgreementSignatures extends Controller
{
$date = ['required', 'date', 'after_or_equal:today', 'before_or_equal:' . now()->addDays(90)->toDateString()];
$supplierContract = request('partner_type') === 'supplier' && request('document_type') === 'cooperation-contract';
$supplierFields = [];
foreach (SupplierCooperationTemplates::FIELDS as $key => $field) {
$supplierFields['supplier_contract_fields.' . $key] = [
Rule::requiredIf($supplierContract && ($field['required'] ?? false)), 'nullable', 'string',
'max:' . ($key === 'supply_price' ? 1000 : 255),
];
if (($field['type'] ?? '') === 'email') {
$supplierFields['supplier_contract_fields.' . $key][] = 'email';
}
}
return [
'supplier_contract_template' => [Rule::requiredIf($supplierContract), 'nullable', Rule::in(array_keys(SupplierCooperationTemplates::TEMPLATES)), Rule::prohibitedIf(! $supplierContract)],
'supplier_contract_fields' => ['nullable', 'array:' . implode(',', array_keys(SupplierCooperationTemplates::FIELDS))],
'partner_type' => ['required', 'in:courier,supplier'],
'supplier_id' => ['required_if:partner_type,supplier', 'nullable', 'integer', 'min:1'],
'idempotency_key' => ['required', 'string', 'max:80', 'regex:/^[A-Za-z0-9_-]+$/'],
@ -504,7 +519,7 @@ class CourierAgreementSignatures extends Controller
'payment_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'authorization_start_date' => ['required_if:document_type,authorization', 'date'],
'authorization_end_date' => ['required_if:document_type,authorization', 'date', 'after_or_equal:authorization_start_date'],
];
] + $supplierFields;
}
private function payload(array $data): array
@ -517,6 +532,9 @@ class CourierAgreementSignatures extends Controller
$payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? ''));
$payload['entity_proof_date'] = $data['signature_date'] ?? null;
}
if (($data['partner_type'] ?? 'courier') === 'supplier' && ($data['document_type'] ?? '') === 'cooperation-contract') {
$payload['supplier_contract_signer_name'] = $data['operator_name'];
}
return $payload;
}

View File

@ -135,6 +135,11 @@ class CourierAgreementSignature extends Model
public function documentLabel(): string
{
foreach (\App\Support\SupplierCooperationTemplates::TEMPLATES as $template) {
if ($this->template_code === $template['code']) {
return $template['name'];
}
}
return self::DOCUMENTS[$this->document_type]['name'] ?? $this->document_type;
}

View File

@ -20,7 +20,8 @@ final class CourierAgreementPdf
} catch (\Throwable) {
$signatureDate = '待填写';
}
$html = view('business.pdf.courier-agreement', [
$supplierContract = app(SupplierCooperationTemplates::class)->isSupplierContract($signature);
$html = view($supplierContract ? 'business.pdf.supplier-cooperation-contract' : 'business.pdf.courier-agreement', [
'signature' => $signature,
'type' => $signature->document_type,
'title' => $this->title($signature),

View File

@ -23,6 +23,9 @@ final class CourierAgreementSignatureService
abort_if(! EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$companyId = (int) (company_id() ?: 1);
$document = CourierAgreementSignature::DOCUMENTS[$data['document_type']] ?? null;
if ($data['document_type'] === 'cooperation-contract' && ($data['partner_type'] ?? 'courier') === 'supplier') {
$document = app(SupplierCooperationTemplates::class)->metadata((string) ($data['supplier_contract_template'] ?? ''));
}
if (! $document) {
throw ValidationException::withMessages(['document_type' => '不支持的协议文件类型。']);

View File

@ -0,0 +1,101 @@
<?php
namespace App\Support;
use App\Models\Business\CourierAgreementSignature;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
final class SupplierCooperationTemplates
{
public const TEMPLATES = [
'dropshipping' => ['name' => '产品代发协议', 'code' => 'TPL-SUPPLIER-DROPSHIPPING-V1', 'version' => '1', 'file' => '产品代发协议-2022年确定版.docx', 'attachments' => 2],
'consignment' => ['name' => '产品代销协议', 'code' => 'TPL-SUPPLIER-CONSIGNMENT-V1', 'version' => '1', 'file' => '产品代销协议-2022确定版.docx', 'attachments' => 4],
'production' => ['name' => '生产合作协议', 'code' => 'TPL-SUPPLIER-PRODUCTION-V1', 'version' => '1', 'file' => '生产合作协议-2022确定版.docx', 'attachments' => 5],
];
public const FIELDS = [
'number' => ['label' => '合同编号', 'required' => true],
'location' => ['label' => '合同签订地', 'required' => true, 'default' => '常州市武进区'],
'party_a_address' => ['label' => '甲方地址'],
'party_a_legal' => ['label' => '甲方法定代表人'],
'party_a_contact' => ['label' => '甲方联系人'],
'party_a_email' => ['label' => '甲方邮箱', 'type' => 'email'],
'party_a_fax' => ['label' => '甲方传真'],
'party_b_address' => ['label' => '乙方地址'],
'party_b_legal' => ['label' => '乙方法定代表人'],
'party_b_contact' => ['label' => '乙方联系人'],
'party_b_email' => ['label' => '乙方邮箱', 'type' => 'email'],
'party_b_fax' => ['label' => '乙方传真'],
'account_name' => ['label' => '合同账户名称(原文为甲方付款账户)'],
'account_number' => ['label' => '合同银行账号'],
'bank_name' => ['label' => '合同开户行'],
'brands' => ['label' => '生产协议使用的品牌', 'default' => '亿家达、欧意朗', 'templates' => ['production']],
'product_model' => ['label' => '采购订单产品型号/SKU', 'templates' => ['consignment', 'production']],
'supply_price' => ['label' => '采购订单供货价格/明细说明', 'templates' => ['consignment', 'production']],
'purchase_contact' => ['label' => '采购对接人(订单附件)', 'templates' => ['consignment', 'production']],
'purchase_qq' => ['label' => '采购对接人 QQ', 'templates' => ['consignment', 'production']],
'purchase_wechat' => ['label' => '采购对接人微信', 'templates' => ['consignment', 'production']],
'purchase_phone' => ['label' => '采购对接人电话', 'templates' => ['consignment', 'production']],
];
public function metadata(string $key): array
{
return self::TEMPLATES[$key] ?? throw ValidationException::withMessages([
'supplier_contract_template' => '请选择有效的供应商合作合同模板。',
]);
}
public function blocks(string $key): array
{
$this->metadata($key);
return json_decode(file_get_contents(resource_path("contracts/supplier/{$key}-v1.json")), true, 512, JSON_THROW_ON_ERROR)['blocks'];
}
public function values(array $payload): array
{
$values = [];
foreach (self::FIELDS as $key => $field) {
$values[$key] = trim((string) ($payload['supplier_contract_fields'][$key] ?? ''));
}
$values += [
'party_a' => $payload['cooperation_party_a_name'] ?? $payload['company_full_name'] ?? '',
'party_b' => $payload['cooperation_party_b_name'] ?? '',
'party_a_phone' => $payload['cooperation_party_a_phone'] ?? '',
'party_b_phone' => $payload['cooperation_party_b_phone'] ?? '',
'start_date' => $this->date($payload['cooperation_start_date'] ?? ''),
'end_date' => $this->date($payload['cooperation_end_date'] ?? ''),
'signature_date' => $this->date($payload['signature_date'] ?? ''),
'party_a_signer' => $payload['secondary_operator_name'] ?? '',
'party_b_signer' => $payload['supplier_contract_signer_name'] ?? '',
];
return $values;
}
public function text(string $text, array $values, bool $preview = false): string
{
$parts = preg_split('/(\[\[[a-z_]+\]\])/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
return implode('', array_map(static function (string $part) use ($values, $preview): string {
if (preg_match('/^\[\[([a-z_]+)\]\]$/', $part, $match)) {
$value = trim((string) ($values[$match[1]] ?? ''));
return $preview
? '<span class="agreement-preview-value" data-supplier-bind="' . e($match[1]) . '">' . e($value ?: '待填写') . '</span>'
: e($value ?: '________');
}
return e($part);
}, $parts));
}
public function isSupplierContract(CourierAgreementSignature $signature): bool
{
return $signature->document_type === 'cooperation-contract'
&& ($signature->payload['partner_type'] ?? 'courier') === 'supplier';
}
private function date(string $value): string
{
return $value === '' ? '' : Carbon::parse($value)->format('Y年n月j日');
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@
@php($agreementCourierNames = collect($agreementCourierNames ?? []))
@php($agreementCourierAccounts = $agreementCourierAccounts ?? [])
@php($agreementSupplierOptions = $agreementSupplierOptions ?? [])
@php($supplierContractTemplates = \App\Support\SupplierCooperationTemplates::TEMPLATES)
@php($supplierContractRequestKey = (string) \Illuminate\Support\Str::uuid())
@php($agreementQuoteVersions = $agreementQuoteVersions ?? collect())
@php($agreementCooperationSecondaryEsignAccount = '18052733960')
@php($agreementDefaultSubject = $agreementSubjects->firstWhere('is_default', true) ?: $agreementSubjects->first())
@ -50,6 +52,18 @@
display: none !important;
}
.supplier-contract-body { line-height: 1.8; }
.supplier-contract-body .supplier-contract-heading { font-size: 14px; margin: 18px 0 8px; }
.supplier-contract-paragraph { margin: 0 0 8px; overflow-wrap: anywhere; }
.supplier-contract-table { width: 100%; border-collapse: collapse; table-layout: fixed; margin: 12px 0; }
.supplier-contract-table td { border: 1px solid var(--agreement-border); padding: 6px; vertical-align: top; }
.supplier-contract-signatures { width: 100%; table-layout: fixed; margin: 18px 0; }
.supplier-contract-signatures td { width: 50%; vertical-align: top; padding: 8px; }
.supplier-contract-signatures p { margin: 0 0 8px; }
.supplier-contract-seal-space { height: 85px; }
.supplier-contract-image { margin: 12px 0; text-align: center; }
.supplier-contract-image img { max-width: 100%; height: auto; }
.agreement-tabs,
.agreement-panel {
min-width: 0;
@ -1554,7 +1568,7 @@
<div class="agreement-request-layout">
<form class="agreement-request-form" method="POST" action="{{ route('business.courier-agreement-signatures.store') }}" novalidate data-agreement-form data-signing-request-form>
@csrf
<input type="hidden" name="idempotency_key" value="{{ (string) \Illuminate\Support\Str::uuid() }}">
<input type="hidden" name="idempotency_key" value="{{ $supplierContractRequestKey }}">
<header class="agreement-request-header">
<h2 class="agreement-request-title"><span class="material-icons-outlined" aria-hidden="true">draw</span><span>发起签章请求</span></h2>
<span class="agreement-status agreement-status--{{ $esignConfigured ? 'success' : 'warning' }}">{{ $esignConfigured ? 'E签宝可用' : '沙箱未配置' }}</span>
@ -1676,7 +1690,7 @@
<div class="agreement-field" data-secondary-signer-fields hidden>
<label for="agreement-secondary-esign-account"><span data-secondary-esign-label>第二签署方 E 签宝账号</span> <span class="agreement-required" aria-hidden="true">*</span></label>
<input class="agreement-control" id="agreement-secondary-esign-account" name="secondary_esign_account" type="text" placeholder="填写另一签署方的手机号或邮箱" autocomplete="email" disabled>
<p class="agreement-help">主体变更、合作合同和授权委托书需要双方分别实名签署;合作合同统一使用我方账号 {{ $agreementCooperationSecondaryEsignAccount }}。</p>
<p class="agreement-help">主体变更、合作合同和授权委托书需要双方分别实名签署;快递合作合同使用我方账号 {{ $agreementCooperationSecondaryEsignAccount }},供应商合作合同请填写我方签署账号。</p>
</div>
<div class="agreement-field" data-secondary-operator-field hidden>
<label for="agreement-secondary-operator-name"><span data-secondary-operator-label>变更后主体 E 签宝实名认证姓名</span> <span class="agreement-required" aria-hidden="true">*</span></label>
@ -1820,8 +1834,25 @@
</div>
</div>
<div data-template-fields="supplier-cooperation-pending" hidden>
<p class="agreement-help">供应商合作合同模板尚未提供。提供正式模板后,可在这里填写合同内容。</p>
<div data-supplier-contract-fields hidden>
<div class="agreement-field">
<label for="agreement-supplier-contract-template">合同模板 <span class="agreement-required" aria-hidden="true">*</span></label>
<select class="agreement-control" id="agreement-supplier-contract-template" name="supplier_contract_template" data-supplier-contract-template disabled required>
@foreach ($supplierContractTemplates as $key => $template)
<option value="{{ $key }}" data-name="{{ $template['name'] }}" data-code="{{ $template['code'] }}" data-file="{{ $template['file'] }}">{{ $template['name'] }} · V{{ $template['version'] }}</option>
@endforeach
</select>
<p class="agreement-help">正文和附件按所选模板一起签署,签署任务保留本次模板版本。</p>
</div>
<div class="agreement-form-grid">
@foreach (\App\Support\SupplierCooperationTemplates::FIELDS as $key => $field)
<div class="agreement-field" data-supplier-field-templates="{{ implode(' ', $field['templates'] ?? array_keys($supplierContractTemplates)) }}">
<label for="supplier-contract-{{ $key }}">{{ $field['label'] }} @if($field['required'] ?? false)<span class="agreement-required" aria-hidden="true">*</span>@endif</label>
<input class="agreement-control" id="supplier-contract-{{ $key }}" name="supplier_contract_fields[{{ $key }}]" data-supplier-field="{{ $key }}" type="{{ $field['type'] ?? 'text' }}" value="{{ $key === 'number' ? 'SUP-' . strtoupper(substr(str_replace('-', '', $supplierContractRequestKey), 0, 12)) : ($field['default'] ?? '') }}" maxlength="{{ $key === 'supply_price' ? 1000 : 255 }}" @required($field['required'] ?? false) disabled>
</div>
@endforeach
</div>
<p class="agreement-help">按原合同填写银行账户资料;其余空白项可按本次合作情况补充,发送前请查看完整正文及附件。</p>
</div>
<div data-template-fields="authorization" hidden>
@ -2000,10 +2031,12 @@
<p class="agreement-preview-page-number">原合同全文预览</p>
</article>
<article class="agreement-preview-paper" data-template-preview="supplier-cooperation-pending" hidden>
<h3>合作合同</h3>
<p>供应商合作合同模板待提供。</p>
<p>提供正式合同模板后,可在这里查看合同预览。</p>
<article class="agreement-preview-paper" data-template-preview="supplier-cooperation" hidden>
@foreach ($supplierContractTemplates as $key => $template)
<div data-supplier-contract-preview="{{ $key }}" @if(!$loop->first) hidden @endif>
@include('business.partials.supplier-cooperation-contract', ['templateKey' => $key, 'mode' => 'preview', 'payload' => []])
</div>
@endforeach
</article>
<article class="agreement-preview-paper" data-template-preview="authorization" hidden>
@ -2092,6 +2125,9 @@
<tr><td>公司主体变更</td><td>公司主体变更证明.docx</td><td>本地映射 V1</td><td>变更前后名称、变更日期、启用日期</td><td>变更前、变更后公司</td><td><span class="agreement-status agreement-status--warning">法人签字并盖章</span></td><td><button class="agreement-icon-button" type="button" disabled title="E签宝模板尚未创建"><span class="material-icons-outlined" aria-hidden="true">settings</span></button></td></tr>
<tr><td>共用合同证明</td><td>共用合同证明.docx</td><td>本地映射 V1</td><td>我方合作公司、物流商归属主体、共用合同快递、证明日期</td><td>物流商归属企业或个人</td><td><span class="agreement-status agreement-status--warning">企业盖章或个人签字</span></td><td><button class="agreement-icon-button" type="button" disabled title="E签宝模板尚未创建"><span class="material-icons-outlined" aria-hidden="true">settings</span></button></td></tr>
<tr><td>合作合同</td><td>保丟不保损-合同发快递2024.8.doc</td><td>本地映射 V1</td><td>甲乙方名称、甲乙方电话、协议起止日期</td><td>甲方、乙方</td><td><span class="agreement-status agreement-status--warning">双方签字盖章</span></td><td><button class="agreement-icon-button" type="button" disabled title="E签宝模板尚未创建"><span class="material-icons-outlined" aria-hidden="true">settings</span></button></td></tr>
@foreach ($supplierContractTemplates as $template)
<tr><td>{{ $template['name'] }}</td><td>{{ $template['file'] }}</td><td>V{{ $template['version'] }}</td><td>甲乙方资料、编号、日期、账户及附件变量</td><td>我方、供应商</td><td><span class="agreement-status agreement-status--warning">双方签署 · 含{{ $template['attachments'] }}项附件</span></td><td><span class="agreement-help">发起签章时选择</span></td></tr>
@endforeach
<tr><td>授权委托书</td><td>授权委托书(快递).docx</td><td>本地映射 V1</td><td>委托双方、授权事项、往来方、有效期</td><td>委托方、受托方</td><td><span class="agreement-status agreement-status--warning">双方签署</span></td><td><button class="agreement-icon-button" type="button" disabled title="E签宝模板尚未创建"><span class="material-icons-outlined" aria-hidden="true">settings</span></button></td></tr>
<tr><td>报价确认单</td><td>报价版本快照</td><td>本地映射 V1</td><td>版本号、快递公司、报价类型、有效期、报价明细</td><td>快递公司</td><td><span class="agreement-status agreement-status--warning">快递方签章</span></td><td><button class="agreement-icon-button" type="button" disabled title="E签宝模板尚未创建"><span class="material-icons-outlined" aria-hidden="true">settings</span></button></td></tr>
</tbody>
@ -2273,6 +2309,7 @@
var supplierIdInput = root.querySelector('[data-signing-supplier-id]');
var supplierOptions = @json($agreementSupplierOptions);
var documentSelect = form.querySelector('[data-document-type]');
var supplierContractSelect = form.querySelector('[data-supplier-contract-template]');
var documentOptions = Array.prototype.slice.call(documentSelect.options);
var currentPartnerType = 'courier';
var sendButton = root.querySelector('[data-sign-request-send]');
@ -3171,7 +3208,7 @@
function activateTemplate(templateType) {
root.querySelectorAll('[data-template-fields]').forEach(function (group) {
var active = group.dataset.templateFields === templateType;
var active = group.dataset.templateFields === templateType || (templateType === 'supplier-cooperation' && group.dataset.templateFields === 'cooperation-contract');
group.hidden = !active;
group.querySelectorAll('input, select, textarea').forEach(function (control) {
control.disabled = !active;
@ -3310,16 +3347,16 @@
function refreshPreview() {
var documentOption = selectedDocument();
var templateType = documentOption.value;
var supplierCooperationPending = currentPartnerType === 'supplier' && templateType === 'cooperation-contract';
var supplierContract = currentPartnerType === 'supplier' && templateType === 'cooperation-contract';
var supplierUnavailable = currentPartnerType === 'supplier' && !supplierOptions.length;
sendButton.disabled = supplierCooperationPending || supplierUnavailable || !esignAvailable;
sendButton.title = supplierCooperationPending ? '供应商合作合同模板尚未提供' : supplierUnavailable ? '供应商资料暂不可用' : (esignAvailable ? '推送至E签宝' : 'E签宝尚未配置');
sendButton.querySelector('span:last-child').textContent = supplierCooperationPending ? '合作合同模板待提供' : '推送至E签宝';
sendButton.disabled = supplierUnavailable || !esignAvailable;
sendButton.title = supplierUnavailable ? '供应商资料暂不可用' : (esignAvailable ? '推送至E签宝' : 'E签宝尚未配置');
sendButton.querySelector('span:last-child').textContent = '推送至E签宝';
var courier = fieldValue('[name="courier_name"]', currentPartnerType === 'supplier' ? '供应商' : '快递公司');
var companyName = fieldValue('[name="company_full_name"]', '待填写公司全称');
var accountAgreement = templateType === 'account-proof' || templateType === 'account-change';
var entityChange = templateType === 'entity-change';
var cooperationContract = templateType === 'cooperation-contract' && !supplierCooperationPending;
var cooperationContract = templateType === 'cooperation-contract';
var authorization = templateType === 'authorization';
var sharedContract = templateType === 'shared-contract';
var formerCompanyDeregistered = entityChange && entityChangeDeregisteredInput && entityChangeDeregisteredInput.checked;
@ -3386,7 +3423,17 @@
? '填写与 E 签宝账号实名一致的个人姓名;该姓名会作为个人签署人发送,不需要填写企业全称。'
: '必须与对方 E 签宝企业认证名称一致;这是签署主体,不要求与银行卡持卡人姓名一致。';
}
activateTemplate(supplierCooperationPending ? 'supplier-cooperation-pending' : templateType);
activateTemplate(supplierContract ? 'supplier-cooperation' : templateType);
root.querySelector('[data-supplier-contract-fields]').hidden = !supplierContract;
supplierContractSelect.disabled = !supplierContract;
root.querySelectorAll('[data-supplier-field-templates]').forEach(function (group) {
var active = supplierContract && group.dataset.supplierFieldTemplates.split(' ').indexOf(supplierContractSelect.value) !== -1;
group.hidden = !active;
group.querySelectorAll('input').forEach(function (input) { input.disabled = !active; });
});
root.querySelectorAll('[data-supplier-contract-preview]').forEach(function (preview) {
preview.hidden = preview.dataset.supplierContractPreview !== supplierContractSelect.value;
});
setText('[data-cooperation-party-b-label]', otherSignerIsPerson ? '乙方个人姓名(E 签宝实名)' : '乙方名称');
root.querySelector('[name="cooperation_party_b_name"]').placeholder = otherSignerIsPerson
? '填写合作人的 E 签宝实名认证姓名'
@ -3402,7 +3449,7 @@
control.disabled = !needsSecondary;
control.required = needsSecondary;
if (templateType === 'cooperation-contract') {
if (templateType === 'cooperation-contract' && currentPartnerType === 'courier') {
control.value = cooperationSecondaryEsignAccount;
control.readOnly = true;
} else {
@ -3414,7 +3461,7 @@
}
});
if (templateType === 'cooperation-contract' && secondaryEsignAccountInput) {
if (templateType === 'cooperation-contract' && currentPartnerType === 'courier' && secondaryEsignAccountInput) {
secondaryEsignAccountInput.dataset.cooperationDefault = 'true';
}
}
@ -3425,13 +3472,15 @@
secondaryOperatorInput.required = needsSecondaryOperator;
setText('[data-secondary-operator-label]', entityChange ? '变更后主体 E 签宝实名认证姓名' : cooperationContract ? '我方 E 签宝实名认证姓名' : '受托方 E 签宝实名认证姓名');
}
setText('[data-template-name]', documentOption.dataset.name);
setText('[data-template-code]', supplierCooperationPending ? '模板待提供' : documentOption.dataset.code);
setText('[data-template-description]', supplierCooperationPending ? '供应商合作合同模板尚未提供。提供正式模板后,可填写并预览。' : formerCompanyDeregistered ? '原法人以个人身份签字,变更后公司仍须法人签字并盖章;双方分别接收签署请求。' : otherSignerIsPerson ? '使用合作人的个人实名认证姓名和账号签署。' : documentOption.dataset.description);
setText('[data-variable-template-name]', documentOption.dataset.name);
setText('[data-variable-template-status]', supplierCooperationPending ? '— 模板待提供' : '— 填写变量');
setText('[data-preview-template-code]', supplierCooperationPending ? '模板待提供' : documentOption.dataset.code);
setText('[data-preview-status-hint]', supplierCooperationPending ? '提供模板后可预览' : '蓝色=已填 红色=待填');
var contractOption = supplierContractSelect.options[supplierContractSelect.selectedIndex];
var selectedMetadata = supplierContract ? contractOption.dataset : documentOption.dataset;
setText('[data-template-name]', selectedMetadata.name);
setText('[data-template-code]', selectedMetadata.code);
setText('[data-template-description]', supplierContract ? '原文件:' + selectedMetadata.file + ';正文和附件由双方签署并回传。' : formerCompanyDeregistered ? '原法人以个人身份签字,变更后公司仍须法人签字并盖章;双方分别接收签署请求。' : otherSignerIsPerson ? '使用合作人的个人实名认证姓名和账号签署。' : documentOption.dataset.description);
setText('[data-variable-template-name]', selectedMetadata.name);
setText('[data-variable-template-status]', '— 填写变量');
setText('[data-preview-template-code]', selectedMetadata.code);
setText('[data-preview-status-hint]', '已填写变量随表单更新');
setPreviewValue('company', companyName);
setPreviewValue('courier', courier);
setPreviewValue('signatureDate', formatDateField('[name="signature_date"]', '待填写'));
@ -3502,6 +3551,25 @@
setPreviewValue('cooperationPartyBPhone', fieldValue('[name="cooperation_party_b_phone"]', '待填写'));
setPreviewValue('cooperationStartDate', formatDateField('[name="cooperation_start_date"]', '待填写'));
setPreviewValue('cooperationEndDate', formatDateField('[name="cooperation_end_date"]', '待填写'));
if (supplierContract) {
var supplierValues = {
party_a: fieldValue('[name="cooperation_party_a_name"]', companyName),
party_b: fieldValue('[name="cooperation_party_b_name"]', ''),
party_a_phone: fieldValue('[name="cooperation_party_a_phone"]', ''),
party_b_phone: fieldValue('[name="cooperation_party_b_phone"]', ''),
party_a_signer: fieldValue('[name="secondary_operator_name"]', ''),
party_b_signer: otherSignerIsPerson ? fieldValue('[name="cooperation_party_b_name"]', '') : fieldValue('[name="operator_name"]', ''),
signature_date: formatDateField('[name="signature_date"]', ''),
start_date: formatDateField('[name="cooperation_start_date"]', ''),
end_date: formatDateField('[name="cooperation_end_date"]', '')
};
root.querySelectorAll('[data-supplier-field]').forEach(function (input) { supplierValues[input.dataset.supplierField] = input.value.trim(); });
root.querySelectorAll('[data-supplier-bind]').forEach(function (element) {
var value = supplierValues[element.dataset.supplierBind] || '';
element.textContent = value || '待填写';
element.classList.toggle('is-empty', !value);
});
}
}
if (templateType === 'authorization') {
@ -3806,11 +3874,6 @@
form.addEventListener('submit', function (event) {
refreshPreview();
if (currentPartnerType === 'supplier' && selectedDocument().value === 'cooperation-contract') {
event.preventDefault();
root.querySelector('[data-request-error]').textContent = '供应商合作合同模板尚未提供,暂不能发起签署。';
return;
}
if (!form.checkValidity()) {
event.preventDefault();
var error = root.querySelector('[data-request-error]');

View File

@ -0,0 +1,10 @@
@if (($block['text'] ?? '') !== '')
@if (($block['heading'] ?? false))
<h2 class="supplier-contract-heading" @if(($block['align'] ?? '') === 'center') style="text-align:center" @endif>{!! $supplierTemplates->text($block['text'], $supplierValues, $supplierPreview) !!}</h2>
@else
<p class="supplier-contract-paragraph @if($block['signature'] ?? false) supplier-contract-signature @endif" @if(($block['align'] ?? '') === 'center') style="text-align:center" @endif>{!! $supplierTemplates->text($block['text'], $supplierValues, $supplierPreview) !!}</p>
@endif
@endif
@foreach ($block['images'] ?? [] as $image)
<div class="supplier-contract-image"><img src="{{ $supplierPreview ? asset('public/img/supplier-contract-templates/' . $image) : 'data:image/png;base64,' . base64_encode(file_get_contents(public_path('img/supplier-contract-templates/' . $image))) }}" alt="产品包装与合格证示意图"></div>
@endforeach

View File

@ -0,0 +1,42 @@
@php
$supplierTemplates = app(\App\Support\SupplierCooperationTemplates::class);
$supplierValues = $supplierTemplates->values($payload ?? []);
$supplierPreview = ($mode ?? 'pdf') === 'preview';
@endphp
<div class="supplier-contract-body">
@foreach ($supplierTemplates->blocks($templateKey) as $block)
@if ($block['type'] === 'signatures')
<table class="supplier-contract-signatures">
<tr>
@foreach (['a' => '甲方', 'b' => '乙方'] as $party => $label)
<td>
<p>{{ $label }}:{!! $supplierTemplates->text('[[party_' . $party . ']]', $supplierValues, $supplierPreview) !!}</p>
<p>授权代表:{!! $supplierTemplates->text('[[party_' . $party . '_signer]]', $supplierValues, $supplierPreview) !!}</p>
<p>签字/盖章:</p>
<div class="supplier-contract-seal-space"></div>
<p>合同签订日期:{!! $supplierTemplates->text('[[signature_date]]', $supplierValues, $supplierPreview) !!}</p>
</td>
@endforeach
</tr>
</table>
@elseif ($block['type'] === 'table')
<table class="supplier-contract-table">
<tbody>
@foreach ($block['rows'] as $row)
<tr>
@foreach ($row as $cell)
<td style="width:{{ $cell['width'] }}%" @if(($cell['colspan'] ?? 1) > 1) colspan="{{ $cell['colspan'] }}" @endif @if(($cell['rowspan'] ?? 1) > 1) rowspan="{{ $cell['rowspan'] }}" @endif>
@foreach ($cell['paragraphs'] as $paragraph)
@include('business.partials.supplier-contract-paragraph', ['block' => $paragraph])
@endforeach
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
@else
@include('business.partials.supplier-contract-paragraph')
@endif
@endforeach
</div>

View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@font-face { font-family: contract-fallback; font-style: normal; font-weight: 400; src: url("file://{{ $fontPath }}") format("truetype"); }
@font-face { font-family: contract-fallback; font-style: normal; font-weight: 700; src: url("file://{{ $fontPath }}") format("truetype"); }
@page { margin: 20mm 17mm; }
body { font-family: contract-fallback, sans-serif; color: #171717; font-size: 12px; line-height: 1.65; }
.latin { font-family: "DejaVu Sans", sans-serif; }
.supplier-contract-heading { font-size: 15px; margin: 16px 0 8px; page-break-after: avoid; }
.supplier-contract-paragraph { margin: 0 0 7px; overflow-wrap: anywhere; }
.supplier-contract-signature { margin-top: 18px; min-height: 40px; page-break-inside: avoid; }
.supplier-contract-table { width: 100%; border-collapse: collapse; table-layout: fixed; margin: 10px 0; page-break-inside: avoid; }
.supplier-contract-table td { border: 1px solid #999; padding: 6px; vertical-align: top; }
.supplier-contract-table p { margin-bottom: 4px; }
.supplier-contract-image { text-align: center; margin: 12px 0; page-break-inside: avoid; }
.supplier-contract-image img { max-width: 100%; max-height: 430px; }
.supplier-contract-signatures { width: 100%; table-layout: fixed; page-break-inside: avoid; margin: 18px 0; }
.supplier-contract-signatures td { width: 50%; vertical-align: top; padding: 8px; }
.supplier-contract-signatures p { margin: 0 0 8px; }
.supplier-contract-seal-space { height: 85px; }
.meta { color: #595959; font-size: 9px; text-align: right; margin-bottom: 14px; }
</style>
</head>
<body>
<div class="meta">业务单号:{{ $signature->business_no }} 模板:{{ $signature->template_code }} 版本:{{ $signature->template_version }}</div>
@include('business.partials.supplier-cooperation-contract', ['templateKey' => $payload['supplier_contract_template'], 'mode' => 'pdf'])
</body>
</html>

View File

@ -98,7 +98,7 @@ class AgreementSigningPageTest extends FeatureTestCase
->assertSeeText('仅用于 E 签宝身份核验,不是收款人姓名')
->assertSee("setPreviewValue('proofSigner', '公司盖章')", false)
->assertSee("setPreviewValue('changeSigner', '公司盖章')", false)
->assertSeeText('合作合同统一使用我方账号 18052733960')
->assertSeeText('快递合作合同使用我方账号 18052733960')
->assertSeeText('我方 E 签宝实名认证姓名')
->assertSee('name="secondary_operator_name"', false)
->assertSeeText('填写变量')
@ -124,6 +124,24 @@ class AgreementSigningPageTest extends FeatureTestCase
$this->assertSame('张经理', $response->viewData('agreementSupplierOptions')[0]['operator']);
}
public function test_supplier_contract_templates_include_full_preview_and_editable_variables(): void
{
$this->grantWorkspaceAccess();
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create',
]))->assertOk();
foreach (['dropshipping', 'consignment', 'production'] as $template) {
$response->assertSee('data-supplier-contract-preview="' . $template . '"', false);
$response->assertSeeText(\App\Support\SupplierCooperationTemplates::TEMPLATES[$template]['name']);
$response->assertSee(\App\Support\SupplierCooperationTemplates::TEMPLATES[$template]['code'], false);
}
$response->assertSee('name="supplier_contract_template"', false)
->assertSee('name="supplier_contract_fields[number]"', false)
->assertSee('data-supplier-bind="party_a"', false)
->assertSee('production-3.png', false)
->assertDontSeeText('供应商合作合同模板尚未提供');
}
public function testAgreementSigningPageUsesCourierNamesFromCourierInformation(): void
{
$this->grantWorkspaceAccess();

View File

@ -9,6 +9,7 @@ use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierAgreementPdf;
use App\Support\SupplierCooperationTemplates;
use DOMDocument;
use DOMXPath;
use Illuminate\Http\Client\Request as ClientRequest;
@ -136,11 +137,97 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
$payload['partner_type'] = 'supplier';
$payload['supplier_id'] = 7;
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
->assertSessionHasErrors(['document_type' => '供应商合作合同模板尚未提供,暂不能发起签署。']);
->assertSessionHasErrors('supplier_contract_template');
$this->assertSame(0, CourierAgreementSignature::query()->count());
Http::assertNothingSent();
}
/** @dataProvider supplierTemplateProvider */
public function test_supplier_contract_keeps_the_selected_template_and_archives_the_signed_file(string $template): void
{
$this->fakeEsign();
$payload = $this->supplierContractPayload($template);
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect();
$signature = CourierAgreementSignature::query()->firstOrFail();
$metadata = SupplierCooperationTemplates::TEMPLATES[$template];
$this->assertSame($metadata['code'], $signature->template_code);
$this->assertSame('1', $signature->template_version);
$this->assertSame($metadata['file'], $signature->source_file_name);
$this->assertSame($metadata['name'], $signature->documentLabel());
$this->assertSame($template, $signature->payload['supplier_contract_template']);
$this->assertSame('SUP-TEST-001', $signature->payload['supplier_contract_fields']['number']);
$this->assertSame('张经理', $signature->payload['supplier_contract_signer_name']);
$source = Storage::disk('uploads')->get($signature->source_pdf_path);
$this->assertStringStartsWith('%PDF-', $source);
$this->assertSame(hash('sha256', $source), $signature->source_pdf_sha256);
Http::assertSent(function (ClientRequest $request): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') return false;
$signers = $request->data()['signers'] ?? [];
return count($signers) === 2
&& data_get($signers, '0.orgSignerInfo.orgName') === $this->company->name
&& data_get($signers, '0.orgSignerInfo.transactorInfo.psnAccount') === '13900139000'
&& data_get($signers, '0.orgSignerInfo.transactorInfo.psnInfo.psnName') === '李经理'
&& data_get($signers, '1.orgSignerInfo.orgName') === '测试供应商有限公司'
&& data_get($signers, '1.orgSignerInfo.transactorInfo.psnAccount') === '13800138000'
&& data_get($signers, '1.orgSignerInfo.transactorInfo.psnInfo.psnName') === '张经理';
});
Http::swap(new \Illuminate\Http\Client\Factory());
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-001/detail' => Http::response(['code' => 0, 'data' => ['signFlowStatus' => 2]]),
'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-001/file-download-url' => Http::response(['code' => 0, 'data' => ['files' => [['downloadUrl' => 'https://download.esign.test/supplier-signed']]]]),
'https://download.esign.test/supplier-signed' => Http::response('%PDF-supplier-signed'),
]);
$archived = app(\App\Support\CourierAgreementSignatureService::class)->synchronize($signature, $this->user->id);
$this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $archived->status);
$this->assertSame('%PDF-supplier-signed', Storage::disk('uploads')->get($archived->signed_pdf_path));
$this->assertSame($metadata['code'], $archived->template_code);
$this->assertSame($source, Storage::disk('uploads')->get($archived->source_pdf_path));
}
public static function supplierTemplateProvider(): array
{
return [['dropshipping'], ['consignment'], ['production']];
}
public function test_supplier_contract_rejects_invalid_template_and_missing_contract_identity(): void
{
Http::preventStrayRequests();
$this->withExceptionHandling();
$payload = $this->cooperationContractPayload('invalid-supplier-contract');
$payload += ['partner_type' => 'supplier', 'supplier_id' => 7, 'supplier_contract_template' => '../other'];
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertSessionHasErrors('supplier_contract_template');
$payload = $this->supplierContractPayload('dropshipping');
$payload['supplier_contract_fields']['number'] = '';
$payload['supplier_contract_fields']['location'] = '';
$this->post(route('business.courier-agreement-signatures.store'), $payload)->assertSessionHasErrors(['supplier_contract_fields.number', 'supplier_contract_fields.location']);
$this->assertSame(0, CourierAgreementSignature::query()->count());
Http::assertNothingSent();
}
public function test_supplier_contract_variables_are_escaped_and_cover_the_body_and_appendices(): void
{
$templates = app(SupplierCooperationTemplates::class);
foreach (array_keys(SupplierCooperationTemplates::TEMPLATES) as $template) {
$html = view('business.partials.supplier-cooperation-contract', [
'templateKey' => $template, 'mode' => 'preview',
'payload' => ['cooperation_party_a_name' => '<script>alert(1)</script>', 'cooperation_party_b_name' => '新供应商', 'supplier_contract_fields' => ['number' => 'SUP-NEW-001']],
])->render();
$this->assertStringContainsString('&lt;script&gt;', $html);
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('SUP-NEW-001', $html);
$this->assertStringContainsString('新供应商', $html);
$this->assertStringNotContainsString('[[', $html);
$this->assertStringNotContainsString('沭阳简华家具有限公司', $html);
$this->assertStringNotContainsString('常州市武进飞燕被服有限公司', $html);
$blocks = $templates->blocks($template);
$text = json_encode($blocks, JSON_UNESCAPED_UNICODE);
for ($attachment = 1; $attachment <= SupplierCooperationTemplates::TEMPLATES[$template]['attachments']; $attachment++) {
$this->assertStringContainsString('附件' . $attachment, $text);
}
$this->assertSame(2, count(array_filter($blocks, fn ($block) => $block['type'] === 'table')));
}
}
public function test_unknown_partner_type_is_rejected_before_sending(): void
{
Http::preventStrayRequests();
@ -1359,6 +1446,20 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
});
}
private function supplierContractPayload(string $template): array
{
$repository = Mockery::mock(SupplierManagementMirrorRepository::class);
$repository->shouldReceive('signingSupplier')->with(7)->once()->andReturn((object) [
'Id' => 7, 'scmname' => '测试供应商', 'accountbankno' => '13800138000',
]);
$this->app->instance(SupplierManagementMirrorRepository::class, $repository);
return array_merge($this->cooperationContractPayload('supplier-' . $template), [
'partner_type' => 'supplier', 'supplier_id' => 7, 'supplier_contract_template' => $template,
'cooperation_party_b_name' => '测试供应商有限公司',
'supplier_contract_fields' => ['number' => 'SUP-TEST-001', 'location' => '测试签订地', 'party_b_address' => '测试供应商地址'],
]);
}
private function accountProofPayload(string $idempotencyKey): array
{
return [