快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
315 lines
15 KiB
PHP
315 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use App\Models\Business\CourierOnboarding;
|
||
use App\Models\Business\CourierOnboardingFile;
|
||
use App\Support\CourierRequoteService;
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 快递总览:工作平台(钉钉流程 → wp_express)和财务系统快递入驻按快递合成一行。
|
||
*
|
||
* 字段各有归属:快递代码、发货账号、ERP账户ID 归工作平台;主体、对公/对私账户、证照、报价、签署归财务系统。
|
||
* 两边先按入驻里关联的「工作平台快递代码」对,没关联的按系统快递名称对。
|
||
*/
|
||
final class CourierOverviewData
|
||
{
|
||
public const SOURCES = [
|
||
'erp_pending' => '待 ERP 开通',
|
||
'both' => '入驻 + 工作平台',
|
||
'onboarding' => '只有入驻',
|
||
'platform' => '只在工作平台(缺入驻资料)',
|
||
'mismatch' => '账户对不上',
|
||
];
|
||
|
||
private ?Collection $onboardingCache = null;
|
||
|
||
public function __construct(
|
||
private readonly CourierInformationMirrorRepository $mirror,
|
||
private readonly CourierRequoteService $requotes,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* @return array{rows:LengthAwarePaginator,counts:array<string,int>,platform_ready:bool}
|
||
*/
|
||
public function page(array $filters, int $page, int $perPage): array
|
||
{
|
||
$all = $this->rows();
|
||
$platformReady = $all['platform_ready'];
|
||
$rows = collect($all['rows']);
|
||
$counts = [
|
||
'all' => $rows->count(),
|
||
'both' => $rows->where('source', 'both')->count(),
|
||
'platform' => $rows->where('source', 'platform')->count(),
|
||
'onboarding' => $rows->where('source', 'onboarding')->count(),
|
||
'mismatch' => $rows->where('account_status', 'mismatch')->count(),
|
||
'erp_pending' => $rows->where('erp_status', 'pending')->count(),
|
||
];
|
||
|
||
$source = (string) ($filters['source'] ?? '');
|
||
if ($source === 'mismatch') {
|
||
$rows = $rows->where('account_status', 'mismatch');
|
||
} elseif ($source === 'erp_pending') {
|
||
$rows = $rows->where('erp_status', 'pending');
|
||
} elseif (array_key_exists($source, self::SOURCES)) {
|
||
$rows = $rows->where('source', $source);
|
||
}
|
||
$keyword = trim((string) ($filters['search'] ?? ''));
|
||
if ($keyword !== '') {
|
||
$rows = $rows->filter(static fn (array $row): bool => str_contains(
|
||
mb_strtolower($row['name'] . ' ' . ($row['expcode'] ?? '') . ' ' . ($row['onboarding']?->company_full_name ?? '')),
|
||
mb_strtolower($keyword),
|
||
));
|
||
}
|
||
$rows = $rows->values();
|
||
|
||
return [
|
||
'rows' => new LengthAwarePaginator(
|
||
$rows->forPage($page, $perPage)->values(),
|
||
$rows->count(),
|
||
$perPage,
|
||
$page,
|
||
['path' => request()->url(), 'query' => request()->except('page')],
|
||
),
|
||
'counts' => $counts,
|
||
'platform_ready' => $platformReady,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{rows:list<array<string,mixed>>,platform_ready:bool}
|
||
*/
|
||
public function rows(): array
|
||
{
|
||
try {
|
||
$platform = $this->mirror->overviewSource();
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
$platform = null;
|
||
}
|
||
|
||
$onboardings = Schema::hasTable('business_courier_onboardings')
|
||
? CourierOnboarding::query()
|
||
->with('files')
|
||
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
|
||
->latest('id')
|
||
->get()
|
||
: collect();
|
||
|
||
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
|
||
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
|
||
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
|
||
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
|
||
$used = [];
|
||
$rows = [];
|
||
|
||
foreach ($platform['express'] ?? [] as $express) {
|
||
$code = (string) $express->expcode;
|
||
$onboarding = $byCode->get($code) ?? $byName->get(CourierOnboarding::normalizeName($express->expname));
|
||
if ($onboarding !== null && isset($used[$onboarding->id])) {
|
||
$onboarding = null;
|
||
}
|
||
if ($onboarding !== null) {
|
||
$used[$onboarding->id] = true;
|
||
}
|
||
$rows[] = $this->row($onboarding, $express, $platform['accounts'][$code] ?? []);
|
||
}
|
||
|
||
foreach ($onboardings as $onboarding) {
|
||
if (! isset($used[$onboarding->id])) {
|
||
$rows[] = $this->row($onboarding, null, []);
|
||
}
|
||
}
|
||
|
||
usort($rows, static fn (array $a, array $b): int => [$a['sort'], $a['name']] <=> [$b['sort'], $b['name']]);
|
||
|
||
return ['rows' => $rows, 'platform_ready' => $platform !== null];
|
||
}
|
||
|
||
/**
|
||
* @param list<object> $accounts
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function row(?CourierOnboarding $onboarding, ?object $express, array $accounts): array
|
||
{
|
||
$source = $onboarding && $express ? 'both' : ($express ? 'platform' : 'onboarding');
|
||
$platformNumbers = collect($accounts)
|
||
->map(static fn ($account): string => preg_replace('/\D+/', '', (string) ($account->account ?? '')))
|
||
->filter()
|
||
->values();
|
||
$onboardingNumbers = collect([$onboarding?->bank_account_number, $onboarding?->private_account_number])
|
||
->map(static fn ($number): string => preg_replace('/\D+/', '', (string) $number))
|
||
->filter()
|
||
->values();
|
||
|
||
$accountStatus = match (true) {
|
||
$source !== 'both' => null,
|
||
$platformNumbers->isEmpty() => 'platform_missing',
|
||
$onboardingNumbers->isEmpty() => 'onboarding_missing',
|
||
$onboardingNumbers->diff($platformNumbers)->isEmpty() => 'match',
|
||
$onboardingNumbers->intersect($platformNumbers)->isNotEmpty() => 'partial',
|
||
default => 'mismatch',
|
||
};
|
||
|
||
$required = $onboarding ? CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) : [];
|
||
$kinds = $onboarding ? $onboarding->files->pluck('kind')->unique()->all() : [];
|
||
$missingFiles = array_values(array_diff($required, $kinds));
|
||
$effective = $onboarding && filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
|
||
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
|
||
: null;
|
||
|
||
// ERP 开通:入驻开通后等钉钉「新建快递」流程回填快递代码;工作平台老快递视为已开通。
|
||
$erpStatus = match (true) {
|
||
$onboarding === null => 'opened',
|
||
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
|
||
$onboarding->erp_opened_at !== null || $express !== null => 'opened',
|
||
default => 'pending',
|
||
};
|
||
$erpAccountId = $onboarding?->erp_account_id
|
||
?: collect($accounts)->pluck('erpAccountId')->filter()->unique()->implode(' / ');
|
||
|
||
return [
|
||
'erp_status' => $erpStatus,
|
||
'erp_account_id' => $erpAccountId ?: null,
|
||
'erp_source' => $onboarding?->erpSourceLabel() ?: ($express ? '工作平台' : null),
|
||
'name' => (string) ($express->expname ?? $onboarding?->courier_name ?? $onboarding?->company_full_name ?? ''),
|
||
'expcode' => $express?->expcode ?? $onboarding?->workplatform_expcode,
|
||
'source' => $source,
|
||
'sort' => match (true) {
|
||
$erpStatus === 'pending' => 0,
|
||
$accountStatus === 'mismatch' => 1,
|
||
$source === 'onboarding' => 2,
|
||
$source === 'both' => 3,
|
||
default => 4,
|
||
},
|
||
'platform' => $express,
|
||
'platform_accounts' => $accounts,
|
||
'onboarding' => $onboarding,
|
||
'account_status' => $accountStatus,
|
||
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding?->entity_type), $missingFiles),
|
||
'effective_version' => $effective?->version_code,
|
||
'linked_by' => $onboarding && $express ? ((string) $onboarding->workplatform_expcode === (string) $express->expcode ? 'code' : 'name') : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 「快递资料」表格右侧接上的入驻信息:本页工作平台快递按快递代码 / 系统快递名称对上的入驻,
|
||
* 以及还没进工作平台(待 ERP 开通、或停用工作平台后新入驻)的快递,放在第一页最前面。
|
||
*
|
||
* @param list<object> $expressRows 本页工作平台快递
|
||
* @return array{matches:array<string,array<string,mixed>>,extra:list<array<string,mixed>>}
|
||
*/
|
||
public function expressAppend(array $expressRows, int $page, string $nameKeyword = '', string $codeFilter = ''): array
|
||
{
|
||
$onboardings = $this->onboardings();
|
||
if ($onboardings->isEmpty()) {
|
||
return ['matches' => [], 'extra' => []];
|
||
}
|
||
|
||
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
|
||
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
|
||
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
|
||
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
|
||
|
||
$matches = [];
|
||
foreach ($expressRows as $express) {
|
||
$onboarding = $byCode->get((string) ($express->expcode ?? '')) ?? $byName->get(CourierOnboarding::normalizeName($express->expname ?? ''));
|
||
if ($onboarding !== null) {
|
||
$matches[(string) ($express->Id ?? $express->expcode)] = $this->onboardingInfo($onboarding, true);
|
||
}
|
||
}
|
||
|
||
$extra = [];
|
||
if ($page === 1) {
|
||
try {
|
||
$platform = $this->mirror->overviewSource();
|
||
} catch (Throwable) {
|
||
$platform = null;
|
||
}
|
||
$codes = collect($platform['express'] ?? [])->pluck('expcode')->map(static fn ($code): string => (string) $code)->flip();
|
||
$names = collect($platform['express'] ?? [])->pluck('expname')->map(static fn ($name): string => CourierOnboarding::normalizeName($name))->flip();
|
||
foreach ($onboardings as $onboarding) {
|
||
$inPlatform = (filled($onboarding->workplatform_expcode) && $codes->has((string) $onboarding->workplatform_expcode))
|
||
|| (filled($onboarding->courier_name) && $names->has(CourierOnboarding::normalizeName($onboarding->courier_name)));
|
||
if ($inPlatform) {
|
||
continue;
|
||
}
|
||
if ($codeFilter !== '' && strcasecmp((string) $onboarding->workplatform_expcode, $codeFilter) !== 0) {
|
||
continue;
|
||
}
|
||
if ($nameKeyword !== '' && ! str_contains(mb_strtolower((string) $onboarding->courier_name . ' ' . $onboarding->company_full_name), mb_strtolower($nameKeyword))) {
|
||
continue;
|
||
}
|
||
$extra[] = $this->onboardingInfo($onboarding, false);
|
||
}
|
||
}
|
||
|
||
return ['matches' => $matches, 'extra' => $extra];
|
||
}
|
||
|
||
/** 快递资料里选中的一行对应的入驻(先按快递公司代码,再按系统快递名称)。 */
|
||
public function infoFor(?string $code, ?string $name): ?array
|
||
{
|
||
$onboardings = $this->onboardings();
|
||
$onboarding = (filled($code) ? $onboardings->first(static fn (CourierOnboarding $o): bool => strcasecmp((string) $o->workplatform_expcode, (string) $code) === 0) : null)
|
||
?? (filled($name) ? $onboardings->first(static fn (CourierOnboarding $o): bool => CourierOnboarding::normalizeName($o->courier_name) === CourierOnboarding::normalizeName($name)) : null);
|
||
|
||
return $onboarding !== null ? $this->onboardingInfo($onboarding, true) : null;
|
||
}
|
||
|
||
/** @return Collection<int,CourierOnboarding> */
|
||
private function onboardings(): Collection
|
||
{
|
||
return $this->onboardingCache ??= Schema::hasTable('business_courier_onboardings')
|
||
? CourierOnboarding::query()
|
||
->with('files')
|
||
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
|
||
->latest('id')
|
||
->get()
|
||
: collect();
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function onboardingInfo(CourierOnboarding $onboarding, bool $inPlatform): array
|
||
{
|
||
$required = CourierOnboardingFile::requiredKindsFor($onboarding->entity_type);
|
||
$missing = array_values(array_diff($required, $onboarding->files->pluck('kind')->unique()->all()));
|
||
$effective = filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
|
||
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
|
||
: null;
|
||
|
||
// 合同起止:协议签署里「合作合同」已签署的那份(最新一份)上填的合作期限。
|
||
$contract = app(\App\Support\CourierOnboardingService::class)->agreementSignatures($onboarding)
|
||
->first(static fn (\App\Models\Business\CourierAgreementSignature $signature): bool => $signature->document_type === 'cooperation-contract'
|
||
&& in_array($signature->status, [\App\Models\Business\CourierAgreementSignature::STATUS_COMPLETED, \App\Models\Business\CourierAgreementSignature::STATUS_ARCHIVED], true));
|
||
$contractPayload = $contract !== null && is_array($contract->payload) ? $contract->payload : [];
|
||
|
||
return [
|
||
'contract_start' => $contractPayload['cooperation_start_date'] ?? null,
|
||
'contract_end' => $contractPayload['cooperation_end_date'] ?? null,
|
||
'onboarding' => $onboarding,
|
||
'erp_status' => match (true) {
|
||
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
|
||
$inPlatform || $onboarding->erp_opened_at !== null => 'opened',
|
||
default => 'pending',
|
||
},
|
||
'checks' => $onboarding->accountChecks(),
|
||
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding->entity_type), $missing),
|
||
'effective_version' => $effective?->version_code,
|
||
];
|
||
}
|
||
|
||
public static function mask(?string $number): string
|
||
{
|
||
$digits = preg_replace('/\s+/', '', (string) $number);
|
||
|
||
return $digits === '' ? '' : '尾号 ' . mb_substr($digits, -4);
|
||
}
|
||
}
|