CaiWuXiTong/tests/Feature/Business/CourierOnboardingDingTalkTest.php
limeng 2901bc6396 快递入驻复审通过后自动发起钉钉流程
快递资料提交时即发起钉钉会早于本地初审、复审。改为财务复审通过并成功提交事务后,自动发起“新建快递流程-通用”,使用复审确认的快递名称。

提交、初审、复审校验失败或事务回滚均不调用钉钉;重复复审不重复发起。钉钉失败保留本地复审和首版报价;旧版明确失败的申请通过现有管理员命令单独补发。原审批完结后的资料回传继续生效。

修改:入驻审核服务、钉钉发起服务、页面流程说明、相关测试和对接文档。
验证:44 项相关测试、695 个断言通过;实际渲染页面及桌面截图检查通过;diff 检查通过。

无金额口径、权限、数据库迁移或生产凭据变更。钉钉模板须另行发布首个“杨灵补资料”节点,配置编辑及节点必填权限并避免自动跳过;真实审批全链路仍待该模板配置后验收。
2026-11-06 14:47:01 +08:00

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);
}
}