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

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

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

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

168 lines
8.0 KiB
PHP

<?php
namespace App\Support;
use App\Exceptions\CourierOnboardingDingTalkException;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Services\DingTalk\CourierOnboardingClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class CourierOnboardingDingTalk
{
private const EVENTS = ['dingtalk_starting', 'dingtalk_started', 'dingtalk_failed', 'dingtalk_unknown'];
public function __construct(private readonly CourierOnboardingClient $client)
{
}
/** 财务复审通过并提交事务后尝试一次,外部服务失败不撤销本地审核结果。 */
public function start(int $companyId, int $id, bool $retry = false): string
{
if (! config('courier_onboarding.dingtalk.enabled')) {
return 'disabled';
}
$onboarding = null;
$sending = false;
try {
$onboarding = DB::transaction(function () use ($companyId, $id, $retry): ?CourierOnboarding {
$record = $this->query($companyId)->lockForUpdate()->findOrFail($id);
if (filled($record->dingtalk_instance_id)
|| $record->status !== CourierOnboarding::STATUS_APPROVED) {
return null;
}
$latest = CourierOnboardingEvent::allCompanies()->withTrashed()->companyId($companyId)
->where('onboarding_id', $id)->whereIn('event_type', self::EVENTS)->latest('id')->first();
if ($latest !== null && (! $retry || $latest->event_type !== 'dingtalk_failed')) {
return null;
}
// 先持久化占位,再发 HTTP。进程中断也不能盲目重发。
$this->event($record, 'dingtalk_starting', '财务系统初审、复审均已通过,自动发起入驻审批。');
return $record;
});
if ($onboarding === null) {
return 'skipped';
}
$payload = $this->payload($onboarding);
$token = $this->client->accessToken();
$sending = true;
$instanceId = $this->client->start($token, $payload);
$this->bind($onboarding, $instanceId);
return 'started';
} catch (Throwable $exception) {
$uncertain = $exception instanceof CourierOnboardingDingTalkException ? $exception->uncertain : $sending;
$message = $exception instanceof CourierOnboardingDingTalkException
? $exception->getMessage() : '钉钉审批对接异常,请联系系统管理员核查。';
if ($onboarding !== null) {
try {
$this->event($onboarding, $uncertain ? 'dingtalk_unknown' : 'dingtalk_failed', $message);
} catch (Throwable) {
// 保留之前的 starting 记录,阻止在数据库故障后重复发起。
}
}
Log::warning('Courier onboarding DingTalk start did not complete.', [
'company_id' => $companyId, 'onboarding_id' => $id,
'uncertain' => $uncertain, 'exception_class' => $exception::class,
]);
return $uncertain ? 'unknown' : 'failed';
}
}
/** 核查超时请求后只补关联,不再次创建审批。 */
public function reconcile(int $companyId, int $id, string $instanceId): void
{
$record = $this->query($companyId)->findOrFail($id);
$token = $this->client->accessToken();
$snapshot = $this->client->instance($token, $instanceId);
$reason = collect($snapshot['formComponentValues'] ?? [])->firstWhere('name', '原因')['value'] ?? '';
if (($snapshot['originatorUserId'] ?? '') !== config('courier_onboarding.dingtalk.originator_user_id')
|| ! str_contains((string) $reason, $this->marker($record))
|| empty($snapshot['createTime'])
|| ! $this->client->belongsToProcess($token, $instanceId, $snapshot['createTime'])) {
throw new CourierOnboardingDingTalkException('该钉钉审批与本入驻申请不匹配,未关联。');
}
$this->bind($record, $instanceId);
}
public function payload(CourierOnboarding $record): array
{
$values = ['快递名称' => $record->displayName()];
foreach (CourierOnboarding::PROFILE_FIELDS as $field => [$label]) {
if (filled($record->{$field})) {
$values[$label] = (string) $record->{$field};
}
}
// 业务类型与「是否需要核对物流点」含义不同,不能相互代用。
$logistics = match ($record->service_type) {
'快递' => '否',
'快运', '大件专线' => '是',
default => null,
};
if ($logistics !== null) {
$values['快递是否为物流'] = $logistics;
}
if ($record->workplatform_account === '已有') {
$values['绑定快递名称'] = $record->displayName();
}
// 与该流程现有实例的 PhoneField 值保持一致,使用手机号文本。
$values['电话'] = (string) $record->contact_phone;
$detailUrl = rtrim((string) (config('courier_onboarding.public_url') ?: config('app.url')), '/')
. route('business.courier-onboardings.show', ['company_id' => $record->company_id, 'onboarding' => $record->id], false);
$values['原因'] = '快递入驻资料已通过财务系统初审、复审,申请开通快递合作。' . "\n"
. $this->marker($record) . "\n主体:" . $record->company_full_name . "\n业务类型:" . $record->service_type
. "\n对接人:" . $record->contact_name . "\n资料详情(登录财务系统查看):" . $detailUrl
. "\n快递类型、合作方式等内部快递资料请由对接人员在首个补资料节点填写,后续审批全部通过后回传财务系统。"
. "\n报价签署及回传沿用财务系统的易签宝流程,无需重复上传报价单原件。";
// 系统对接方式、账号密码由后续节点补充;不传原件、占位密码或公开扫码链接。
return [
'processCode' => config('courier_onboarding.dingtalk.process_code'),
'originatorUserId' => config('courier_onboarding.dingtalk.originator_user_id'),
'deptId' => (int) config('courier_onboarding.dingtalk.dept_id'),
'formComponentValues' => collect($values)->map(fn (string $value, string $name): array => compact('name', 'value'))->values()->all(),
];
}
private function marker(CourierOnboarding $record): string
{
return '[财务入驻:' . $record->company_id . ':' . $record->id . ':' . $record->number . ']';
}
private function query(int $companyId)
{
return CourierOnboarding::allCompanies()->companyId($companyId);
}
private function bind(CourierOnboarding $record, string $instanceId): void
{
DB::transaction(function () use ($record, $instanceId): void {
$record = $this->query($record->company_id)->lockForUpdate()->findOrFail($record->id);
if (filled($record->dingtalk_instance_id)) {
if ($record->dingtalk_instance_id !== $instanceId) {
throw new CourierOnboardingDingTalkException('入驻申请已关联其他钉钉审批,未覆盖。', true);
}
return;
}
$record->update(['dingtalk_instance_id' => $instanceId]);
$this->event($record, 'dingtalk_started', '已发起钉钉审批,审批编号:' . $instanceId);
});
}
private function event(CourierOnboarding $record, string $type, string $note): void
{
CourierOnboardingEvent::create([
'company_id' => $record->company_id, 'onboarding_id' => $record->id,
'event_type' => $type, 'from_status' => $record->status, 'to_status' => $record->status,
'note' => $note, 'actor_type' => 'system',
]);
}
}