快递资料提交时即发起钉钉会早于本地初审、复审。改为财务复审通过并成功提交事务后,自动发起“新建快递流程-通用”,使用复审确认的快递名称。 提交、初审、复审校验失败或事务回滚均不调用钉钉;重复复审不重复发起。钉钉失败保留本地复审和首版报价;旧版明确失败的申请通过现有管理员命令单独补发。原审批完结后的资料回传继续生效。 修改:入驻审核服务、钉钉发起服务、页面流程说明、相关测试和对接文档。 验证:44 项相关测试、695 个断言通过;实际渲染页面及桌面截图检查通过;diff 检查通过。 无金额口径、权限、数据库迁移或生产凭据变更。钉钉模板须另行发布首个“杨灵补资料”节点,配置编辑及节点必填权限并避免自动跳过;真实审批全链路仍待该模板配置后验收。
222 lines
12 KiB
PHP
222 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Business;
|
|
|
|
use App\Exceptions\CourierOnboardingDingTalkException;
|
|
use App\Models\Business\CourierOnboarding;
|
|
use App\Models\Business\CourierOnboardingEvent;
|
|
use App\Support\CourierOnboardingDingTalk;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\Feature\FeatureTestCase;
|
|
|
|
class CourierOnboardingDingTalkTest extends FeatureTestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->company->makeCurrent();
|
|
config(['courier_onboarding.dingtalk' => [
|
|
'enabled' => true, 'client_id' => 'onboarding-app', 'client_secret' => 'onboarding-secret',
|
|
'process_code' => 'PROC-TEST', 'originator_user_id' => 'originator-test', 'dept_id' => 123,
|
|
], 'courier_onboarding.public_url' => 'https://finance.example.com:8442']);
|
|
Http::preventStrayRequests();
|
|
}
|
|
|
|
public function test_uses_separate_app_and_omits_original_and_internal_credentials(): void
|
|
{
|
|
$record = $this->record();
|
|
config(['dingtalk.client_id' => 'login-app', 'dingtalk.client_secret' => 'login-secret']);
|
|
$this->fakeSuccess();
|
|
|
|
$this->assertSame('started', $this->service()->start($record->company_id, $record->id));
|
|
$this->assertSame('instance-test', $record->fresh()->dingtalk_instance_id);
|
|
$this->assertSame(CourierOnboarding::STATUS_APPROVED, $record->fresh()->status);
|
|
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/oauth2/accessToken')
|
|
&& $request['appKey'] === 'onboarding-app' && $request['appSecret'] === 'onboarding-secret');
|
|
Http::assertSent(function (Request $request) use ($record): bool {
|
|
if (! str_ends_with($request->url(), '/workflow/processInstances')) {
|
|
return false;
|
|
}
|
|
$values = array_column($request['formComponentValues'], 'value', 'name');
|
|
$this->assertSame('originator-test', $request['originatorUserId']);
|
|
$this->assertSame(123, $request['deptId']);
|
|
$this->assertSame('否', $values['快递是否为物流']);
|
|
$this->assertSame('是', $values['是否需要核对物流点']);
|
|
$this->assertSame('13800001111', $values['电话']);
|
|
$this->assertSame('测试快递', $values['绑定快递名称']);
|
|
foreach (['报价单原件', '附件', '系统对接方式', '查件记账账号', '查件记账密码', '财务账单账号', '财务账单密码', '聚水潭聚工单账号', '聚水潭聚工单密码'] as $field) {
|
|
$this->assertArrayNotHasKey($field, $values);
|
|
}
|
|
$body = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
|
|
$this->assertStringNotContainsString($record->token, $body);
|
|
$this->assertStringContainsString('/' . $record->company_id . '/workspace/courier-payables/courier-onboarding/applications/' . $record->id, $values['原因']);
|
|
|
|
return true;
|
|
});
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true));
|
|
Http::assertSentCount(2);
|
|
}
|
|
|
|
public function test_pending_claim_blocks_reentry_and_resubmission_does_not_create_another_approval(): void
|
|
{
|
|
$record = $this->record();
|
|
Http::fake(function (Request $request) use ($record) {
|
|
if (str_ends_with($request->url(), '/oauth2/accessToken')) {
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true));
|
|
|
|
return Http::response(['accessToken' => 'test-token']);
|
|
}
|
|
|
|
return Http::response(['instanceId' => 'instance-test']);
|
|
});
|
|
$this->assertSame('started', $this->service()->start($record->company_id, $record->id));
|
|
$record->update(['status' => CourierOnboarding::STATUS_RETURNED]);
|
|
$record->update(['status' => CourierOnboarding::STATUS_SUBMITTED]);
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id));
|
|
$record->update(['status' => CourierOnboarding::STATUS_APPROVED]);
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id));
|
|
Http::assertSentCount(2);
|
|
}
|
|
|
|
public function test_rejected_request_can_only_be_retried_explicitly_and_does_not_store_raw_errors(): void
|
|
{
|
|
$record = $this->record();
|
|
Http::fake([
|
|
'*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']),
|
|
'*/workflow/processInstances' => Http::sequence()
|
|
->push(['code' => 'Forbidden.AccessDenied', 'message' => 'secret-raw-data'], 403)
|
|
->push(['instanceId' => 'instance-test']),
|
|
]);
|
|
$this->assertSame('failed', $this->service()->start($record->company_id, $record->id));
|
|
$this->assertNull($record->fresh()->dingtalk_instance_id);
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id));
|
|
$event = $record->events()->reorder('id', 'desc')->firstOrFail();
|
|
$this->assertSame('dingtalk_failed', $event->event_type);
|
|
$this->assertStringContainsString('Forbidden.AccessDenied', $event->note);
|
|
$this->assertStringNotContainsString('secret-raw-data', $event->note);
|
|
$this->assertSame('started', $this->service()->start($record->company_id, $record->id, true));
|
|
}
|
|
|
|
public function test_timeout_server_error_and_incomplete_success_never_retry_blindly(): void
|
|
{
|
|
foreach (['timeout', 'server-error', 'incomplete'] as $scenario) {
|
|
$record = $this->record();
|
|
Http::fake(function (Request $request) use ($scenario) {
|
|
if (str_ends_with($request->url(), '/oauth2/accessToken')) {
|
|
return Http::response(['accessToken' => 'test-token']);
|
|
}
|
|
if ($scenario === 'timeout') {
|
|
throw new ConnectionException('sensitive-request-context');
|
|
}
|
|
|
|
return Http::response([], $scenario === 'server-error' ? 500 : 200);
|
|
});
|
|
$this->assertSame('unknown', $this->service()->start($record->company_id, $record->id));
|
|
$this->assertSame('dingtalk_unknown', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type);
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true));
|
|
$this->assertNull($record->fresh()->dingtalk_instance_id);
|
|
}
|
|
}
|
|
|
|
public function test_explicit_retry_recovers_old_submission_failure_after_final_review_without_duplicate_creation(): void
|
|
{
|
|
$record = $this->record();
|
|
$record->update(['status' => CourierOnboarding::STATUS_SUBMITTED]);
|
|
CourierOnboardingEvent::create([
|
|
'company_id' => $record->company_id, 'onboarding_id' => $record->id,
|
|
'event_type' => 'dingtalk_failed', 'from_status' => CourierOnboarding::STATUS_SUBMITTED,
|
|
'to_status' => CourierOnboarding::STATUS_SUBMITTED, 'actor_type' => 'system',
|
|
'note' => '旧版在提交时发起失败。',
|
|
]);
|
|
$this->fakeSuccess();
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true));
|
|
Http::assertNothingSent();
|
|
$record->update(['status' => CourierOnboarding::STATUS_APPROVED]);
|
|
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id));
|
|
$this->assertSame('started', $this->service()->start($record->company_id, $record->id, true));
|
|
$this->assertSame(CourierOnboarding::STATUS_APPROVED, $record->fresh()->status);
|
|
$this->assertSame('instance-test', $record->fresh()->dingtalk_instance_id);
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true));
|
|
Http::assertSentCount(2);
|
|
}
|
|
|
|
public function test_disabled_unsubmitted_and_other_company_records_do_not_call_api(): void
|
|
{
|
|
$record = $this->record();
|
|
config(['courier_onboarding.dingtalk.enabled' => false]);
|
|
$this->assertSame('disabled', $this->service()->start($record->company_id, $record->id));
|
|
config(['courier_onboarding.dingtalk.enabled' => true]);
|
|
$this->assertSame('failed', $this->service()->start($record->company_id + 1, $record->id));
|
|
foreach ([CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_RETURNED, CourierOnboarding::STATUS_ACTIVATED, CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED] as $status) {
|
|
$record->update(['status' => $status]);
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id));
|
|
$this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true));
|
|
}
|
|
Http::assertNothingSent();
|
|
$this->assertSame(0, $record->events()->count());
|
|
}
|
|
|
|
public function test_missing_credentials_fail_before_creation_and_unknown_business_type_is_not_invented(): void
|
|
{
|
|
$record = $this->record();
|
|
$record->update(['service_type' => '其他', 'workplatform_account' => '新开']);
|
|
$values = array_column($this->service()->payload($record)['formComponentValues'], 'value', 'name');
|
|
$this->assertArrayNotHasKey('快递是否为物流', $values);
|
|
$this->assertArrayNotHasKey('绑定快递名称', $values);
|
|
config(['courier_onboarding.dingtalk.client_secret' => '']);
|
|
$this->assertSame('failed', $this->service()->start($record->company_id, $record->id));
|
|
$this->assertSame('dingtalk_failed', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type);
|
|
Http::assertNothingSent();
|
|
}
|
|
|
|
public function test_recovery_checks_workflow_originator_and_onboarding_marker_before_binding(): void
|
|
{
|
|
$record = $this->record();
|
|
$other = $this->record();
|
|
$reason = collect($this->service()->payload($record)['formComponentValues'])->firstWhere('name', '原因');
|
|
Http::fake([
|
|
'*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']),
|
|
'*/workflow/processInstances*' => Http::response(['result' => [
|
|
'createTime' => '2026-11-06T03:00Z', 'originatorUserId' => 'originator-test', 'formComponentValues' => [$reason],
|
|
]]),
|
|
'*/workflow/processes/instanceIds/query' => Http::response(['result' => ['list' => ['existing-instance']]]),
|
|
]);
|
|
try {
|
|
$this->service()->reconcile($other->company_id, $other->id, 'existing-instance');
|
|
$this->fail('不同入驻申请不可关联同一审批');
|
|
} catch (CourierOnboardingDingTalkException) {
|
|
$this->assertNull($other->fresh()->dingtalk_instance_id);
|
|
}
|
|
$this->service()->reconcile($record->company_id, $record->id, 'existing-instance');
|
|
$this->assertSame('existing-instance', $record->fresh()->dingtalk_instance_id);
|
|
Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'
|
|
&& str_ends_with($request->url(), '/workflow/processInstances'));
|
|
}
|
|
|
|
private function record(): CourierOnboarding
|
|
{
|
|
return CourierOnboarding::create([
|
|
'company_id' => $this->company->id, 'number' => 'RZ-TEST-' . uniqid(), 'token' => bin2hex(random_bytes(20)),
|
|
'status' => CourierOnboarding::STATUS_APPROVED, 'courier_name' => '测试快递',
|
|
'company_full_name' => '测试快递公司', 'contact_name' => '测试联系人', 'contact_phone' => '13800001111',
|
|
'service_type' => '快递', 'workplatform_account' => '已有', 'need_logistics_check' => '是',
|
|
]);
|
|
}
|
|
|
|
private function fakeSuccess(): void
|
|
{
|
|
Http::fake([
|
|
'*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']),
|
|
'*/workflow/processInstances' => Http::response(['instanceId' => 'instance-test']),
|
|
]);
|
|
}
|
|
|
|
private function service(): CourierOnboardingDingTalk
|
|
{
|
|
return app(CourierOnboardingDingTalk::class);
|
|
}
|
|
}
|