feat: add callback-free eSign status polling
This commit is contained in:
parent
aaab198edb
commit
59a984d32a
140
app/Console/Commands/PollEsignSignatures.php
Normal file
140
app/Console/Commands/PollEsignSignatures.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Models\Business\CourierQuoteSignature;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use App\Support\CourierQuoteSignatureService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class PollEsignSignatures extends Command
|
||||
{
|
||||
protected $signature = 'esign:poll-signatures
|
||||
{--limit= : 本轮最多查询的签署流程数}
|
||||
{--min-age= : 同一流程两次查询之间的最少秒数}';
|
||||
|
||||
protected $description = '在不使用回调地址时,定时查询 E 签宝流程状态并归档已签文件';
|
||||
|
||||
public function handle(
|
||||
CourierQuoteSignatureService $quoteSignatures,
|
||||
CourierAgreementSignatureService $agreementSignatures,
|
||||
): int {
|
||||
if (! (bool) config('services.esign.polling_enabled', false)) {
|
||||
$this->components->info('E 签宝定时轮询未启用。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! EsignClient::configured()) {
|
||||
$this->components->warn('E 签宝尚未正确配置,已跳过定时轮询。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$limit = min(max((int) ($this->option('limit')
|
||||
?? config('services.esign.poll_batch_size', 25)), 1), 100);
|
||||
$minAge = max((int) ($this->option('min-age')
|
||||
?? config('services.esign.poll_min_age_seconds', 120)), 0);
|
||||
$cutoff = now()->subSeconds($minAge);
|
||||
$candidates = $this->candidates($limit, $cutoff);
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$model = $candidate['model'];
|
||||
|
||||
try {
|
||||
if ($candidate['type'] === 'quote') {
|
||||
$quoteSignatures->synchronize($model, (int) ($model->updated_by ?: $model->created_by));
|
||||
} else {
|
||||
$agreementSignatures->synchronize($model, (int) ($model->updated_by ?: $model->created_by));
|
||||
}
|
||||
|
||||
$succeeded++;
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
Log::warning('E 签宝签署流程定时查询失败。', [
|
||||
'signature_type' => $candidate['type'],
|
||||
'signature_id' => $model->getKey(),
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->components->info(sprintf(
|
||||
'E 签宝轮询完成:候选 %d,成功 %d,失败 %d。',
|
||||
$candidates->count(),
|
||||
$succeeded,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return Collection<int,array{type:string,model:CourierQuoteSignature|CourierAgreementSignature}> */
|
||||
private function candidates(int $limit, \DateTimeInterface $cutoff): Collection
|
||||
{
|
||||
$environment = strtolower(trim((string) config('services.esign.environment', 'sandbox')));
|
||||
|
||||
$quotes = CourierQuoteSignature::allCompanies()
|
||||
->where('environment', $environment)
|
||||
->whereNotNull('esign_sign_flow_id')
|
||||
->where('esign_sign_flow_id', '<>', '')
|
||||
->whereIn('status', [
|
||||
CourierQuoteSignature::STATUS_SIGNING,
|
||||
CourierQuoteSignature::STATUS_COMPLETED,
|
||||
])
|
||||
->where(fn (Builder $query) => $query
|
||||
->whereNull('last_synced_at')
|
||||
->orWhere('last_synced_at', '<=', $cutoff))
|
||||
->orderByRaw('CASE WHEN last_synced_at IS NULL THEN 0 ELSE 1 END')
|
||||
->orderBy('last_synced_at')
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (CourierQuoteSignature $signature): array => [
|
||||
'type' => 'quote',
|
||||
'model' => $signature,
|
||||
]);
|
||||
|
||||
$agreements = CourierAgreementSignature::allCompanies()
|
||||
->disableCache()
|
||||
->where('environment', $environment)
|
||||
->whereNotNull('esign_sign_flow_id')
|
||||
->where('esign_sign_flow_id', '<>', '')
|
||||
->whereIn('status', [
|
||||
CourierAgreementSignature::STATUS_SIGNING,
|
||||
CourierAgreementSignature::STATUS_UNKNOWN,
|
||||
CourierAgreementSignature::STATUS_COMPLETED,
|
||||
])
|
||||
->where(fn (Builder $query) => $query
|
||||
->whereNull('last_synced_at')
|
||||
->orWhere('last_synced_at', '<=', $cutoff))
|
||||
->orderByRaw('CASE WHEN last_synced_at IS NULL THEN 0 ELSE 1 END')
|
||||
->orderBy('last_synced_at')
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (CourierAgreementSignature $signature): array => [
|
||||
'type' => 'agreement',
|
||||
'model' => $signature,
|
||||
]);
|
||||
|
||||
return $quotes
|
||||
->concat($agreements)
|
||||
->sortBy(fn (array $candidate): string => sprintf(
|
||||
'%020d:%s:%020d',
|
||||
$candidate['model']->last_synced_at?->getTimestamp() ?? 0,
|
||||
$candidate['type'],
|
||||
$candidate['model']->getKey(),
|
||||
))
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,13 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('recurring:check')->dailyAt($schedule_time)->runInBackground();
|
||||
$schedule->command('storage-temp:clear')->dailyAt('17:00');
|
||||
$schedule->command('model:prune')->dailyAt('17:00');
|
||||
|
||||
if ((bool) config('services.esign.polling_enabled', false)) {
|
||||
$schedule->command('esign:poll-signatures --quiet')
|
||||
->cron('*/2 * * * *')
|
||||
->withoutOverlapping(5)
|
||||
->runInBackground();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -210,32 +210,37 @@ final class CourierAgreementSignatureService
|
||||
$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 签宝未返回签署文件或完成证书下载地址。');
|
||||
if ($signedUrl === '') {
|
||||
throw new RuntimeException('E 签宝未返回签署文件下载地址。');
|
||||
}
|
||||
$signedBytes = $client->download($signedUrl);
|
||||
$certificateBytes = $client->download($certificateUrl);
|
||||
if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) {
|
||||
$certificateBytes = $certificateUrl === '' ? null : $client->download($certificateUrl);
|
||||
if (! str_starts_with($signedBytes, '%PDF-')
|
||||
|| ($certificateBytes !== null && ! 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)) {
|
||||
if (! $disk->put($signedPath, $signedBytes)
|
||||
|| ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) {
|
||||
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
|
||||
}
|
||||
$signature->update([
|
||||
$archive = [
|
||||
'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,
|
||||
]);
|
||||
];
|
||||
if ($certificateBytes !== null) {
|
||||
$archive['certificate_path'] = $certificatePath;
|
||||
$archive['certificate_sha256'] = hash('sha256', $certificateBytes);
|
||||
}
|
||||
$signature->update($archive);
|
||||
return $signature->fresh();
|
||||
}
|
||||
|
||||
|
||||
@ -239,14 +239,15 @@ final class CourierQuoteSignatureService
|
||||
$signedUrl = (string) data_get($urls, 'files.0.downloadUrl', '');
|
||||
$certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? '');
|
||||
|
||||
if ($signedUrl === '' || $certificateUrl === '') {
|
||||
throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。');
|
||||
if ($signedUrl === '') {
|
||||
throw new RuntimeException('E 签宝未返回签署文件下载地址。');
|
||||
}
|
||||
|
||||
$signedBytes = $client->download($signedUrl);
|
||||
$certificateBytes = $client->download($certificateUrl);
|
||||
$certificateBytes = $certificateUrl === '' ? null : $client->download($certificateUrl);
|
||||
|
||||
if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) {
|
||||
if (! str_starts_with($signedBytes, '%PDF-')
|
||||
|| ($certificateBytes !== null && ! str_starts_with($certificateBytes, '%PDF-'))) {
|
||||
throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。');
|
||||
}
|
||||
|
||||
@ -255,21 +256,25 @@ final class CourierQuoteSignatureService
|
||||
$certificatePath = $this->path($version, 'completion-certificate.pdf');
|
||||
$disk = Storage::disk('uploads');
|
||||
|
||||
if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) {
|
||||
if (! $disk->put($signedPath, $signedBytes)
|
||||
|| ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) {
|
||||
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
|
||||
}
|
||||
|
||||
$signature->update([
|
||||
$archive = [
|
||||
'status' => CourierQuoteSignature::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,
|
||||
]);
|
||||
];
|
||||
if ($certificateBytes !== null) {
|
||||
$archive['certificate_path'] = $certificatePath;
|
||||
$archive['certificate_sha256'] = hash('sha256', $certificateBytes);
|
||||
}
|
||||
$signature->update($archive);
|
||||
$this->updateVersionStatus($signature, '已签署并归档');
|
||||
|
||||
return $signature->fresh('version');
|
||||
|
||||
@ -50,6 +50,11 @@ return [
|
||||
'notify_url' => env('ESIGN_NOTIFY_URL'),
|
||||
'callback_tolerance_seconds' => (int) env('ESIGN_CALLBACK_TOLERANCE_SECONDS', 900),
|
||||
'timeout' => (int) env('ESIGN_TIMEOUT', 20),
|
||||
// Callback-free deployments opt in to bounded status polling. Keep it
|
||||
// disabled until the private production credentials are verified.
|
||||
'polling_enabled' => filter_var(env('ESIGN_POLLING_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'poll_batch_size' => (int) env('ESIGN_POLL_BATCH_SIZE', 25),
|
||||
'poll_min_age_seconds' => (int) env('ESIGN_POLL_MIN_AGE_SECONDS', 120),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -69,7 +69,7 @@ export const FEATURE_RULES = [
|
||||
paths: /(?:ProductPrice|SupplierPayable|supplier-payables)/i,
|
||||
},
|
||||
{
|
||||
key: 'courier-esign', priority: 90, moduleName: '快递与审批', featureName: '快递报价与易签宝协议签署',
|
||||
key: 'courier-esign', priority: 90, moduleName: '快递与审批', featureName: 'E 签宝无回调状态轮询与归档',
|
||||
subject: /快递|报价|签署|协议|E\s*签|esign|cooperation|courier|signing/i,
|
||||
paths: /(?:Courier|Esign|agreement-signing|quote-management)/i,
|
||||
},
|
||||
|
||||
@ -174,6 +174,56 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
Queue::assertPushed(ArchiveCourierAgreementSignature::class, 1);
|
||||
}
|
||||
|
||||
public function test_polling_archives_mainland_agreement_without_completion_certificate(): void
|
||||
{
|
||||
$signature = CourierAgreementSignature::create([
|
||||
'company_id' => $this->company->id,
|
||||
'business_no' => 'AGR-MAINLAND-001',
|
||||
'idempotency_key' => 'mainland-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' => ['archive_mode' => 'auto'],
|
||||
'environment' => 'sandbox',
|
||||
'source_pdf_path' => 'courier-agreements/mainland/source.pdf',
|
||||
'source_pdf_sha256' => hash('sha256', '%PDF-source'),
|
||||
'esign_file_id' => 'file-agreement-mainland',
|
||||
'esign_sign_flow_id' => 'flow-agreement-mainland',
|
||||
'remote_status' => '1',
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
]);
|
||||
Http::fake([
|
||||
'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-mainland/detail' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => ['signFlowStatus' => 2],
|
||||
]),
|
||||
'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-mainland/file-download-url' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => [
|
||||
'files' => [['downloadUrl' => 'https://download.esign.test/agreement-mainland']],
|
||||
'certificateDownloadUrl' => null,
|
||||
],
|
||||
]),
|
||||
'https://download.esign.test/agreement-mainland' => Http::response('%PDF-agreement-mainland'),
|
||||
]);
|
||||
|
||||
$result = app(\App\Support\CourierAgreementSignatureService::class)
|
||||
->synchronize($signature, $this->user->id);
|
||||
|
||||
$this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $result->status);
|
||||
Storage::disk('uploads')->assertExists($result->signed_pdf_path);
|
||||
$this->assertNull($result->certificate_path);
|
||||
$this->assertNull($result->certificate_sha256);
|
||||
}
|
||||
|
||||
private function fakeEsign(): void
|
||||
{
|
||||
Http::fake(function (ClientRequest $request) {
|
||||
|
||||
@ -246,6 +246,38 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
|
||||
$this->assertNull($signature->archived_at);
|
||||
}
|
||||
|
||||
public function test_manual_sync_archives_when_mainland_flow_has_no_completion_certificate(): void
|
||||
{
|
||||
$signature = $this->createPreparedSignature([
|
||||
'status' => CourierQuoteSignature::STATUS_SIGNING,
|
||||
'esign_file_id' => 'file-mainland',
|
||||
'esign_sign_flow_id' => 'flow-mainland',
|
||||
'remote_status' => '1',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
Http::fake([
|
||||
'https://smlopenapi.esign.test/v3/sign-flow/flow-mainland/detail' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => ['signFlowStatus' => 2],
|
||||
]),
|
||||
'https://smlopenapi.esign.test/v3/sign-flow/flow-mainland/file-download-url' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => [
|
||||
'files' => [['downloadUrl' => 'https://download.esign.test/mainland-signed']],
|
||||
'certificateDownloadUrl' => null,
|
||||
],
|
||||
]),
|
||||
'https://download.esign.test/mainland-signed' => Http::response('%PDF-mainland-signed'),
|
||||
]);
|
||||
|
||||
$result = app(CourierQuoteSignatureService::class)->synchronize($signature, $this->user->id);
|
||||
|
||||
$this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $result->status);
|
||||
Storage::disk('uploads')->assertExists($result->signed_pdf_path);
|
||||
$this->assertNull($result->certificate_path);
|
||||
$this->assertNull($result->certificate_sha256);
|
||||
}
|
||||
|
||||
public function test_verified_completion_callback_is_idempotent_and_queues_archiving(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
187
tests/Feature/Console/PollEsignSignaturesTest.php
Normal file
187
tests/Feature/Console/PollEsignSignaturesTest.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Models\Business\CourierQuoteSignature;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PollEsignSignaturesTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'services.esign.environment' => 'production',
|
||||
'services.esign.base_url' => 'https://openapi.esign.test',
|
||||
'services.esign.app_id' => 'production-app-id',
|
||||
'services.esign.app_secret' => 'production-app-secret',
|
||||
'services.esign.notify_url' => null,
|
||||
'services.esign.polling_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_polls_only_due_non_terminal_flows_without_a_callback(): void
|
||||
{
|
||||
$quote = $this->quoteSignature([
|
||||
'status' => CourierQuoteSignature::STATUS_SIGNING,
|
||||
'esign_sign_flow_id' => 'quote-flow-due',
|
||||
'last_synced_at' => now()->subMinutes(5),
|
||||
]);
|
||||
$this->quoteSignature([
|
||||
'status' => CourierQuoteSignature::STATUS_ARCHIVED,
|
||||
'esign_sign_flow_id' => 'quote-flow-archived',
|
||||
'last_synced_at' => now()->subMinutes(5),
|
||||
]);
|
||||
$agreement = $this->agreementSignature([
|
||||
'status' => CourierAgreementSignature::STATUS_UNKNOWN,
|
||||
'esign_sign_flow_id' => 'agreement-flow-due',
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
$this->agreementSignature([
|
||||
'status' => CourierAgreementSignature::STATUS_SIGNING,
|
||||
'esign_sign_flow_id' => 'agreement-flow-recent',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://openapi.esign.test/v3/sign-flow/*/detail' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => ['signFlowStatus' => 1],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->artisan('esign:poll-signatures', ['--limit' => 10, '--min-age' => 120])
|
||||
->expectsOutputToContain('候选 2,成功 2,失败 0')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertSentCount(2);
|
||||
$this->assertNotNull($quote->fresh()->last_synced_at);
|
||||
$this->assertNotNull($agreement->fresh()->last_synced_at);
|
||||
}
|
||||
|
||||
public function test_it_skips_polling_until_the_private_switch_is_enabled(): void
|
||||
{
|
||||
config(['services.esign.polling_enabled' => false]);
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->artisan('esign:poll-signatures')
|
||||
->expectsOutputToContain('定时轮询未启用')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_it_does_not_poll_flows_created_in_another_environment(): void
|
||||
{
|
||||
$this->quoteSignature([
|
||||
'environment' => 'sandbox',
|
||||
'esign_sign_flow_id' => 'sandbox-quote-flow',
|
||||
'last_synced_at' => now()->subMinutes(5),
|
||||
]);
|
||||
$this->agreementSignature([
|
||||
'environment' => 'sandbox',
|
||||
'esign_sign_flow_id' => 'sandbox-agreement-flow',
|
||||
'last_synced_at' => now()->subMinutes(5),
|
||||
]);
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->artisan('esign:poll-signatures', ['--limit' => 10, '--min-age' => 0])
|
||||
->expectsOutputToContain('候选 0,成功 0,失败 0')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_it_limits_the_total_number_of_remote_queries_per_run(): void
|
||||
{
|
||||
$this->agreementSignature([
|
||||
'esign_sign_flow_id' => 'agreement-flow-first',
|
||||
'last_synced_at' => now()->subMinutes(10),
|
||||
]);
|
||||
$this->agreementSignature([
|
||||
'esign_sign_flow_id' => 'agreement-flow-second',
|
||||
'last_synced_at' => now()->subMinutes(5),
|
||||
]);
|
||||
Http::fake([
|
||||
'https://openapi.esign.test/v3/sign-flow/*/detail' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => ['signFlowStatus' => 1],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->artisan('esign:poll-signatures', ['--limit' => 1, '--min-age' => 0])
|
||||
->expectsOutputToContain('候选 1,成功 1,失败 0')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function test_it_continues_after_one_remote_query_fails(): void
|
||||
{
|
||||
$failed = $this->agreementSignature([
|
||||
'esign_sign_flow_id' => 'agreement-flow-failed',
|
||||
'last_synced_at' => now()->subMinutes(10),
|
||||
]);
|
||||
$succeeded = $this->agreementSignature([
|
||||
'esign_sign_flow_id' => 'agreement-flow-succeeded',
|
||||
'last_synced_at' => now()->subMinutes(5),
|
||||
]);
|
||||
Http::fake(fn ($request) => str_contains($request->url(), 'agreement-flow-failed')
|
||||
? Http::response(['code' => 500, 'message' => 'temporary failure'], 503)
|
||||
: Http::response(['code' => 0, 'data' => ['signFlowStatus' => 1]]));
|
||||
|
||||
$this->artisan('esign:poll-signatures', ['--limit' => 10, '--min-age' => 0])
|
||||
->expectsOutputToContain('候选 2,成功 1,失败 1')
|
||||
->assertFailed();
|
||||
|
||||
Http::assertSentCount(2);
|
||||
$this->assertNotNull($failed->fresh()->last_error);
|
||||
$this->assertNotNull($succeeded->fresh()->last_synced_at);
|
||||
}
|
||||
|
||||
private function quoteSignature(array $overrides = []): CourierQuoteSignature
|
||||
{
|
||||
return CourierQuoteSignature::create(array_merge([
|
||||
'company_id' => $this->company->id,
|
||||
'quote_version_id' => random_int(1000, 999999),
|
||||
'provider' => 'esign',
|
||||
'environment' => 'production',
|
||||
'status' => CourierQuoteSignature::STATUS_SIGNING,
|
||||
'organization_name' => '测试快递公司',
|
||||
'source_pdf_path' => 'quotes/source.pdf',
|
||||
'source_pdf_sha256' => str_repeat('a', 64),
|
||||
'source_pdf_generated_at' => now(),
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function agreementSignature(array $overrides = []): CourierAgreementSignature
|
||||
{
|
||||
$sequence = CourierAgreementSignature::count() + 1;
|
||||
|
||||
return CourierAgreementSignature::create(array_merge([
|
||||
'company_id' => $this->company->id,
|
||||
'business_no' => 'ESIGN-POLL-' . $sequence,
|
||||
'idempotency_key' => 'esign-poll-' . $sequence,
|
||||
'document_type' => 'account-proof',
|
||||
'template_code' => 'TPL-ACCOUNT-CERT-V2',
|
||||
'template_version' => '2',
|
||||
'source_file_name' => 'account-proof.doc',
|
||||
'status' => CourierAgreementSignature::STATUS_SIGNING,
|
||||
'courier_name' => '测试快递',
|
||||
'company_full_name' => '测试公司',
|
||||
'organization_name' => '测试快递公司',
|
||||
'operator_account' => '13800138000',
|
||||
'payload' => ['archive_mode' => 'auto'],
|
||||
'environment' => 'production',
|
||||
'source_pdf_path' => 'agreements/source.pdf',
|
||||
'source_pdf_sha256' => str_repeat('b', 64),
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,9 @@ test('matches exactly one project and rejects ambiguous matches', () => {
|
||||
test('assigns one primary feature and stops on ambiguous feature matches', () => {
|
||||
assert.equal(classifyCommit('财务经营报表增加安装明细', ['app/Models/Business/InstallationDetail.php']).rule.key, 'installation-details')
|
||||
assert.equal(classifyCommit('修复云数据库管理不支持 PostgreSQL 连接', ['app/Http/Controllers/SystemSettings/Databases.php']).rule.key, 'upstream-connections')
|
||||
const esign = classifyCommit('feat: add callback-free eSign status polling', ['app/Console/Commands/PollEsignSignatures.php']).rule
|
||||
assert.equal(esign.key, 'courier-esign')
|
||||
assert.equal(esign.featureName, 'E 签宝无回调状态轮询与归档')
|
||||
assert.equal(
|
||||
classifyCommit('feat: align taobao reconciliation headers', ['resources/views/store_receivables/zhongcai_reconciliations/partials/embedded-table.blade.php']).rule.key,
|
||||
'zhongcai-reconciliation',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user