feat(协议签署): 按需生成证件推送单并独立签署归档 (#335)
This commit is contained in:
parent
8ba7ac8327
commit
8d7f6da49f
@ -22,6 +22,7 @@ storage/debugbar
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/framework/testing
|
||||
storage/logs/*
|
||||
tests
|
||||
test-data
|
||||
|
||||
@ -8,6 +8,7 @@ use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierSigningSubject;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use App\Support\CourierAgreementFileArchive;
|
||||
use App\Support\CourierAgreementCredentials;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use App\Support\CourierQuoteConfirmation;
|
||||
use App\Support\CourierQuoteSignatureService;
|
||||
@ -198,6 +199,12 @@ class CourierAgreementSignatures extends Controller
|
||||
$data['operator_account'] = $data['esign_account'];
|
||||
|
||||
$data['payload'] = $this->payload($data);
|
||||
$data['payload']['credential_required'] = $request->boolean('credential_required');
|
||||
$data['payload']['credential_signer'] = (int) ($data['credential_signer'] ?? ($type === 'entity-change' ? 1 : 0));
|
||||
if ($request->boolean('credential_required') && $type === 'entity-change'
|
||||
&& $data['payload']['credential_signer'] === 0 && $data['counterparty_signer_type'] === 'person') {
|
||||
throw ValidationException::withMessages(['credential_signer' => '营业执照需要企业盖章,请选择变更后主体签署证件。']);
|
||||
}
|
||||
|
||||
$signature = $this->service->send($data, user_id());
|
||||
|
||||
@ -387,6 +394,16 @@ class CourierAgreementSignatures extends Controller
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
abort_unless($agreementSignature->company_id === (int) company_id(), 404);
|
||||
if (preg_match('/^credential-(license|identity|bank)-(source|signed)$/', $kind, $match)) {
|
||||
$document = $agreementSignature->credentialDocuments()[$match[1]] ?? null;
|
||||
abort_unless($document, 404);
|
||||
$path = $document[$match[2] . '_path'] ?? null;
|
||||
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '证件推送单不存在。');
|
||||
return \Storage::disk('uploads')->response($path,
|
||||
$agreementSignature->business_no . '-' . $document['name'] . ($match[2] === 'signed' ? '-已签署' : '-原文') . '.pdf',
|
||||
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
|
||||
}
|
||||
try {
|
||||
[$path, $suffix] = match ($kind) {
|
||||
'source' => [$this->agreementFileArchive->sourcePath($agreementSignature), '签署原文'],
|
||||
@ -519,14 +536,14 @@ class CourierAgreementSignatures extends Controller
|
||||
'payment_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
|
||||
'authorization_start_date' => ['required_if:document_type,authorization', 'date'],
|
||||
'authorization_end_date' => ['required_if:document_type,authorization', 'date', 'after_or_equal:authorization_start_date'],
|
||||
] + $supplierFields;
|
||||
] + $supplierFields + CourierAgreementCredentials::rules((string) request('document_type'), request()->boolean('credential_required'));
|
||||
}
|
||||
|
||||
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']);
|
||||
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name'], $payload['credential_images']);
|
||||
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
|
||||
if (($data['document_type'] ?? null) === 'entity-change') {
|
||||
$payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? ''));
|
||||
|
||||
@ -100,6 +100,15 @@ class CourierAgreementSignature extends Model
|
||||
return $partnerType === 'supplier' ? '供应商' : '快递公司';
|
||||
}
|
||||
|
||||
public function credentialDocuments(): array
|
||||
{
|
||||
try {
|
||||
return $this->payload['credential_documents'] ?? [];
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
|
||||
@ -120,11 +120,34 @@ final class EsignClient
|
||||
]],
|
||||
]];
|
||||
|
||||
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
|
||||
'docs' => [[
|
||||
$docs = [[
|
||||
'fileId' => $fileId,
|
||||
'fileName' => $fileName,
|
||||
]],
|
||||
]];
|
||||
foreach ($signer['additional_docs'] ?? [] as $document) {
|
||||
$index = (int) $document['signer_index'];
|
||||
if (! isset($signerPayload[$index]) || blank($document['fileId'])) {
|
||||
throw new RuntimeException('证件推送单的签署方或文件 ID 无效。');
|
||||
}
|
||||
if (($document['stamp_only'] ?? false) && $signerPayload[$index]['signerType'] !== 1) {
|
||||
throw new RuntimeException('营业执照需要企业盖章,请选择企业签署方。');
|
||||
}
|
||||
$docs[] = ['fileId' => $document['fileId'], 'fileName' => $document['fileName']];
|
||||
// Each credential PDF is one page, with exactly one field assigned
|
||||
// to the chosen recipient. Other signers only sign the main file.
|
||||
$signerPayload[$index]['signFields'][] = [
|
||||
'fileId' => $document['fileId'],
|
||||
'normalSignFieldConfig' => [
|
||||
'freeMode' => false,
|
||||
'movableSignField' => false,
|
||||
'signFieldStyle' => 1,
|
||||
'signFieldSize' => '120',
|
||||
'signFieldPosition' => ['positionPage' => '1', 'positionX' => 420.0, 'positionY' => 725.0],
|
||||
],
|
||||
];
|
||||
}
|
||||
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
|
||||
'docs' => $docs,
|
||||
'signFlowConfig' => $flowConfig,
|
||||
'signers' => $signerPayload,
|
||||
]);
|
||||
|
||||
105
app/Support/CourierAgreementCredentials.php
Normal file
105
app/Support/CourierAgreementCredentials.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierAgreementCredentials
|
||||
{
|
||||
public const IMAGES = [
|
||||
'license' => '营业执照', 'id_front' => '身份证人像面', 'id_back' => '身份证国徽面',
|
||||
'bank_front' => '银行卡正面', 'bank_back' => '银行卡背面',
|
||||
];
|
||||
|
||||
public static function definitions(string $type): array
|
||||
{
|
||||
$sheets = [
|
||||
'identity' => ['name' => '身份证推送单', 'images' => ['id_front', 'id_back']],
|
||||
'bank' => ['name' => '银行卡推送单', 'images' => ['bank_front', 'bank_back']],
|
||||
];
|
||||
return match ($type) {
|
||||
'authorization' => $sheets,
|
||||
'entity-change' => ['license' => ['name' => '营业执照盖章推送单', 'images' => ['license']]] + $sheets,
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function rules(string $type, bool $requested): array
|
||||
{
|
||||
$allowed = self::definitions($type);
|
||||
$required = $requested ? array_merge(...array_column($allowed ?: [['images' => []]], 'images')) : [];
|
||||
$rules = [
|
||||
'credential_required' => ['nullable', 'boolean', Rule::prohibitedIf($allowed === [] && $requested)],
|
||||
'credential_signer' => ['nullable', 'integer', 'in:0,1'],
|
||||
'credential_images' => ['nullable', 'array:' . implode(',', array_keys(self::IMAGES))],
|
||||
];
|
||||
foreach (self::IMAGES as $key => $label) {
|
||||
$active = in_array($key, $required, true);
|
||||
$rules['credential_images.' . $key] = [Rule::requiredIf($active), Rule::prohibitedIf(! $active),
|
||||
'nullable', 'image', 'mimes:jpg,jpeg,png', 'max:10240', 'dimensions:max_width=5000,max_height=5000'];
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function prepare(CourierAgreementSignature $signature, array $uploads): array
|
||||
{
|
||||
$definitions = self::definitions($signature->document_type);
|
||||
if (! ($signature->payload['credential_required'] ?? false)) {
|
||||
return [];
|
||||
}
|
||||
$disk = Storage::disk('uploads');
|
||||
$base = "courier-agreements/{$signature->company_id}/{$signature->business_no}/credentials";
|
||||
$result = [];
|
||||
foreach ($definitions as $key => $definition) {
|
||||
$pictures = [];
|
||||
$originals = [];
|
||||
foreach ($definition['images'] as $imageKey) {
|
||||
$upload = $uploads[$imageKey] ?? null;
|
||||
if (! $upload instanceof UploadedFile || ! $upload->isValid()) {
|
||||
throw new RuntimeException('证件图片不完整,请重新上传。');
|
||||
}
|
||||
$extension = $upload->getMimeType() === 'image/png' ? 'png' : 'jpg';
|
||||
$originalPath = "{$base}/{$imageKey}.{$extension}";
|
||||
$original = file_get_contents($upload->getRealPath());
|
||||
if (! $disk->put($originalPath, $original)) {
|
||||
throw new RuntimeException('证件原始图片无法写入私有存储。');
|
||||
}
|
||||
$image = (new ImageManager(['driver' => 'gd']))->make($upload->getRealPath())->orientate();
|
||||
$image->resize(2400, 2400, static function ($constraint): void {
|
||||
$constraint->aspectRatio();
|
||||
$constraint->upsize();
|
||||
});
|
||||
$maxHeight = $key === 'license' ? 470 : 180;
|
||||
$scale = min(450 / $image->width(), $maxHeight / $image->height());
|
||||
$pictures[] = ['label' => self::IMAGES[$imageKey],
|
||||
'uri' => 'data:image/png;base64,' . base64_encode((string) $image->encode('png')),
|
||||
'width' => round($image->width() * $scale, 2), 'height' => round($image->height() * $scale, 2)];
|
||||
$originals[$imageKey] = ['path' => $originalPath, 'sha256' => hash('sha256', $original)];
|
||||
$image->destroy();
|
||||
}
|
||||
$pdf = app('dompdf.wrapper');
|
||||
$pdf->setPaper('a4', 'portrait');
|
||||
$pdf->loadHTML(view('business.pdf.courier-credential', [
|
||||
'title' => $definition['name'], 'pictures' => $pictures,
|
||||
'stampOnly' => $key === 'license', 'signature' => $signature,
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render(), 'UTF-8');
|
||||
$bytes = $pdf->output();
|
||||
if (! str_starts_with($bytes, '%PDF-') || $pdf->getDomPDF()->getCanvas()->get_page_count() !== 1) {
|
||||
throw new RuntimeException('证件推送单必须为一页 PDF,请检查上传图片。');
|
||||
}
|
||||
$sourcePath = "{$base}/{$key}-source.pdf";
|
||||
if (! $disk->put($sourcePath, $bytes)) {
|
||||
throw new RuntimeException('证件推送单无法写入私有存储。');
|
||||
}
|
||||
$result[$key] = ['name' => $definition['name'], 'source_path' => $sourcePath,
|
||||
'source_sha256' => hash('sha256', $bytes), 'originals' => $originals];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,8 @@ final class CourierAgreementSignatureService
|
||||
$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 {
|
||||
$claimedAttempt = false;
|
||||
$signature = DB::transaction(function () use ($companyId, $document, $data, $payload, $idempotencyKey, $businessNo, $userId, &$claimedAttempt): CourierAgreementSignature {
|
||||
$existing = CourierAgreementSignature::allCompanies()
|
||||
->withTrashed()
|
||||
->where('company_id', $companyId)
|
||||
@ -56,6 +57,7 @@ final class CourierAgreementSignatureService
|
||||
throw ValidationException::withMessages(['signature' => '该协议已进入不可重复发起状态。']);
|
||||
}
|
||||
|
||||
$claimedAttempt = true;
|
||||
$existing->update([
|
||||
'status' => CourierAgreementSignature::STATUS_SENDING,
|
||||
'updated_by' => $userId,
|
||||
@ -64,6 +66,7 @@ final class CourierAgreementSignatureService
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$claimedAttempt = true;
|
||||
$signature = new CourierAgreementSignature([
|
||||
'company_id' => $companyId,
|
||||
'business_no' => $businessNo,
|
||||
@ -88,6 +91,13 @@ final class CourierAgreementSignatureService
|
||||
]);
|
||||
$signature->save();
|
||||
|
||||
$credentials = app(CourierAgreementCredentials::class)->prepare($signature, $data['credential_images'] ?? []);
|
||||
if ($credentials !== []) {
|
||||
$storedPayload = $signature->payload;
|
||||
$storedPayload['credential_documents'] = $credentials;
|
||||
$signature->payload = $storedPayload;
|
||||
$signature->save();
|
||||
}
|
||||
$bytes = $this->pdf->render($signature);
|
||||
$signature->source_pdf_sha256 = hash('sha256', $bytes);
|
||||
if (! Storage::disk('uploads')->put($signature->source_pdf_path, $bytes)) {
|
||||
@ -98,7 +108,7 @@ final class CourierAgreementSignatureService
|
||||
return $signature;
|
||||
});
|
||||
|
||||
if ($signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) {
|
||||
if (! $claimedAttempt || $signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) {
|
||||
return $signature->fresh();
|
||||
}
|
||||
|
||||
@ -123,6 +133,20 @@ final class CourierAgreementSignatureService
|
||||
'notice_types' => '1',
|
||||
];
|
||||
$signer['signers'] = $signers;
|
||||
$storedPayload = $signature->payload;
|
||||
$credentialDocuments = $storedPayload['credential_documents'] ?? [];
|
||||
$recipient = (int) ($storedPayload['credential_signer'] ?? ($signature->document_type === 'entity-change' ? 1 : 0));
|
||||
foreach ($credentialDocuments as $key => &$credential) {
|
||||
$credentialId = $client->uploadPdf(Storage::disk('uploads')->get($credential['source_path']), $key . '-' . $signature->business_no . '.pdf');
|
||||
$credential['file_id'] = $credentialId;
|
||||
$signer['additional_docs'][] = ['fileId' => $credentialId, 'fileName' => $credential['name'] . '.pdf',
|
||||
'signer_index' => $recipient, 'stamp_only' => $key === 'license'];
|
||||
}
|
||||
unset($credential);
|
||||
if ($credentialDocuments !== []) {
|
||||
$storedPayload['credential_documents'] = $credentialDocuments;
|
||||
$signature->update(['payload' => $storedPayload]);
|
||||
}
|
||||
$flowCreationStarted = true;
|
||||
$signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs());
|
||||
|
||||
@ -230,7 +254,11 @@ final class CourierAgreementSignatureService
|
||||
}
|
||||
$client ??= app(EsignClient::class);
|
||||
$urls = $client->downloadUrls($signature->esign_sign_flow_id);
|
||||
$signedUrl = (string) data_get($urls, 'files.0.downloadUrl', '');
|
||||
$credentials = $signature->credentialDocuments();
|
||||
$remoteFiles = collect($urls['files'] ?? []);
|
||||
$signedUrl = $credentials === []
|
||||
? (string) data_get($urls, 'files.0.downloadUrl', '')
|
||||
: (string) ($remoteFiles->firstWhere('fileId', $signature->esign_file_id)['downloadUrl'] ?? '');
|
||||
$certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? '');
|
||||
if ($signedUrl === '') {
|
||||
throw new RuntimeException('E 签宝未返回签署文件下载地址。');
|
||||
@ -249,6 +277,22 @@ final class CourierAgreementSignatureService
|
||||
|| ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) {
|
||||
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
|
||||
}
|
||||
foreach ($credentials as $key => &$credential) {
|
||||
$url = (string) ($remoteFiles->firstWhere('fileId', $credential['file_id'])['downloadUrl'] ?? '');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('E 签宝未返回全部证件推送单,暂不能完成归档。');
|
||||
}
|
||||
$bytes = $client->download($url);
|
||||
if (! str_starts_with($bytes, '%PDF-')) {
|
||||
throw new RuntimeException('E 签宝返回的证件推送单不是有效 PDF。');
|
||||
}
|
||||
$credential['signed_path'] = $base . '/credentials/' . $key . '-signed.pdf';
|
||||
$credential['signed_sha256'] = hash('sha256', $bytes);
|
||||
if (! $disk->put($credential['signed_path'], $bytes)) {
|
||||
throw new RuntimeException('证件签署文件无法写入私有业务存储。');
|
||||
}
|
||||
}
|
||||
unset($credential);
|
||||
$archive = [
|
||||
'status' => CourierAgreementSignature::STATUS_ARCHIVED,
|
||||
'signed_pdf_path' => $signedPath,
|
||||
@ -259,10 +303,15 @@ final class CourierAgreementSignatureService
|
||||
'updated_by' => $userId ?: (int) $signature->updated_by,
|
||||
];
|
||||
$archiveReason ??= '签署完成后自动归档';
|
||||
if (filled($archiveReason)) {
|
||||
if (filled($archiveReason) || $credentials !== []) {
|
||||
try {
|
||||
$payload = $signature->payload ?? [];
|
||||
$payload['archive_reason'] = mb_substr(trim($archiveReason), 0, 255);
|
||||
if ($credentials !== []) {
|
||||
$payload['credential_documents'] = $credentials;
|
||||
}
|
||||
if (filled($archiveReason)) {
|
||||
$payload['archive_reason'] = mb_substr(trim($archiveReason), 0, 255);
|
||||
}
|
||||
$archive['payload'] = $payload;
|
||||
} catch (Throwable) {
|
||||
// Preserve unreadable historical payloads; retiring an APP_KEY
|
||||
|
||||
@ -7,7 +7,7 @@ server {
|
||||
ssl_session_cache shared:CAIWU_DOMAIN_SSL:2m;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
client_max_body_size 12m;
|
||||
client_max_body_size 64m;
|
||||
|
||||
# The legacy registered HTTP callback must reach the browser's canonical
|
||||
# HTTPS origin before Laravel reads its session. Proxying it with a forged
|
||||
@ -34,7 +34,7 @@ server {
|
||||
ssl_session_cache shared:CAIWU_DOMAIN_SSL:2m;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
client_max_body_size 12m;
|
||||
client_max_body_size 64m;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18088;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
memory_limit=512M
|
||||
max_execution_time=120
|
||||
post_max_size=12M
|
||||
post_max_size=64M
|
||||
upload_max_filesize=10M
|
||||
date.timezone=Asia/Shanghai
|
||||
expose_php=Off
|
||||
|
||||
@ -8,6 +8,12 @@
|
||||
- 回调复用现有 E签宝验签入口,校验原始 Body、时间戳、AppId 和 HmacSHA256;协议回调按流程 ID 幂等更新并排队归档。
|
||||
- 收款账户证明及账户变更中的开户名称按实际银行卡持卡人姓名填写,不要求与营业执照或公司全称一致;签署主体和公司全称仍单独维护。
|
||||
- 签署完成后只归档文件,不自动启用收款账户;账户、生效日期和收款人仍须财务复核。
|
||||
- 证件补充默认关闭,仅适用于授权委托书与公司主体变更。收款账户变更没有证件上传入口。
|
||||
- 授权委托书按需发送三份独立 PDF:主文件、身份证正反面合页推送单、银行卡正反面合页推送单。主体变更按需增加一份独立营业执照盖章推送单,共四份。
|
||||
- 每张证件推送单仅有一处固定签署位置,分配给页面选定的证件签署方;营业执照要求企业盖章。主文件原有签署顺序保持不变。
|
||||
- 原始图片、推送单原文与已签文件存入私有业务存储;文件 ID、路径和 SHA-256 放在原任务的加密 payload 内,无需数据库迁移。
|
||||
- 同一流程中的文件按 fileId 匹配并分别回传归档;缺少任一推送单时不能标记归档成功,可通过现有同步或归档任务重试。任务行支持逐份预览和下载。
|
||||
- 证件单张 JPG / PNG 上限 10 MB,宽高上限 5000 像素。多图请求需要 PHP 与生产入口 Nginx 的请求总量上限均为 64 MB;更新入口配置前备份并核对现有配置,保留其他配置。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
|
||||
@ -1511,6 +1511,13 @@
|
||||
@if ($task->certificate_path)
|
||||
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.download', [$task, 'certificate']) }}" title="下载签署完成证书"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span></a>
|
||||
@endif
|
||||
@foreach ($task->credentialDocuments() as $credentialKey => $credential)
|
||||
<a class="agreement-button agreement-button--compact" target="_blank" rel="noopener" href="{{ route('business.courier-agreement-signatures.download', [$task, 'credential-' . $credentialKey . (!empty($credential['signed_path']) ? '-signed' : '-source'), 'preview' => 1]) }}">预览{{ $credential['name'] }}</a>
|
||||
<a class="agreement-button agreement-button--compact" href="{{ route('business.courier-agreement-signatures.download', [$task, 'credential-' . $credentialKey . '-source']) }}">{{ $credential['name'] }}原文</a>
|
||||
@if (!empty($credential['signed_path']))
|
||||
<a class="agreement-button agreement-button--compact" href="{{ route('business.courier-agreement-signatures.download', [$task, 'credential-' . $credentialKey . '-signed']) }}">{{ $credential['name'] }}已签署</a>
|
||||
@endif
|
||||
@endforeach
|
||||
<form method="POST" action="{{ route('business.courier-agreement-signatures.destroy', $task) }}" data-delete-task-form data-task-number="{{ $task->business_no }}" data-task-description="{{ $task->courier_name }} · {{ $task->documentLabel() }} · {{ $task->statusLabel() }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
@ -1567,7 +1574,7 @@
|
||||
|
||||
<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" method="POST" action="{{ route('business.courier-agreement-signatures.store') }}" novalidate data-agreement-form data-signing-request-form>
|
||||
<form class="agreement-request-form" method="POST" enctype="multipart/form-data" action="{{ route('business.courier-agreement-signatures.store') }}" novalidate data-agreement-form data-signing-request-form>
|
||||
@csrf
|
||||
<input type="hidden" name="idempotency_key" value="{{ $supplierContractRequestKey }}">
|
||||
<header class="agreement-request-header">
|
||||
@ -1926,6 +1933,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@include('business.pages.courier-payables.credential-upload')
|
||||
|
||||
<fieldset class="agreement-form-section agreement-archive-fieldset" data-archive-fieldset>
|
||||
<legend class="agreement-section-heading">回传归档</legend>
|
||||
<div class="agreement-archive-options">
|
||||
@ -1950,6 +1959,7 @@
|
||||
<span class="agreement-preview-meta"><span data-preview-template-code>TPL-ACCOUNT-CERT-V2</span> · <span data-preview-status-hint>蓝色=已填 红色=待填</span></span>
|
||||
</header>
|
||||
<div class="agreement-preview-scroll">
|
||||
<div data-credential-preview hidden></div>
|
||||
<article class="agreement-preview-paper" data-template-preview="account-proof">
|
||||
<h3>收款帐户资料证明</h3>
|
||||
<p>TO <strong data-preview-bind="company">{{ $agreementCompanyName }}</strong>:</p>
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
<section class="agreement-form-section" data-credential-section hidden>
|
||||
<h3 class="agreement-section-heading">证件签署留档(按需)</h3>
|
||||
<label class="agreement-checkbox-choice"><input type="checkbox" name="credential_required" value="1" data-credential-required disabled><span><strong style="display: block">本次需要补充证件并签署留档</strong><span class="agreement-help" style="display: block">默认不勾选,仅发送给本次选中的合作方。</span></span></label>
|
||||
<div data-credential-content hidden style="display: grid; gap: .875rem; margin-top: 1rem">
|
||||
<div class="agreement-field" style="margin-top: 1rem">
|
||||
<label for="agreement-credential-signer">证件签署方</label>
|
||||
<select id="agreement-credential-signer" class="agreement-control" name="credential_signer" data-credential-signer disabled></select>
|
||||
<p class="agreement-help">每张推送单仅由所选签署方签字或盖章一次。营业执照须由企业盖章。</p>
|
||||
</div>
|
||||
<div class="agreement-form-grid">
|
||||
@foreach (\App\Support\CourierAgreementCredentials::IMAGES as $key => $label)
|
||||
<div class="agreement-field" data-credential-image-field="{{ $key }}">
|
||||
<label for="agreement-credential-{{ $key }}">{{ $label }} <span class="agreement-required">*</span></label>
|
||||
<input class="agreement-control" id="agreement-credential-{{ $key }}" name="credential_images[{{ $key }}]" type="file" accept="image/jpeg,image/png" data-credential-image="{{ $key }}" disabled>
|
||||
<img data-credential-thumbnail="{{ $key }}" alt="{{ $label }}预览" hidden style="max-width: 100%; height: 100px; object-fit: contain; margin-top: .5rem">
|
||||
<button class="agreement-inline-button" type="button" data-credential-remove="{{ $key }}" style="align-self: flex-start" hidden>删除图片</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="agreement-help">JPG / PNG,每张不超过 10 MB,宽高不超过 5000 像素。请遮挡银行卡安全码。</p>
|
||||
<p class="agreement-help" data-credential-description></p>
|
||||
</div>
|
||||
<div class="agreement-template-note" style="margin-top: 1rem" aria-live="polite"><span class="material-icons-outlined" aria-hidden="true">description</span><div><strong>本次发送清单</strong><p data-credential-send-list>仅发送当前签署文件</p></div></div>
|
||||
</section>
|
||||
<script>
|
||||
(function () {
|
||||
function initializeCredentials() {
|
||||
var root = document.querySelector('[data-agreement-signing]');
|
||||
if (!root || root.dataset.credentialsInitialized) return;
|
||||
var form = root.querySelector('[data-agreement-form]');
|
||||
if (!form) return;
|
||||
root.dataset.credentialsInitialized = 'true';
|
||||
var type = form.querySelector('[name="document_type"]'), section = root.querySelector('[data-credential-section]');
|
||||
var required = root.querySelector('[data-credential-required]'), content = root.querySelector('[data-credential-content]');
|
||||
var signer = root.querySelector('[data-credential-signer]'), preview = root.querySelector('[data-credential-preview]');
|
||||
var images = Array.from(root.querySelectorAll('[data-credential-image]')), urls = {}, previousType = null;
|
||||
var definitions = {
|
||||
authorization: [{name: '身份证推送单', keys: ['id_front', 'id_back']}, {name: '银行卡推送单', keys: ['bank_front', 'bank_back']}],
|
||||
'entity-change': [{name: '营业执照盖章推送单', keys: ['license']}, {name: '身份证推送单', keys: ['id_front', 'id_back']}, {name: '银行卡推送单', keys: ['bank_front', 'bank_back']}]
|
||||
};
|
||||
function release(key) { if (urls[key]) URL.revokeObjectURL(urls[key]); delete urls[key]; }
|
||||
function imageControl(key) { return images.find(function (input) { return input.dataset.credentialImage === key; }); }
|
||||
function update() {
|
||||
var supported = Boolean(definitions[type.value]);
|
||||
if (previousType !== type.value) {
|
||||
required.checked = false; signer.replaceChildren();
|
||||
(type.value === 'entity-change' ? ['变更前主体', '变更后主体'] : ['委托方', '受托方']).forEach(function (label, index) {
|
||||
var option = document.createElement('option'); option.value = String(index); option.textContent = label; signer.append(option);
|
||||
});
|
||||
signer.value = type.value === 'entity-change' ? '1' : '0';
|
||||
images.forEach(function (input) { input.value = ''; release(input.dataset.credentialImage); }); previousType = type.value;
|
||||
}
|
||||
section.hidden = !supported; required.disabled = !supported;
|
||||
var signerNames = type.value === 'entity-change' ? ['former_company_name', 'changed_company_name'] : ['principal_name', 'trustee_name'];
|
||||
var roleNames = type.value === 'entity-change' ? ['变更前主体', '变更后主体'] : ['委托方', '受托方'];
|
||||
Array.from(signer.options).forEach(function (option, index) {
|
||||
var input = form.querySelector('[name="' + signerNames[index] + '"]');
|
||||
option.textContent = roleNames[index] + ':' + (input && input.value.trim() || '待填写');
|
||||
});
|
||||
var active = supported && required.checked; content.hidden = !active; signer.disabled = !active;
|
||||
var sheets = active ? definitions[type.value] : [], keys = sheets.flatMap(function (sheet) { return sheet.keys; });
|
||||
images.forEach(function (input) {
|
||||
var key = input.dataset.credentialImage, visible = keys.includes(key);
|
||||
input.disabled = !visible; input.required = visible;
|
||||
root.querySelector('[data-credential-image-field="' + key + '"]').hidden = !visible;
|
||||
var img = root.querySelector('[data-credential-thumbnail="' + key + '"]');
|
||||
img.hidden = !urls[key]; if (urls[key]) img.src = urls[key]; else img.removeAttribute('src');
|
||||
root.querySelector('[data-credential-remove="' + key + '"]').hidden = !input.files.length;
|
||||
input.setCustomValidity(input.files.length && (!['image/jpeg', 'image/png'].includes(input.files[0].type) || input.files[0].size > 10 * 1024 * 1024) ? '请选择不超过 10 MB 的 JPG 或 PNG 图片' : '');
|
||||
});
|
||||
root.querySelector('[data-credential-description]').textContent = type.value === 'entity-change'
|
||||
? '营业执照单独一页盖章;身份证、银行卡正反面各合成一页,各签字或盖章一次。'
|
||||
: '身份证正反面合为一页,银行卡正反面合为另一页,每页各签字或盖章一次。';
|
||||
var selectedName = type.options[type.selectedIndex].textContent.trim();
|
||||
root.querySelector('[data-credential-send-list]').textContent = [selectedName + '.pdf'].concat(sheets.map(function (sheet) { return sheet.name + '.pdf'; })).join(';') + '。分别回传、独立留档。';
|
||||
preview.replaceChildren(); preview.hidden = !active;
|
||||
sheets.forEach(function (sheet) {
|
||||
var paper = document.createElement('article'); paper.className = 'agreement-preview-paper';
|
||||
var title = document.createElement('h3'); title.textContent = sheet.name; paper.append(title);
|
||||
sheet.keys.forEach(function (key) {
|
||||
var block = document.createElement('div'); block.className = 'agreement-preview-account';
|
||||
if (urls[key]) { var img = document.createElement('img'); img.src = urls[key]; img.alt = imageControl(key).previousElementSibling.textContent; img.style.cssText = 'width:100%;height:130px;object-fit:contain'; block.append(img); }
|
||||
else block.textContent = imageControl(key).previousElementSibling.textContent.replace('*', '').trim() + ' · 待上传'; paper.append(block);
|
||||
});
|
||||
var sign = document.createElement('div'); sign.className = 'agreement-preview-account';
|
||||
sign.textContent = (sheet.keys[0] === 'license' ? '盖章' : '签字 / 盖章') + '位置 · 本页 1 处'; paper.append(sign); preview.append(paper);
|
||||
});
|
||||
}
|
||||
images.forEach(function (input) { input.addEventListener('change', function () {
|
||||
var key = input.dataset.credentialImage; release(key);
|
||||
if (input.files[0] && ['image/jpeg', 'image/png'].includes(input.files[0].type) && input.files[0].size <= 10 * 1024 * 1024) urls[key] = URL.createObjectURL(input.files[0]); update();
|
||||
}); });
|
||||
root.querySelectorAll('[data-credential-remove]').forEach(function (button) { button.addEventListener('click', function () { var key = button.dataset.credentialRemove; imageControl(key).value = ''; release(key); update(); }); });
|
||||
type.addEventListener('change', update); required.addEventListener('change', update);
|
||||
form.addEventListener('input', update); form.addEventListener('change', update);
|
||||
form.addEventListener('reset', function () { setTimeout(function () { previousType = null; update(); }, 0); });
|
||||
update();
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializeCredentials); else initializeCredentials();
|
||||
})();
|
||||
</script>
|
||||
34
resources/views/business/pdf/courier-credential.blade.php
Normal file
34
resources/views/business/pdf/courier-credential.blade.php
Normal file
@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@font-face { font-family: CredentialChinese; font-weight: 400; src: url('file://{{ $fontPath }}') format('truetype'); }
|
||||
@font-face { font-family: CredentialChinese; font-weight: 700; src: url('file://{{ $fontPath }}') format('truetype'); }
|
||||
@page { margin: 36pt; }
|
||||
body { font-family: CredentialChinese, sans-serif; font-size: 11pt; color: #171717; }
|
||||
h1 { font-size: 16pt; text-align: center; margin: 0 0 16pt; }
|
||||
.meta { font-size: 9pt; color: #595959; margin-bottom: 14pt; }
|
||||
.latin { font-family: DejaVu Sans, sans-serif; }
|
||||
.picture { text-align: center; margin-bottom: 14pt; }
|
||||
.label { margin-bottom: 6pt; }
|
||||
.sign { position: fixed; left: 0; right: 0; top: 630pt; height: 125pt; border: 1pt dashed #856632; }
|
||||
.sign-label { margin: 12pt; font-size: 11pt; }
|
||||
.sign-hint { margin: 12pt; font-size: 9pt; color: #595959; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ $title }}</h1>
|
||||
<div class="meta">合作方:{{ $signature->courier_name }} 任务编号:<span class="latin">{{ $signature->business_no }}</span></div>
|
||||
@foreach ($pictures as $picture)
|
||||
<div class="picture">
|
||||
<div class="label">{{ $picture['label'] }}</div>
|
||||
<img src="{{ $picture['uri'] }}" style="width: {{ $picture['width'] }}pt; height: {{ $picture['height'] }}pt" alt="{{ $picture['label'] }}">
|
||||
</div>
|
||||
@endforeach
|
||||
<div class="sign">
|
||||
<div class="sign-label">@if ($stampOnly)盖章确认@else签字 <span class="latin">/</span> 盖章确认@endif</div>
|
||||
<div class="sign-hint">本页资料一并确认,仅需{{ $stampOnly ? '盖章' : '签字或盖章' }}一次。</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
236
tests/Feature/Business/CourierAgreementCredentialsTest.php
Normal file
236
tests/Feature/Business/CourierAgreementCredentialsTest.php
Normal file
@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Support\CourierAgreementCredentials;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use RuntimeException;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierAgreementCredentialsTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('uploads');
|
||||
config(['app.key' => 'base64:' . base64_encode(str_repeat('k', 32)),
|
||||
'services.esign.environment' => 'sandbox', 'services.esign.base_url' => 'https://smlopenapi.esign.test',
|
||||
'services.esign.app_id' => 'sandbox-test-id', 'services.esign.app_secret' => 'sandbox-test-secret']);
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
}
|
||||
|
||||
public function test_documents_are_optional_and_account_change_rejects_credentials(): void
|
||||
{
|
||||
foreach (['authorization', 'entity-change', 'account-change'] as $type) {
|
||||
$this->assertTrue(Validator::make([], CourierAgreementCredentials::rules($type, false))->passes());
|
||||
}
|
||||
$this->assertTrue(Validator::make(['credential_required' => 1], CourierAgreementCredentials::rules('account-change', true))->fails());
|
||||
$this->assertTrue(Validator::make(['credential_required' => 1], CourierAgreementCredentials::rules('authorization', true))->fails());
|
||||
}
|
||||
|
||||
public function test_page_renders_optional_uploads_and_live_sheet_preview(): void
|
||||
{
|
||||
$couriers = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class);
|
||||
$couriers->shouldReceive('names')->andReturn(['测试快递']);
|
||||
$couriers->shouldReceive('signingAccountsByName')->andReturn(['测试快递' => '13800138000']);
|
||||
$this->app->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $couriers);
|
||||
$suppliers = \Mockery::mock(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class);
|
||||
$suppliers->shouldReceive('signingOptions')->andReturn([]);
|
||||
$this->app->instance(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class, $suppliers);
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create',
|
||||
]));
|
||||
$response->assertOk()->assertSee('enctype="multipart/form-data"', false)
|
||||
->assertSee('data-credential-preview', false)->assertSee('data-credential-required disabled', false)
|
||||
->assertSeeText('本次需要补充证件并签署留档');
|
||||
if ($qa = getenv('CAIWU_CREDENTIAL_QA')) {
|
||||
file_put_contents($qa . '/page.html', $response->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_authorization_sends_three_files_and_each_sheet_has_only_one_signature_field(): void
|
||||
{
|
||||
$this->fakeEsign();
|
||||
$response = $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'));
|
||||
$response->assertRedirect()->assertSessionHasNoErrors();
|
||||
$signature = CourierAgreementSignature::query()->firstOrFail();
|
||||
$this->assertCount(2, $signature->credentialDocuments());
|
||||
foreach ($signature->credentialDocuments() as $document) {
|
||||
$this->assertCount(2, $document['originals']);
|
||||
Storage::disk('uploads')->assertExists($document['source_path']);
|
||||
}
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
if (! str_ends_with($request->url(), '/create-by-file')) return false;
|
||||
$data = $request->data();
|
||||
$this->assertCount(3, $data['docs']);
|
||||
$this->assertCount(3, $data['signers'][0]['signFields']);
|
||||
$this->assertCount(1, $data['signers'][1]['signFields']);
|
||||
foreach (array_slice($data['signers'][0]['signFields'], 1) as $field) {
|
||||
$this->assertSame('1', $field['normalSignFieldConfig']['signFieldPosition']['positionPage']);
|
||||
$this->assertFalse($field['normalSignFieldConfig']['freeMode']);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Reposting the same request must not create a second signing flow.
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'))->assertRedirect();
|
||||
$this->assertCount(1, Http::recorded(fn (ClientRequest $request) => str_ends_with($request->url(), '/create-by-file')));
|
||||
}
|
||||
|
||||
public function test_missing_back_side_stops_request_before_sending_files(): void
|
||||
{
|
||||
Http::fake();
|
||||
$payload = $this->payload('authorization');
|
||||
unset($payload['credential_images']['id_back']);
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
|
||||
->assertSessionHasErrors('credential_images.id_back');
|
||||
$this->assertDatabaseCount('business_courier_agreement_signatures', 0);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_duplicate_processing_request_does_not_create_another_flow(): void
|
||||
{
|
||||
Http::fake();
|
||||
$payload = $this->payload('authorization');
|
||||
$key = 'agreement-' . hash('sha256', $this->company->id . '|' . $payload['idempotency_key']);
|
||||
CourierAgreementSignature::create(['company_id' => $this->company->id, 'business_no' => 'AGR-IN-PROGRESS',
|
||||
'idempotency_key' => $key, 'document_type' => 'authorization', 'template_code' => 'TPL-AUTHORIZATION-V1',
|
||||
'template_version' => '1', 'source_file_name' => '授权委托书.pdf', 'status' => 'sending',
|
||||
'courier_name' => '测试快递', 'company_full_name' => $this->company->name,
|
||||
'organization_name' => '测试委托公司', 'payload' => [], 'environment' => 'sandbox',
|
||||
'source_pdf_path' => 'pending/source.pdf', 'source_pdf_sha256' => '',
|
||||
'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
|
||||
->assertRedirect()->assertSessionHasNoErrors();
|
||||
Http::assertNothingSent();
|
||||
$this->assertDatabaseCount('business_courier_agreement_signatures', 1);
|
||||
}
|
||||
|
||||
public function test_personal_signer_cannot_be_assigned_license_stamp(): void
|
||||
{
|
||||
Http::fake();
|
||||
$payload = $this->payload('entity-change');
|
||||
$payload['entity_change_old_company_deregistered'] = 1;
|
||||
$payload['credential_signer'] = 0;
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
|
||||
->assertSessionHasErrors('credential_signer');
|
||||
$this->assertDatabaseCount('business_courier_agreement_signatures', 0);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_entity_change_sends_four_files_with_credentials_only_for_new_company(): void
|
||||
{
|
||||
$this->fakeEsign();
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('entity-change'))
|
||||
->assertRedirect()->assertSessionHasNoErrors();
|
||||
$signature = CourierAgreementSignature::query()->firstOrFail();
|
||||
$this->assertCount(3, $signature->credentialDocuments());
|
||||
$this->assertCount(1, $signature->credentialDocuments()['license']['originals']);
|
||||
if ($qa = getenv('CAIWU_CREDENTIAL_QA')) {
|
||||
foreach ($signature->credentialDocuments() as $key => $document) {
|
||||
file_put_contents($qa . '/' . $key . '.pdf', Storage::disk('uploads')->get($document['source_path']));
|
||||
}
|
||||
}
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
if (! str_ends_with($request->url(), '/create-by-file')) return false;
|
||||
$data = $request->data();
|
||||
$this->assertCount(4, $data['docs']);
|
||||
$this->assertCount(1, $data['signers'][0]['signFields']);
|
||||
$this->assertCount(4, $data['signers'][1]['signFields']);
|
||||
$this->assertSame(1, $data['signers'][1]['signerType']);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_archive_matches_file_ids_even_when_remote_files_are_out_of_order(): void
|
||||
{
|
||||
$this->fakeEsign();
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'))->assertRedirect();
|
||||
$signature = CourierAgreementSignature::query()->firstOrFail();
|
||||
$signature->update(['status' => 'completed', 'remote_status' => '2']);
|
||||
$this->fakeDownloads($signature);
|
||||
$archived = app(CourierAgreementSignatureService::class)->archive($signature);
|
||||
$this->assertSame('archived', $archived->status);
|
||||
$this->assertSame('%PDF-main', Storage::disk('uploads')->get($archived->signed_pdf_path));
|
||||
foreach ($archived->credentialDocuments() as $key => $document) {
|
||||
$this->assertSame('%PDF-' . $key, Storage::disk('uploads')->get($document['signed_path']));
|
||||
$this->assertSame(hash('sha256', '%PDF-' . $key), $document['signed_sha256']);
|
||||
$this->loginAs()->get(route('business.courier-agreement-signatures.download', [$archived, 'credential-' . $key . '-signed']))->assertOk();
|
||||
$preview = $this->loginAs()->get(route('business.courier-agreement-signatures.download', [$archived, 'credential-' . $key . '-signed', 'preview' => 1]));
|
||||
$preview->assertOk();
|
||||
$this->assertStringStartsWith('inline;', $preview->headers->get('Content-Disposition'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_missing_credential_download_prevents_marking_task_archived(): void
|
||||
{
|
||||
$this->fakeEsign();
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'))->assertRedirect();
|
||||
$signature = CourierAgreementSignature::query()->firstOrFail();
|
||||
$signature->update(['status' => 'completed', 'remote_status' => '2']);
|
||||
$this->fakeDownloads($signature, true);
|
||||
try {
|
||||
app(CourierAgreementSignatureService::class)->archive($signature);
|
||||
$this->fail('Missing credential must prevent successful archive');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('全部证件', $e->getMessage());
|
||||
}
|
||||
$this->assertSame('completed', $signature->fresh()->status);
|
||||
$this->assertNull($signature->fresh()->archived_at);
|
||||
}
|
||||
|
||||
private function fakeEsign(): void
|
||||
{
|
||||
$counter = 0;
|
||||
Http::fake(function (ClientRequest $request) use (&$counter) {
|
||||
if (str_ends_with($request->url(), '/file-upload-url')) {
|
||||
$id = 'file-' . ++$counter;
|
||||
return Http::response(['code' => 0, 'data' => ['fileId' => $id, 'fileUploadUrl' => 'https://upload.esign.test/' . $id]]);
|
||||
}
|
||||
if (str_starts_with($request->url(), 'https://upload.esign.test/')) return Http::response('ok');
|
||||
if (preg_match('~/v3/files/file-\d+$~', $request->url())) return Http::response(['code' => 0, 'data' => ['status' => 2]]);
|
||||
if (str_ends_with($request->url(), '/create-by-file')) return Http::response(['code' => 0, 'data' => ['signFlowId' => 'flow-credentials']]);
|
||||
return Http::response([], 404);
|
||||
});
|
||||
}
|
||||
|
||||
private function fakeDownloads(CourierAgreementSignature $signature, bool $missing = false): void
|
||||
{
|
||||
Http::swap(new \Illuminate\Http\Client\Factory());
|
||||
$files = [['fileId' => $signature->esign_file_id, 'downloadUrl' => 'https://download.esign.test/main']];
|
||||
$responses = ['https://download.esign.test/main' => Http::response('%PDF-main')];
|
||||
foreach ($signature->credentialDocuments() as $key => $document) {
|
||||
if (! $missing || $key !== 'bank') $files[] = ['fileId' => $document['file_id'], 'downloadUrl' => 'https://download.esign.test/' . $key];
|
||||
$responses['https://download.esign.test/' . $key] = Http::response('%PDF-' . $key);
|
||||
}
|
||||
$responses['https://smlopenapi.esign.test/v3/sign-flow/flow-credentials/file-download-url'] = Http::response(['code' => 0, 'data' => ['files' => array_reverse($files)]]);
|
||||
Http::fake($responses);
|
||||
}
|
||||
|
||||
private function payload(string $type): array
|
||||
{
|
||||
$images = [];
|
||||
foreach (CourierAgreementCredentials::definitions($type) as $definition) {
|
||||
foreach ($definition['images'] as $key) $images[$key] = UploadedFile::fake()->image($key . '.png', 900, 550);
|
||||
}
|
||||
return ['partner_type' => 'courier', 'idempotency_key' => 'credentials-' . $type,
|
||||
'courier_name' => '测试快递', 'document_type' => $type, 'signing_subject' => $this->company->name,
|
||||
'company_full_name' => $this->company->name, 'signature_date' => now()->toDateString(),
|
||||
'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000',
|
||||
'secondary_esign_account' => '13900139000', 'operator_name' => '测试甲方', 'secondary_operator_name' => '测试乙方',
|
||||
'counterparty_signer_type' => 'organization', 'secondary_signer_type' => 'person', 'archive_mode' => 'auto',
|
||||
'principal_name' => '测试委托公司', 'principal_id_number' => 'TEST-PRINCIPAL',
|
||||
'trustee_name' => '测试乙方', 'trustee_id_number' => 'TEST-TRUSTEE', 'authorization_items' => ['1'],
|
||||
'bill_counterparty' => '测试往来公司', 'loan_counterparty' => '测试往来公司', 'payment_counterparty' => '测试往来公司',
|
||||
'authorization_start_date' => now()->toDateString(), 'authorization_end_date' => now()->addYear()->toDateString(),
|
||||
'former_company_name' => '测试原公司', 'changed_company_name' => '测试新公司',
|
||||
'entity_change_date' => now()->toDateString(), 'changed_company_start_date' => now()->toDateString(),
|
||||
'credential_required' => 1, 'credential_signer' => $type === 'entity-change' ? 1 : 0, 'credential_images' => $images];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user