接入快递协议 E 签宝签署流程

This commit is contained in:
limeng 2026-08-28 14:02:15 +08:00
parent b371b9137d
commit e49d583597
16 changed files with 1257 additions and 20 deletions

View File

@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierAgreementSignature;
use App\Services\Esign\EsignClient;
use App\Support\CourierAgreementSignatureService;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierAgreementSignatures extends Controller
{
public function __construct(private readonly CourierAgreementSignatureService $service)
{
}
public function store(Request $request): RedirectResponse
{
$this->ensureCanManage();
abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
abort_unless(Schema::hasTable('business_courier_agreement_signatures'), 503, '协议签署数据表尚未完成迁移,请先执行数据库迁移。');
$data = $request->validate($this->rules());
$type = $data['document_type'];
if ($type === 'authorization' && count($data['authorization_items'] ?? []) === 0) {
throw ValidationException::withMessages(['authorization_items' => '授权委托书至少选择一项授权事项。']);
}
$this->validateEsignAccount($data['esign_account'], 'esign_account');
if (! empty($data['secondary_esign_account'])) {
$this->validateEsignAccount($data['secondary_esign_account'], 'secondary_esign_account');
}
$data['organization_name'] = $data['signing_subject'];
$data['operator_name'] = $data['operator_name'] ?? $data['courier_name'];
$data['operator_account'] = $data['esign_account'];
$data['payload'] = $this->payload($data);
$signature = $this->service->send($data, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', $signature->status === CourierAgreementSignature::STATUS_SIGNING
? "{$signature->documentLabel()}已推送到 E 签宝,等待对方签署。"
: "{$signature->documentLabel()}签署任务已受理。")
->with('agreement_signature_id', $signature->id);
}
public function synchronize(CourierAgreementSignature $agreementSignature): RedirectResponse
{
$this->ensureCanManage();
$signature = $this->service->synchronize($agreementSignature, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "{$signature->documentLabel()}状态已同步:{$signature->statusLabel()}。");
}
public function archive(CourierAgreementSignature $agreementSignature): RedirectResponse
{
$this->ensureCanManage();
abort_unless($agreementSignature->status === CourierAgreementSignature::STATUS_COMPLETED && $agreementSignature->remote_status === '2', 422, '只有签署完成的协议可以人工归档。');
$signature = $this->service->archive($agreementSignature, null, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "{$signature->documentLabel()}已人工确认并归档。");
}
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse
{
$this->ensureCanManage();
[$path, $suffix] = match ($kind) {
'source' => [$agreementSignature->source_pdf_path, '签署原文'],
'signed' => [$agreementSignature->signed_pdf_path, '已签署协议'],
'certificate' => [$agreementSignature->certificate_path, '签署完成证书'],
default => abort(404),
};
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。');
return \Storage::disk('uploads')->download($path, $agreementSignature->business_no . '-' . $suffix . '.pdf', ['Content-Type' => 'application/pdf']);
}
private function rules(): array
{
$date = ['required', 'date', 'after_or_equal:today', 'before_or_equal:' . now()->addDays(90)->toDateString()];
return [
'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))],
'signing_subject' => ['required', 'string', 'min:2', 'max:255'],
'company_full_name' => ['required', 'string', 'min:2', 'max:255'],
'signing_deadline' => $date,
'esign_account' => ['required', 'string', 'max:255'],
'secondary_esign_account' => ['required_if:document_type,entity-change,authorization', 'nullable', 'string', 'max:255'],
'operator_name' => ['nullable', 'string', 'max:100'],
'archive_mode' => ['required', 'in:auto,manual'],
'account_proof_effective_date' => ['required_if:document_type,account-proof', 'date'],
'account_proof_account_name' => ['required_if:document_type,account-proof', 'string', 'max:255'],
'account_proof_bank_name' => ['required_if:document_type,account-proof', 'string', 'max:255'],
'account_proof_bank_account' => ['required_if:document_type,account-proof', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'],
'account_change_date' => ['required_if:document_type,account-change', 'date'],
'old_account_name' => ['required_if:document_type,account-change', 'string', 'max:255'],
'old_account_number' => ['required_if:document_type,account-change', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'],
'old_account_stop_date' => ['required_if:document_type,account-change', 'date'],
'new_account_effective_date' => ['required_if:document_type,account-change', 'date'],
'new_account_name' => ['required_if:document_type,account-change', 'string', 'max:255'],
'new_bank_name' => ['required_if:document_type,account-change', 'string', 'max:255'],
'new_bank_account' => ['required_if:document_type,account-change', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'],
'former_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
'entity_change_date' => ['required_if:document_type,entity-change', 'date'],
'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'],
'entity_proof_date' => ['required_if:document_type,entity-change', 'date'],
'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'],
'shared_courier_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'],
'shared_proof_date' => ['required_if:document_type,shared-contract', 'date'],
'principal_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
'principal_id_number' => ['required_if:document_type,authorization', 'string', 'max:64'],
'trustee_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
'trustee_id_number' => ['required_if:document_type,authorization', 'string', 'max:64'],
'authorization_items' => ['nullable', 'array'],
'authorization_items.*' => ['in:1,2,3,4'],
'bill_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'loan_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'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'],
];
}
private function payload(array $data): array
{
$allowed = array_keys($this->rules());
$payload = array_intersect_key($data, array_flip($allowed));
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name']);
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
return $payload;
}
private function ensureCanManage(): void
{
abort_unless(
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true),
403,
'当前账号没有协议签署管理权限。',
);
}
private function validateEsignAccount(string $account, string $field): void
{
$valid = preg_match('/^1\d{10}$/', $account) === 1
|| filter_var($account, FILTER_VALIDATE_EMAIL) !== false;
if (! $valid) {
throw ValidationException::withMessages([$field => 'E签宝账号必须是 11 位手机号或有效邮箱。']);
}
}
}

View File

@ -6,6 +6,7 @@ use App\Business\BusinessTableImporter;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Models\Banking\MarketingAccountBill;
use App\Models\Business\CourierAgreementSignature;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
@ -13,6 +14,7 @@ use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
@ -196,6 +198,7 @@ class Pages extends Controller
'edgeBanding' => $this->edgeBandingContent($page['slug']),
'inventoryData' => $this->inventoryContent($page['slug']),
'rawBoardData' => $this->rawBoardContent($page['slug']),
'agreementTasks' => $this->agreementTasks($page['slug']),
];
}
@ -233,9 +236,28 @@ class Pages extends Controller
'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null,
'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null,
'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null,
'agreementTasks' => $this->agreementTasks($page['slug'] ?? null),
];
}
protected function agreementTasks(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierAgreementSignature::query()
->latest('id')
->limit(50)
->get();
} catch (QueryException $exception) {
// 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。
report($exception);
return collect();
}
}
protected function resolveContent(string $page, ?string $tab): ?array
{
return match ($page) {

View File

@ -3,7 +3,10 @@
namespace App\Http\Controllers\Integrations;
use App\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Jobs\Business\ArchiveCourierAgreementSignature;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -11,7 +14,7 @@ use Illuminate\Routing\Controller;
class EsignCallbacks extends Controller
{
public function __invoke(Request $request, CourierQuoteSignatureService $service): JsonResponse
public function __invoke(Request $request, CourierQuoteSignatureService $service, CourierAgreementSignatureService $agreementService): JsonResponse
{
$appId = trim((string) config('services.esign.app_id'));
$appSecret = trim((string) config('services.esign.app_secret'));
@ -60,7 +63,24 @@ class EsignCallbacks extends Controller
return $this->success();
}
$signatureRecord = $service->acceptCallback($signFlowId, $remoteStatus, hash('sha256', $rawBody));
$payloadHash = hash('sha256', $rawBody);
$signatureRecord = $service->acceptCallback($signFlowId, $remoteStatus, $payloadHash);
if ($signatureRecord === null) {
$agreementRecord = $agreementService->acceptCallback($signFlowId, $remoteStatus, $payloadHash, [
'event_key' => hash('sha256', $timestamp . '|' . ($payload['action'] ?? '') . '|' . $signFlowId . '|' . $payloadHash),
'action' => (string) ($payload['action'] ?? 'SIGN_FLOW_COMPLETE'),
'timestamp' => $timestamp,
'raw_body' => $rawBody,
]);
if ($agreementRecord
&& $remoteStatus === '2'
&& $agreementService->autoArchive($agreementRecord)
&& $agreementRecord->status !== CourierAgreementSignature::STATUS_ARCHIVED) {
ArchiveCourierAgreementSignature::dispatch($agreementRecord->id);
}
}
if ($signatureRecord
&& $remoteStatus === '2'

View File

@ -0,0 +1,31 @@
<?php
namespace App\Jobs\Business;
use App\Models\Business\CourierAgreementSignature;
use App\Support\CourierAgreementSignatureService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ArchiveCourierAgreementSignature implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(public readonly int $signatureId)
{
}
public function handle(CourierAgreementSignatureService $service): void
{
$signature = CourierAgreementSignature::allCompanies()->find($this->signatureId);
if ($signature) {
$service->synchronize($signature);
}
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CourierAgreementSignature extends Model
{
public const STATUS_PREPARED = 'prepared';
public const STATUS_SENDING = 'sending';
public const STATUS_SIGNING = 'signing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_REVOKED = 'revoked';
public const STATUS_EXPIRED = 'expired';
public const STATUS_REJECTED = 'rejected';
public const STATUS_FAILED = 'failed';
public const STATUS_UNKNOWN = 'unknown';
public const DOCUMENTS = [
'account-proof' => [
'code' => 'TPL-ACCOUNT-CERT-V2',
'version' => '2',
'name' => '收款账户证明',
'file' => '收款账户证明文件.doc',
],
'account-change' => [
'code' => 'TPL-ACCOUNT-CHANGE-V1',
'version' => '1',
'name' => '收款账户变更',
'file' => '收款账户变更证明.doc',
],
'entity-change' => [
'code' => 'TPL-ENTITY-CHANGE-V1',
'version' => '1',
'name' => '公司主体变更',
'file' => '公司主体变更证明.docx',
],
'shared-contract' => [
'code' => 'TPL-SHARED-CONTRACT-V1',
'version' => '1',
'name' => '共用合同证明',
'file' => '共用合同证明.docx',
],
'authorization' => [
'code' => 'TPL-AUTHORIZATION-V1',
'version' => '1',
'name' => '授权委托书',
'file' => '授权委托书(快递).docx',
],
];
protected $table = 'business_courier_agreement_signatures';
protected $fillable = [
'company_id', 'business_no', 'idempotency_key', 'document_type', 'template_code',
'template_version', 'source_file_name', 'status', 'courier_name', 'company_full_name',
'organization_name', 'operator_name', 'operator_account', 'payload', 'environment',
'expires_at', 'source_pdf_path', 'source_pdf_sha256', 'esign_file_id',
'esign_sign_flow_id', 'remote_status', 'signed_pdf_path', 'signed_pdf_sha256',
'certificate_path', 'certificate_sha256', 'sent_at', 'completed_at', 'archived_at',
'last_synced_at', 'last_error', 'callback_payload_hash', 'created_by', 'updated_by',
];
protected $casts = [
'company_id' => 'integer',
'expires_at' => 'datetime',
'sent_at' => 'datetime',
'completed_at' => 'datetime',
'archived_at' => 'datetime',
'last_synced_at' => 'datetime',
'payload' => 'encrypted:array',
'operator_account' => 'encrypted',
];
public function statusLabel(): string
{
return match ($this->status) {
self::STATUS_PREPARED => '待推送',
self::STATUS_SENDING => '推送中',
self::STATUS_SIGNING => '待签署',
self::STATUS_COMPLETED => '签署完成,待归档',
self::STATUS_ARCHIVED => '已签署并归档',
self::STATUS_REVOKED => '已撤销',
self::STATUS_EXPIRED => '已过期',
self::STATUS_REJECTED => '已拒签',
self::STATUS_FAILED => '处理失败',
self::STATUS_UNKNOWN => '状态未知,请人工核对',
default => $this->status,
};
}
public function canSend(): bool
{
return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED], true)
&& blank($this->esign_sign_flow_id);
}
public function documentLabel(): string
{
return self::DOCUMENTS[$this->document_type]['name'] ?? $this->document_type;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CourierAgreementSignatureEvent extends Model
{
protected $table = 'business_courier_agreement_signature_events';
protected $fillable = [
'company_id', 'agreement_signature_id', 'event_key', 'action', 'esign_sign_flow_id',
'event_timestamp', 'verified', 'processed', 'raw_body', 'received_at', 'processed_at',
];
protected $casts = [
'company_id' => 'integer',
'agreement_signature_id' => 'integer',
'event_timestamp' => 'integer',
'verified' => 'boolean',
'processed' => 'boolean',
'raw_body' => 'encrypted',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
}

View File

@ -32,6 +32,18 @@ final class EsignClient
public function uploadPdf(string $bytes, string $fileName): string
{
if (! str_starts_with($bytes, '%PDF-')) {
throw new RuntimeException('只能向 E 签宝上传有效 PDF 文件。');
}
if (strlen($bytes) > 50 * 1024 * 1024) {
throw new RuntimeException('E 签宝单文件不能超过 50 MB。');
}
if (preg_match('/[\\/:*"<>|??\x00-\x1F]/u', $fileName) === 1 || ! str_ends_with(strtolower($fileName), '.pdf')) {
throw new RuntimeException('E 签宝文件名必须是安全的 PDF 文件名。');
}
$contentMd5 = base64_encode(md5($bytes, true));
$data = $this->request('POST', '/v3/files/file-upload-url', [
'contentMd5' => $contentMd5,
@ -68,6 +80,7 @@ final class EsignClient
'signFlowExpireTime' => $expiresAtMs,
'autoStart' => true,
'autoFinish' => true,
'identityVerify' => true,
];
$notifyUrl = trim((string) config('services.esign.notify_url'));
@ -75,13 +88,10 @@ final class EsignClient
$flowConfig['notifyUrl'] = $notifyUrl;
}
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
'docs' => [[
'fileId' => $fileId,
'fileName' => $fileName,
]],
'signFlowConfig' => $flowConfig,
'signers' => [[
$signers = $signer['signers'] ?? null;
$signerPayload = is_array($signers) && $signers !== []
? array_map(fn (array $item, int $index): array => $this->signerPayload($fileId, $item, $index), array_values($signers), array_keys(array_values($signers)))
: [[
'signerType' => 1,
'signConfig' => ['signOrder' => 1],
'noticeConfig' => ['noticeTypes' => $signer['notice_types']],
@ -100,7 +110,15 @@ final class EsignClient
'signFieldSize' => '159',
],
]],
]];
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
'docs' => [[
'fileId' => $fileId,
'fileName' => $fileName,
]],
'signFlowConfig' => $flowConfig,
'signers' => $signerPayload,
]);
$signFlowId = (string) ($data['signFlowId'] ?? '');
@ -111,6 +129,41 @@ final class EsignClient
return $signFlowId;
}
private function signerPayload(string $fileId, array $signer, int $index): array
{
$type = (int) ($signer['signer_type'] ?? 1);
$payload = [
'signerType' => $type,
'signConfig' => ['signOrder' => $index + 1],
'noticeConfig' => ['noticeTypes' => (string) ($signer['notice_types'] ?? '1')],
'signFields' => [[
'fileId' => $fileId,
'normalSignFieldConfig' => [
'freeMode' => true,
'movableSignField' => true,
'signFieldSize' => '159',
],
]],
];
if ($type === 0) {
$payload['psnSignerInfo'] = [
'psnAccount' => $signer['operator_account'],
'psnInfo' => ['psnName' => $signer['operator_name']],
];
} else {
$payload['orgSignerInfo'] = [
'orgName' => $signer['organization_name'],
'transactorInfo' => [
'psnAccount' => $signer['operator_account'],
'psnInfo' => ['psnName' => $signer['operator_name']],
],
];
}
return $payload;
}
public function signFlowDetail(string $signFlowId): array
{
return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail");

View File

@ -0,0 +1,42 @@
<?php
namespace App\Support;
use App\Models\Business\CourierAgreementSignature;
use RuntimeException;
final class CourierAgreementPdf
{
public function render(CourierAgreementSignature $signature): string
{
$html = view('business.pdf.courier-agreement', [
'signature' => $signature,
'type' => $signature->document_type,
'title' => $this->title($signature),
'payload' => $signature->payload ?? [],
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
])->render();
$pdf = app('dompdf.wrapper');
$pdf->setPaper('a4', 'portrait');
$pdf->loadHTML($html, 'UTF-8');
$bytes = $pdf->output();
if (! is_string($bytes) || ! str_starts_with($bytes, '%PDF-')) {
throw new RuntimeException('协议 PDF 生成失败。');
}
return $bytes;
}
private function title(CourierAgreementSignature $signature): string
{
return match ($signature->document_type) {
'account-proof' => '收款帐户资料证明',
'account-change' => '收款帐户资料变更证明',
'entity-change' => '公司主体变更证明',
'shared-contract' => '证明',
default => '授权委托书',
};
}
}

View File

@ -0,0 +1,316 @@
<?php
namespace App\Support;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierAgreementSignatureEvent;
use App\Services\Esign\EsignClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
final class CourierAgreementSignatureService
{
public function __construct(private readonly CourierAgreementPdf $pdf)
{
}
public function send(array $data, int $userId): CourierAgreementSignature
{
abort_if(! EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$companyId = (int) (company_id() ?: 1);
$document = CourierAgreementSignature::DOCUMENTS[$data['document_type']] ?? null;
if (! $document) {
throw ValidationException::withMessages(['document_type' => '不支持的协议文件类型。']);
}
$payload = is_array($data['payload'] ?? null) ? $data['payload'] : $data;
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
$idempotencyKey = 'agreement-' . hash('sha256', $companyId . '|' . (string) ($data['idempotency_key'] ?? ''));
$businessNo = 'AGR-' . strtoupper(substr(hash('sha256', $idempotencyKey), 0, 20));
$signature = DB::transaction(function () use ($companyId, $document, $data, $payload, $idempotencyKey, $businessNo, $userId): CourierAgreementSignature {
$existing = CourierAgreementSignature::allCompanies()
->where('company_id', $companyId)
->where('idempotency_key', $idempotencyKey)
->lockForUpdate()
->first();
if ($existing) {
if ($existing->esign_sign_flow_id || in_array($existing->status, [CourierAgreementSignature::STATUS_SENDING, CourierAgreementSignature::STATUS_SIGNING, CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true)) {
return $existing;
}
if (! $existing->canSend()) {
throw ValidationException::withMessages(['signature' => '该协议已进入不可重复发起状态。']);
}
return $existing;
}
$signature = new CourierAgreementSignature([
'company_id' => $companyId,
'business_no' => $businessNo,
'idempotency_key' => $idempotencyKey,
'document_type' => $data['document_type'],
'template_code' => $document['code'],
'template_version' => $document['version'],
'source_file_name' => $document['file'],
'status' => CourierAgreementSignature::STATUS_SENDING,
'courier_name' => $data['courier_name'],
'company_full_name' => $data['company_full_name'],
'organization_name' => $data['organization_name'],
'operator_name' => $data['operator_name'] ?? null,
'operator_account' => $data['operator_account'] ?? null,
'payload' => $payload,
'environment' => (string) config('services.esign.environment', 'sandbox'),
'expires_at' => Carbon::parse($data['signing_deadline'])->endOfDay(),
'source_pdf_path' => "courier-agreements/{$companyId}/{$businessNo}/source.pdf",
'source_pdf_sha256' => '',
'created_by' => $userId,
'updated_by' => $userId,
]);
$signature->save();
$bytes = $this->pdf->render($signature);
$signature->source_pdf_sha256 = hash('sha256', $bytes);
if (! Storage::disk('uploads')->put($signature->source_pdf_path, $bytes)) {
throw new RuntimeException('协议 PDF 无法写入私有业务存储。');
}
$signature->save();
return $signature;
});
if ($signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) {
return $signature->fresh();
}
$flowCreationStarted = false;
try {
$client = app(EsignClient::class);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$fileName = 'agreement-' . $signature->business_no . '.pdf';
$fileId = $client->uploadPdf($bytes, $fileName);
$signer = [
'flow_title' => $this->safeTitle($signature->company_full_name . ' ' . $signature->documentLabel()),
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name ?: $signature->courier_name,
'operator_account' => $signature->operator_account,
'notice_types' => '1',
];
$signer['signers'] = $this->signersFor($signature);
$flowCreationStarted = true;
$signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs());
$signature->update([
'status' => CourierAgreementSignature::STATUS_SIGNING,
'esign_file_id' => $fileId,
'esign_sign_flow_id' => $signFlowId,
'remote_status' => '1',
'sent_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
]);
return $signature->fresh();
} catch (Throwable $exception) {
$signature->update([
'status' => $flowCreationStarted ? CourierAgreementSignature::STATUS_UNKNOWN : CourierAgreementSignature::STATUS_FAILED,
'last_error' => mb_substr((string) preg_replace('/[[:cntrl:]]+/u', ' ', $exception->getMessage()), 0, 1000),
'updated_by' => $userId,
]);
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function synchronize(CourierAgreementSignature $signature, ?int $userId = null): CourierAgreementSignature
{
if (blank($signature->esign_sign_flow_id)) {
throw ValidationException::withMessages(['signature' => '该协议尚未创建 E 签宝签署流程。']);
}
if ($signature->status === CourierAgreementSignature::STATUS_ARCHIVED) {
return $signature;
}
try {
$client = app(EsignClient::class);
$detail = $client->signFlowDetail($signature->esign_sign_flow_id);
$remoteStatus = (string) ($detail['signFlowStatus'] ?? $detail['status'] ?? '');
if ($remoteStatus === '') {
throw new RuntimeException('E 签宝未返回签署流程状态。');
}
$signature = $this->applyRemoteStatus($signature, $remoteStatus, $userId ?: (int) $signature->updated_by);
return $remoteStatus === '2' && $this->autoArchive($signature)
? $this->archive($signature, $client, $userId ?: (int) $signature->updated_by)
: $signature;
} catch (Throwable $exception) {
$signature->update(['last_error' => mb_substr($exception->getMessage(), 0, 1000), 'last_synced_at' => now()]);
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function acceptCallback(string $signFlowId, string $remoteStatus, string $payloadHash, array $event = []): ?CourierAgreementSignature
{
return DB::transaction(function () use ($signFlowId, $remoteStatus, $payloadHash, $event): ?CourierAgreementSignature {
$signature = CourierAgreementSignature::allCompanies()->disableCache()->where('esign_sign_flow_id', $signFlowId)->lockForUpdate()->first();
if (! $signature) {
return null;
}
$eventKey = (string) ($event['event_key'] ?? hash('sha256', $signFlowId . '|' . $payloadHash));
$audit = CourierAgreementSignatureEvent::query()->disableCache()->where('event_key', $eventKey)->lockForUpdate()->first();
if ($audit || $signature->callback_payload_hash === $payloadHash) {
return null;
}
if (in_array($signature->status, [
CourierAgreementSignature::STATUS_COMPLETED,
CourierAgreementSignature::STATUS_ARCHIVED,
CourierAgreementSignature::STATUS_REVOKED,
CourierAgreementSignature::STATUS_EXPIRED,
CourierAgreementSignature::STATUS_REJECTED,
], true) && $remoteStatus === $signature->remote_status) {
return null;
}
if ($event !== []) {
$audit = CourierAgreementSignatureEvent::create([
'company_id' => $signature->company_id,
'agreement_signature_id' => $signature->id,
'event_key' => $eventKey,
'action' => (string) ($event['action'] ?? 'SIGN_FLOW_COMPLETE'),
'esign_sign_flow_id' => $signFlowId,
'event_timestamp' => (int) ($event['timestamp'] ?? now()->getTimestampMs()),
'verified' => true,
'raw_body' => (string) ($event['raw_body'] ?? ''),
'received_at' => now(),
]);
}
$signature->callback_payload_hash = $payloadHash;
$result = $this->applyRemoteStatus($signature, $remoteStatus, (int) $signature->updated_by);
if ($audit) {
$audit->update(['processed' => true, 'processed_at' => now()]);
}
return $result;
});
}
public function archive(CourierAgreementSignature $signature, ?EsignClient $client = null, ?int $userId = null): CourierAgreementSignature
{
if ($signature->status === CourierAgreementSignature::STATUS_ARCHIVED) {
return $signature;
}
$client ??= app(EsignClient::class);
$urls = $client->downloadUrls($signature->esign_sign_flow_id);
$signedUrl = (string) data_get($urls, 'files.0.downloadUrl', '');
$certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? '');
if ($signedUrl === '' || $certificateUrl === '') {
throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。');
}
$signedBytes = $client->download($signedUrl);
$certificateBytes = $client->download($certificateUrl);
if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) {
throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。');
}
$base = "courier-agreements/{$signature->company_id}/{$signature->business_no}";
$signedPath = $base . '/signed.pdf';
$certificatePath = $base . '/completion-certificate.pdf';
$disk = Storage::disk('uploads');
if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) {
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
}
$signature->update([
'status' => CourierAgreementSignature::STATUS_ARCHIVED,
'signed_pdf_path' => $signedPath,
'signed_pdf_sha256' => hash('sha256', $signedBytes),
'certificate_path' => $certificatePath,
'certificate_sha256' => hash('sha256', $certificateBytes),
'archived_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId ?: (int) $signature->updated_by,
]);
return $signature->fresh();
}
public function autoArchive(CourierAgreementSignature $signature): bool
{
return ($signature->payload['archive_mode'] ?? 'auto') === 'auto';
}
private function applyRemoteStatus(CourierAgreementSignature $signature, string $remoteStatus, int $userId): CourierAgreementSignature
{
$status = match ($remoteStatus) {
'1' => CourierAgreementSignature::STATUS_SIGNING,
'2' => CourierAgreementSignature::STATUS_COMPLETED,
'3' => CourierAgreementSignature::STATUS_REVOKED,
'5' => CourierAgreementSignature::STATUS_EXPIRED,
'7' => CourierAgreementSignature::STATUS_REJECTED,
default => $signature->status,
};
$terminal = [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_ARCHIVED];
if (in_array($signature->status, $terminal, true) && $status === CourierAgreementSignature::STATUS_SIGNING) {
return $signature;
}
$signature->forceFill([
'status' => $status,
'remote_status' => $remoteStatus,
'completed_at' => $remoteStatus === '2' ? ($signature->completed_at ?? now()) : $signature->completed_at,
'last_synced_at' => now(),
'updated_by' => $userId,
])->save();
return $signature->refresh();
}
private function signersFor(CourierAgreementSignature $signature): array
{
$payload = $signature->payload ?? [];
$primary = [
'signer_type' => 1,
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name ?: $signature->courier_name,
'operator_account' => $signature->operator_account,
];
if ($signature->document_type === 'entity-change') {
return [
array_merge($primary, ['organization_name' => $payload['former_company_name'] ?? $signature->organization_name]),
[
'signer_type' => 1,
'organization_name' => $payload['changed_company_name'] ?? $signature->organization_name,
'operator_name' => $payload['changed_company_name'] ?? $signature->courier_name,
'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account,
],
];
}
if ($signature->document_type === 'authorization') {
return [
array_merge($primary, [
'organization_name' => $payload['principal_name'] ?? $signature->organization_name,
'operator_name' => $payload['principal_name'] ?? $signature->operator_name,
]),
[
'signer_type' => 1,
'organization_name' => $payload['trustee_name'] ?? $signature->organization_name,
'operator_name' => $payload['trustee_name'] ?? $signature->courier_name,
'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account,
],
];
}
return [$primary];
}
private function safeTitle(string $title): string
{
$title = preg_replace('/[^\p{Han}\p{L}\p{N}_ -]/u', '-', $title) ?: '协议签署';
return mb_substr(trim($title), 0, 180);
}
}

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('business_courier_agreement_signatures', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('business_no', 80);
$table->string('idempotency_key', 80);
$table->string('document_type', 40);
$table->string('template_code', 80);
$table->string('template_version', 20)->default('1');
$table->string('source_file_name', 255);
$table->string('status', 32)->default('prepared');
$table->string('courier_name', 255);
$table->string('company_full_name', 255);
$table->string('organization_name', 255);
$table->string('operator_name', 100)->nullable();
$table->text('operator_account')->nullable();
$table->text('payload');
$table->string('environment', 32)->default('sandbox');
$table->timestamp('expires_at')->nullable();
$table->string('source_pdf_path', 1024);
$table->string('source_pdf_sha256', 64);
$table->string('esign_file_id', 128)->nullable();
$table->string('esign_sign_flow_id', 128)->nullable();
$table->string('remote_status', 32)->nullable();
$table->string('signed_pdf_path', 1024)->nullable();
$table->string('signed_pdf_sha256', 64)->nullable();
$table->string('certificate_path', 1024)->nullable();
$table->string('certificate_sha256', 64)->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('archived_at')->nullable();
$table->timestamp('last_synced_at')->nullable();
$table->text('last_error')->nullable();
$table->string('callback_payload_hash', 64)->nullable();
$table->unsignedInteger('created_by');
$table->unsignedInteger('updated_by');
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'business_no'], 'courier_agreement_signatures_business_unique');
$table->unique(['company_id', 'idempotency_key'], 'courier_agreement_signatures_idempotency_unique');
$table->unique('esign_sign_flow_id', 'courier_agreement_signatures_flow_unique');
$table->index(['company_id', 'status'], 'courier_agreement_signatures_company_status_index');
});
}
public function down(): void
{
Schema::dropIfExists('business_courier_agreement_signatures');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('business_courier_agreement_signature_events', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('agreement_signature_id')->nullable();
$table->string('event_key', 128);
$table->string('action', 80);
$table->string('esign_sign_flow_id', 128);
$table->unsignedBigInteger('event_timestamp');
$table->boolean('verified')->default(true);
$table->boolean('processed')->default(false);
$table->text('raw_body');
$table->timestamp('received_at');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique('event_key', 'courier_agreement_signature_events_key_unique');
$table->index(['company_id', 'esign_sign_flow_id'], 'courier_agreement_signature_events_flow_index');
});
}
public function down(): void
{
Schema::dropIfExists('business_courier_agreement_signature_events');
}
};

View File

@ -0,0 +1,51 @@
# E签宝协议签署接入说明
## 已实现范围
- 五份原始文件使用独立类型和字段映射:`account-proof`、`account-change`、`entity-change`、`shared-contract`、`authorization`。
- 每次发起先在 `business_courier_agreement_signatures` 落库,生成唯一 `business_no` 和 `idempotency_key`,再生成 PDF、上传文件并创建签署流程。
- 保存 `fileId`、`signFlowId`、模板版本、原文哈希、签署文件哈希和完成证书哈希。
- 回调复用现有 E签宝验签入口,校验原始 Body、时间戳、AppId 和 HmacSHA256;协议回调按流程 ID 幂等更新并排队归档。
- 签署完成后只归档文件,不自动启用收款账户;账户、生效日期和收款人仍须财务复核。
## 配置步骤
在本地 `runtime/.env` 或受控部署环境中配置,禁止提交到 Git:
```dotenv
ESIGN_ENVIRONMENT=sandbox
ESIGN_BASE_URL=https://smlopenapi.esign.cn
ESIGN_APP_ID=替换为沙箱AppId
ESIGN_APP_SECRET=替换为沙箱AppSecret
ESIGN_NOTIFY_URL=https://你的公网域名/integrations/esign/callback
ESIGN_CALLBACK_TOLERANCE_SECONDS=900
ESIGN_TIMEOUT=20
```
正式环境单独使用 `https://openapi.esign.cn`、正式 AppId/AppSecret、独立 Webhook 和独立数据库记录。不要把沙箱凭证切换到生产域名,也不要在日志中输出 Secret、完整手机号、邮箱、身份证号或临时下载 URL。
在 E签宝开放平台完成:企业认证、SaaS API V3 应用、出口 IP 白名单、Webhook、`SIGN_FLOW_COMPLETE` 订阅,以及短信/邮件和企业签署服务开通。
## 联调步骤
1. 在页面“协议签署 → 发起签章”选择文件类型,填写该类型专属字段和对方实名绑定的手机号/邮箱。
2. 点击“推送至E签宝”,确认本地任务进入“待签署”,并记录任务的 `business_no`、`fileId`、`signFlowId`。
3. 在沙箱签署页完成实名认证、签字或盖章;验证单页和多页文件的签章位置。
4. 模拟或等待 `SIGN_FLOW_COMPLETE`:只有 `signFlowStatus=2` 才会下载已签 PDF 和完成证书。
5. 检查任务变为“已签署并归档”,下载原文、已签协议和证书,核对哈希值。
6. 不发回调时使用任务行的“同步状态”按钮验证主动查单补偿;重复点击推送或重复回调不得产生第二个 `signFlowId`。
## 安全审计要点
- 业务单号、文件 ID、流程 ID 分开保存;发起前有唯一键,超时后不得直接重发。
- 回调使用恒定时间比较和时间戳窗口,先验签再解析;回调状态不能把已完成状态回退为处理中。
- 原文和归档文件存私有 `uploads` 盘并校验 SHA-256;临时下载地址只在服务端短时使用。
- 个人证件和账号字段采用加密 cast 存储;权限仅允许管理员发起、同步和下载。
- 企业名称不能作为模糊账号匹配依据;应在 E签宝实名账号确认后填写手机号/邮箱。
- 电子签署完成不等于财务数据生效,收款账户变更必须保留人工复核和原档案电话核验。
## 真实环境上线前
- 使用密钥管理系统或受控环境变量保存正式 Secret,并完成轮换/吊销流程。
- 配置公网 HTTPS、出口 IP 白名单和回调来源 IP 白名单。
- 为签章坐标、个人/企业签署、拒签/撤销/过期、下载 URL 过期和回调丢失分别留存验收记录。

View File

@ -1,4 +1,6 @@
@php($agreementCompanyName = company()?->name ?: '当前公司')
@php($agreementTasks = $agreementTasks ?? collect())
@php($esignConfigured = \App\Services\Esign\EsignClient::configured())
<section class="agreement-workspace" aria-label="协议签署" data-agreement-signing>
<style>
@ -142,6 +144,14 @@
color: var(--agreement-warning);
}
.agreement-status--success {
color: var(--agreement-success);
}
.agreement-status--danger {
color: var(--agreement-danger, #B80000);
}
.agreement-panel[hidden] {
display: none;
}
@ -824,9 +834,9 @@
<span>模板管理</span>
</button>
</div>
<span class="agreement-connection" title="当前环境尚未配置E签宝应用凭证">
<span class="material-icons-outlined" aria-hidden="true">link_off</span>
E签宝未连接
<span class="agreement-connection" title="{{ $esignConfigured ? 'E签宝应用已配置,可推送签署任务' : '当前环境尚未配置E签宝应用凭证' }}">
<span class="material-icons-outlined" aria-hidden="true">{{ $esignConfigured ? 'link' : 'link_off' }}</span>
{{ $esignConfigured ? 'E签宝已连接 · ' . (config('services.esign.environment', 'sandbox') === 'production' ? '正式' : '沙箱') : 'E签宝未连接' }}
</span>
</div>
@ -881,6 +891,36 @@
</tr>
</thead>
<tbody>
@forelse ($agreementTasks as $task)
<tr>
<td>{{ $task->business_no }}</td>
<td>{{ $task->courier_name }}</td>
<td>{{ $task->documentLabel() }}</td>
<td><span class="agreement-status agreement-status--{{ in_array($task->status, ['archived', 'completed'], true) ? 'success' : (in_array($task->status, ['failed', 'rejected', 'expired', 'revoked'], true) ? 'danger' : 'warning') }}">{{ $task->statusLabel() }}</span></td>
<td>{{ $task->operator_name ?: '—' }}</td>
<td>{{ optional($task->updated_at)->format('Y-m-d H:i') }}</td>
<td class="agreement-table-actions">
@if ($task->esign_sign_flow_id && ! in_array($task->status, ['archived', 'rejected', 'expired', 'revoked'], true))
<form method="POST" action="{{ route('business.courier-agreement-signatures.synchronize', $task) }}">
@csrf
<button class="agreement-icon-button" type="submit" title="同步E签宝状态"><span class="material-icons-outlined" aria-hidden="true">sync</span></button>
</form>
@endif
@if ($task->status === 'completed' && ($task->payload['archive_mode'] ?? 'auto') === 'manual')
<form method="POST" action="{{ route('business.courier-agreement-signatures.archive', $task) }}">
@csrf
<button class="agreement-icon-button" type="submit" title="人工确认并归档"><span class="material-icons-outlined" aria-hidden="true">archive</span></button>
</form>
@endif
@if ($task->source_pdf_path)
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.download', [$task, 'source']) }}" title="下载签署原文"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span></a>
@endif
@if ($task->signed_pdf_path)
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.download', [$task, 'signed']) }}" title="下载已签署协议"><span class="material-icons-outlined" aria-hidden="true">download</span></a>
@endif
</td>
</tr>
@empty
<tr class="agreement-empty-row">
<td colspan="7">
<div class="agreement-empty">
@ -896,6 +936,7 @@
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@ -908,10 +949,12 @@
<section class="agreement-panel" id="agreement-panel-create" role="tabpanel" aria-labelledby="agreement-tab-create" data-agreement-panel="create" hidden>
<div class="agreement-request-layout">
<form class="agreement-request-form" novalidate data-agreement-form data-signing-request-form>
<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() }}">
<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--warning">沙箱未配置</span>
<span class="agreement-status agreement-status--{{ $esignConfigured ? 'success' : 'warning' }}">{{ $esignConfigured ? 'E签宝可用' : '沙箱未配置' }}</span>
</header>
<div class="agreement-request-body">
@ -964,7 +1007,17 @@
</div>
<div class="agreement-field">
<label for="agreement-esign-account">对方E签宝账号</label>
<input class="agreement-control" id="agreement-esign-account" name="esign_account" type="text" value="选择快递公司后自动获取" data-esign-account readonly>
<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">请填写对方在 E 签宝实名绑定的手机号或邮箱;系统不会按公司名称模糊匹配账号。</p>
</div>
<div class="agreement-field">
<label for="agreement-operator-name">对方签署人姓名</label>
<input class="agreement-control" id="agreement-operator-name" name="operator_name" type="text" placeholder="可选,默认使用快递公司名称" autocomplete="name">
</div>
<div class="agreement-field" data-secondary-signer-fields hidden>
<label for="agreement-secondary-esign-account">第二签署方E签宝账号 <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">主体变更和授权委托书需要双方分别实名签署。</p>
</div>
</div>
</section>
@ -1132,7 +1185,7 @@
<footer class="agreement-request-actions">
<button class="agreement-button" type="button" data-request-cancel><span class="material-icons-outlined" aria-hidden="true">close</span><span>取消</span></button>
<button class="agreement-button" type="button" data-preview-refresh><span class="material-icons-outlined" aria-hidden="true">refresh</span><span>刷新预览</span></button>
<button class="agreement-button agreement-button--primary" type="button" data-sign-request-send disabled title="E签宝沙箱应用尚未配置"><span class="material-icons-outlined" aria-hidden="true">send</span><span>推送至E签宝</span></button>
<button class="agreement-button agreement-button--primary" type="submit" data-sign-request-send {{ $esignConfigured ? '' : 'disabled' }} title="{{ $esignConfigured ? '推送至E签宝' : 'E签宝沙箱应用尚未配置' }}"><span class="material-icons-outlined" aria-hidden="true">send</span><span>推送至E签宝</span></button>
</footer>
</form>
@ -1410,10 +1463,16 @@
var templateType = documentOption.value;
var courier = fieldValue('[name="courier_name"]', '快递公司');
var companyName = fieldValue('[name="company_full_name"]', '待填写公司全称');
var esignAccount = form.querySelector('[data-esign-account]');
activateTemplate(templateType);
esignAccount.value = courier === '快递公司' ? '选择快递公司后自动获取' : '沙箱账号待绑定';
var secondary = root.querySelector('[data-secondary-signer-fields]');
if (secondary) {
var needsSecondary = templateType === 'entity-change' || templateType === 'authorization';
secondary.hidden = !needsSecondary;
secondary.querySelectorAll('input').forEach(function (control) {
control.disabled = !needsSecondary;
control.required = needsSecondary;
});
}
setText('[data-template-name]', documentOption.dataset.name);
setText('[data-template-code]', documentOption.dataset.code);
setText('[data-template-description]', documentOption.dataset.description);
@ -1510,7 +1569,23 @@
});
form.addEventListener('submit', function (event) {
event.preventDefault();
refreshPreview();
if (!form.checkValidity()) {
event.preventDefault();
var error = root.querySelector('[data-request-error]');
if (error) {
error.textContent = '请先补齐当前文件类型所需字段,并确认签署账号有效。';
}
announce('协议字段未填写完整');
return;
}
var submit = root.querySelector('[data-sign-request-send]');
if (submit) {
submit.disabled = true;
submit.setAttribute('aria-busy', 'true');
submit.querySelector('span:last-child').textContent = '正在推送…';
}
});
root.querySelector('[data-task-reset]').addEventListener('click', function () {

View File

@ -0,0 +1,72 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@font-face { font-family: agreement-fallback; src: url('{{ $fontPath }}'); }
body { font-family: agreement-fallback, sans-serif; font-size: 13px; line-height: 1.8; color: #171717; }
h1 { text-align: center; font-size: 20px; margin: 0 0 24px; }
p { margin: 0 0 12px; }
.meta { color: #595959; font-size: 11px; text-align: right; margin-bottom: 20px; }
.account { margin: 14px 0 20px 28px; }
.account div { margin: 6px 0; }
.sign { margin-top: 36px; text-align: right; }
.sign p { margin: 8px 0; }
.list { margin-top: 0; }
.page { page-break-after: always; }
</style>
</head>
<body>
<div class="meta">业务单号:{{ $signature->business_no }} 模板:{{ $signature->template_code }}</div>
<h1>{{ $title }}</h1>
@if ($type === 'account-proof')
<p>TO <strong>{{ $payload['company_full_name'] }}</strong>:</p>
<p>基于我公司正规化管理的需要,同时保证广大客户的汇款安全,我司同意自 {{ $payload['account_proof_effective_date'] }} 起我司货款(运费),请汇款至以下账户:</p>
<div class="account">
<div>开户名称:{{ $payload['account_proof_account_name'] }}</div>
<div>开户银行:{{ $payload['account_proof_bank_name'] }}</div>
<div>银行帐户:{{ $payload['account_proof_bank_account'] }}</div>
</div>
<p>请贵公司按照上述汇款资料付款,如因我司提供收款银行帐户资料错误,导致相关款项支付不到位,则由此产生的一切经济纠纷和责任由我司承担。我司承诺若上述收款银行帐户资料发生变化及时书面通知贵公司。</p>
<p>以上收款账户的变更,以我司的盖章为有效。</p>
<div class="sign"><p>{{ $payload['courier_name'] }} 公司盖章:</p><p>日期:签署后生成</p></div>
@elseif ($type === 'account-change')
<p>TO <strong>{{ $payload['company_full_name'] }}</strong>:</p>
<p>基于我公司正规化管理的需要,同时保证广大客户的汇款安全,我司收款账户将于 {{ $payload['account_change_date'] }} 变更账户,之前相应的账户(收款账户为:{{ $payload['old_account_name'] }},账号:{{ $payload['old_account_number'] }})于 {{ $payload['old_account_stop_date'] }} 停止使用,即日起我司收款的银行帐户进行变更,自 {{ $payload['new_account_effective_date'] }} 起我司与贵司往来款项请汇款至以下新账户,新汇款资料如下:</p>
<div class="account">
<div>开户名称:{{ $payload['new_account_name'] }}</div>
<div>开户银行:{{ $payload['new_bank_name'] }}</div>
<div>银行账户:{{ $payload['new_bank_account'] }}</div>
</div>
<p>如因我司提供收款银行帐户资料错误导致相关款项支付不到位,由此产生的一切经济纠纷和责任由我司承担。以上收款账户的变更,以我司的盖章为有效。</p>
<div class="sign"><p>{{ $payload['courier_name'] }} 公司盖章:</p><p>日期:签署后生成</p></div>
@elseif ($type === 'entity-change')
<p>致 <strong>{{ $payload['company_full_name'] }}</strong>:</p>
<p>我司由于企业名称变更,原公司名称 {{ $payload['former_company_name'] }} 于 {{ $payload['entity_change_date'] }} 变更为 {{ $payload['changed_company_name'] }},自 {{ $payload['changed_company_start_date'] }} 开始,我司将启用变更后的公司与贵公司开展工作,原公司名称停止使用。</p>
<p>变更后,原公司的各项事宜由变更后的公司继续执行。原公司的结算未付款项,由变更之后的公司提供的收款账户收款。原公司的业务和款项若出现法律上的纠纷,由变更后的公司承担责任。</p>
<p>特此证明!</p>
<div class="sign"><p>变更前公司法人签字盖公章:{{ $payload['former_company_name'] }}</p><p>变更后公司法人签字盖公章:{{ $payload['changed_company_name'] }}</p><p>日期:{{ $payload['entity_proof_date'] }}</p></div>
@elseif ($type === 'shared-contract')
<p>与 {{ $payload['contract_partner_name'] }} 合作的 {{ $payload['shared_courier_name'] }} 快递共用同一份合同,打款资料为同一份资料,同一个签字人以及同一个法人!</p>
<p>特此证明!</p>
<div class="sign"><p>法人签字并公司盖章:</p><p>日期:{{ $payload['shared_proof_date'] }}</p></div>
@else
<div class="account">
<div>委托方:{{ $payload['principal_name'] }}</div>
<div>身份证号/营业执照号:{{ $payload['principal_id_number'] }}</div>
<div>受托方:{{ $payload['trustee_name'] }}</div>
<div>身份证号/营业执照号:{{ $payload['trustee_id_number'] }}</div>
</div>
<p>兹委托 {{ $payload['trustee_name'] }} 为我或我司的代理人,全权代表我或我司办理下列第 {{ implode('、', $payload['authorization_items'] ?? []) }} 条事项:</p>
<ol class="list">
<li>代表委托方代签与 {{ $payload['bill_counterparty'] }} 的往来业务每月账单。</li>
<li>代表委托方代签与 {{ $payload['loan_counterparty'] }} 借支协议的事项。</li>
<li>代表委托方代签汇款账号变更的事项。</li>
<li>代表委托方代收 {{ $payload['payment_counterparty'] }} 的往来货款。</li>
</ol>
<p>委托有效期:从 {{ $payload['authorization_start_date'] }} 至 {{ $payload['authorization_end_date'] }} 止。</p>
<div class="sign"><p>委托方:{{ $payload['principal_name'] }}</p><p>受托方:{{ $payload['trustee_name'] }}</p><p>日期:签署后生成</p></div>
@endif
</body>
</html>

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\Business\CourierQuoteChanges;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use App\Http\Controllers\Business\CourierAgreementSignatures;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
@ -88,6 +89,16 @@ Route::prefix('workspace/courier-payables/quote-management/signatures')
Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download');
});
Route::prefix('workspace/courier-payables/agreement-signatures')
->name('business.courier-agreement-signatures.')
->middleware('business.page:courier-payables,agreement-signing')
->group(function () {
Route::post('/', [CourierAgreementSignatures::class, 'store'])->name('store');
Route::post('{agreementSignature}/synchronize', [CourierAgreementSignatures::class, 'synchronize'])->name('synchronize');
Route::post('{agreementSignature}/archive', [CourierAgreementSignatures::class, 'archive'])->name('archive');
Route::get('{agreementSignature}/files/{kind}', [CourierAgreementSignatures::class, 'download'])->name('download');
});
Route::get('workspace/product-inventory/normal-products/export', 'Business\Pages@exportNormalProducts')
->middleware('business.page:product-inventory,normal-products')
->name('business.product-inventory.normal-products.export');

View File

@ -0,0 +1,154 @@
<?php
namespace Tests\Feature\Business;
use App\Jobs\Business\ArchiveCourierAgreementSignature;
use App\Models\Auth\Permission;
use App\Models\Business\CourierAgreementSignature;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\FeatureTestCase;
class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
config([
'services.esign.environment' => 'sandbox',
'services.esign.base_url' => 'https://smlopenapi.esign.test',
'services.esign.app_id' => 'sandbox-app-id',
'services.esign.app_secret' => 'sandbox-app-secret',
'services.esign.notify_url' => null,
]);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
public function test_account_proof_uses_its_own_document_mapping_and_pushes_once(): void
{
$this->fakeEsign();
$payload = $this->accountProofPayload('request-001');
$this->loginAs()
->post(route('business.courier-agreement-signatures.store'), $payload)
->assertRedirect(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_view' => 'tasks',
]));
$signature = CourierAgreementSignature::query()->firstOrFail();
$this->assertSame('TPL-ACCOUNT-CERT-V2', $signature->template_code);
$this->assertSame(CourierAgreementSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('flow-agreement-001', $signature->esign_sign_flow_id);
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
$this->assertSame('新北中通快递有限公司', $signature->payload['account_proof_account_name']);
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect();
Http::assertSentCount(4);
$this->assertSame(1, CourierAgreementSignature::query()->count());
}
public function test_verified_agreement_callback_is_idempotent_and_queues_archive(): void
{
Queue::fake();
$signature = CourierAgreementSignature::create([
'company_id' => $this->company->id,
'business_no' => 'AGR-CALLBACK-001',
'idempotency_key' => 'callback-001',
'document_type' => 'account-proof',
'template_code' => 'TPL-ACCOUNT-CERT-V2',
'template_version' => '2',
'source_file_name' => '收款账户证明文件.doc',
'status' => CourierAgreementSignature::STATUS_SIGNING,
'courier_name' => '新北中通',
'company_full_name' => $this->company->name,
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'payload' => ['company_full_name' => $this->company->name, 'courier_name' => '新北中通'],
'environment' => 'sandbox',
'source_pdf_path' => 'courier-agreements/test/source.pdf',
'source_pdf_sha256' => hash('sha256', '%PDF-source'),
'esign_file_id' => 'file-agreement-001',
'esign_sign_flow_id' => 'flow-agreement-callback',
'remote_status' => '1',
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-agreement-callback',
'signFlowStatus' => '2',
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = (string) now()->getTimestampMs();
$callbackSignature = hash_hmac('sha256', $timestamp . '12' . $payload, 'sandbox-app-secret');
$server = [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => $timestamp,
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => $callbackSignature,
'CONTENT_TYPE' => 'application/json',
];
$this->call('POST', route('integrations.esign.callback') . '?zeta=2&alpha=1', [], [], [], $server, $payload)
->assertOk();
$signature->refresh();
$this->assertSame(CourierAgreementSignature::STATUS_COMPLETED, $signature->status);
$this->assertSame(hash('sha256', $payload), $signature->callback_payload_hash);
Queue::assertPushed(ArchiveCourierAgreementSignature::class, 1);
$this->assertNull(app(\App\Support\CourierAgreementSignatureService::class)->acceptCallback(
'flow-agreement-callback', '2', hash('sha256', $payload),
));
$this->call('POST', route('integrations.esign.callback') . '?zeta=2&alpha=1', [], [], [], $server, $payload)
->assertOk();
Queue::assertPushed(ArchiveCourierAgreementSignature::class, 1);
}
private function fakeEsign(): void
{
Http::fake(function (ClientRequest $request) {
return match (true) {
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([
'code' => 0,
'data' => ['fileId' => 'file-agreement-001', 'fileUploadUrl' => 'https://upload.esign.test/agreement-001'],
]),
$request->url() === 'https://upload.esign.test/agreement-001' => Http::response(['ok' => true]),
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-agreement-001' => Http::response([
'code' => 0,
'data' => ['status' => 2],
]),
$request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([
'code' => 0,
'data' => ['signFlowId' => 'flow-agreement-001'],
]),
default => Http::response(['code' => 404], 404),
};
});
}
private function accountProofPayload(string $idempotencyKey): array
{
return [
'idempotency_key' => $idempotencyKey,
'courier_name' => '新北中通',
'document_type' => 'account-proof',
'signing_subject' => $this->company->name,
'company_full_name' => $this->company->name,
'signing_deadline' => now()->addDays(7)->toDateString(),
'esign_account' => '13800138000',
'operator_name' => '张经理',
'archive_mode' => 'auto',
'account_proof_effective_date' => now()->toDateString(),
'account_proof_account_name' => '新北中通快递有限公司',
'account_proof_bank_name' => '中国工商银行',
'account_proof_bank_account' => '6222000012345678',
];
}
}