接入快递协议 E 签宝签署流程
This commit is contained in:
parent
b371b9137d
commit
e49d583597
165
app/Http/Controllers/Business/CourierAgreementSignatures.php
Normal file
165
app/Http/Controllers/Business/CourierAgreementSignatures.php
Normal 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 位手机号或有效邮箱。']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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'
|
||||
|
||||
31
app/Jobs/Business/ArchiveCourierAgreementSignature.php
Normal file
31
app/Jobs/Business/ArchiveCourierAgreementSignature.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/Models/Business/CourierAgreementSignature.php
Normal file
103
app/Models/Business/CourierAgreementSignature.php
Normal 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;
|
||||
}
|
||||
}
|
||||
26
app/Models/Business/CourierAgreementSignatureEvent.php
Normal file
26
app/Models/Business/CourierAgreementSignatureEvent.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@ -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");
|
||||
|
||||
42
app/Support/CourierAgreementPdf.php
Normal file
42
app/Support/CourierAgreementPdf.php
Normal 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 => '授权委托书',
|
||||
};
|
||||
}
|
||||
}
|
||||
316
app/Support/CourierAgreementSignatureService.php
Normal file
316
app/Support/CourierAgreementSignatureService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
51
docs/esign-agreement-integration.md
Normal file
51
docs/esign-agreement-integration.md
Normal 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 过期和回调丢失分别留存验收记录。
|
||||
@ -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) {
|
||||
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 () {
|
||||
|
||||
72
resources/views/business/pdf/courier-agreement.blade.php
Normal file
72
resources/views/business/pdf/courier-agreement.blade.php
Normal 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>
|
||||
@ -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');
|
||||
|
||||
154
tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php
Normal file
154
tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user