diff --git a/app/Business/CourierOnboardingPageData.php b/app/Business/CourierOnboardingPageData.php new file mode 100644 index 00000000..3345dd4a --- /dev/null +++ b/app/Business/CourierOnboardingPageData.php @@ -0,0 +1,98 @@ + ['label' => '待处理', 'statuses' => [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_APPROVED]], + 'filling' => ['label' => '待快递填写', 'statuses' => [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED]], + 'activated' => ['label' => '已开通', 'statuses' => [CourierOnboarding::STATUS_ACTIVATED]], + 'closed' => ['label' => '已关闭', 'statuses' => [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]], + 'all' => ['label' => '全部', 'statuses' => []], + ]; + + public function __construct(private readonly CourierOnboardingService $service) + { + } + + public function page(): array + { + $group = (string) request()->query('group', 'pending'); + $group = array_key_exists($group, self::STATUS_GROUPS) ? $group : 'pending'; + $search = trim((string) request()->query('search', '')); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + + $data = [ + 'view' => 'business.pages.courier-payables.courier-onboarding', + 'onboardingReady' => $this->service->ready(), + 'onboardingGroup' => $group, + 'onboardingSearch' => $search, + 'onboardingGroups' => self::STATUS_GROUPS, + 'onboardingCounts' => [], + 'onboardingRows' => new LengthAwarePaginator([], 0, $limit), + ]; + + if (! $data['onboardingReady']) { + return $data; + } + + $counts = $this->service->statusCounts(); + $data['onboardingCounts'] = collect(self::STATUS_GROUPS) + ->map(static fn (array $config): int => $config['statuses'] === [] + ? array_sum($counts) + : array_sum(array_intersect_key($counts, array_flip($config['statuses'])))) + ->all(); + + $query = CourierOnboarding::query()->withCount('files'); + $statuses = self::STATUS_GROUPS[$group]['statuses']; + if ($statuses !== []) { + $query->whereIn('status', $statuses); + } + if ($search !== '') { + $like = '%' . addcslashes($search, '%_\\') . '%'; + $query->where(static function ($builder) use ($like): void { + foreach (['number', 'invite_label', 'company_full_name', 'network_name', 'courier_name', 'contact_name', 'contact_phone'] as $column) { + $builder->orWhere($column, 'like', $like); + } + }); + } + + $data['onboardingRows'] = $query->latest('updated_at')->latest('id') + ->paginate($limit) + ->withQueryString(); + + return $data; + } + + /** + * 快递资料页「入驻快递」页签:已开通的入驻快递,作为新快递的主数据。 + */ + public function activated(int $page, int $limit): LengthAwarePaginator + { + if (! $this->service->ready()) { + return new LengthAwarePaginator([], 0, $limit, $page); + } + + $search = trim((string) request()->query('search', '')); + $query = CourierOnboarding::query()->where('status', CourierOnboarding::STATUS_ACTIVATED); + if ($search !== '') { + $like = '%' . addcslashes($search, '%_\\') . '%'; + $query->where(static function ($builder) use ($like): void { + foreach (['courier_name', 'company_full_name', 'network_name', 'contact_name'] as $column) { + $builder->orWhere($column, 'like', $like); + } + }); + } + + return $query->latest('activated_at')->paginate($limit, ['*'], 'page', $page)->withQueryString(); + } +} diff --git a/app/Business/CourierOverviewData.php b/app/Business/CourierOverviewData.php new file mode 100644 index 00000000..d1325c60 --- /dev/null +++ b/app/Business/CourierOverviewData.php @@ -0,0 +1,314 @@ + '待 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,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>,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 $accounts + * @return array + */ + 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 $expressRows 本页工作平台快递 + * @return array{matches:array>,extra:list>} + */ + 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 */ + 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 */ + 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); + } +} diff --git a/app/Business/CourierProfileData.php b/app/Business/CourierProfileData.php new file mode 100644 index 00000000..e462436e --- /dev/null +++ b/app/Business/CourierProfileData.php @@ -0,0 +1,343 @@ +> 快递代码 => 覆盖值 */ + public function overrides(array $codes): array + { + $codes = array_values(array_unique(array_filter(array_map('strval', $codes), 'filled'))); + if ($codes === [] || ! Schema::hasTable('business_courier_profiles')) { + return []; + } + + return CourierProfile::query()->whereIn('expcode', $codes)->get() + ->mapWithKeys(static fn (CourierProfile $profile): array => [(string) $profile->expcode => (array) $profile->overrides]) + ->all(); + } + + /** 把覆盖值盖到快递资料行上,`_overridden` 记下改过的字段。 */ + public function apply(object $row, array $overrides): object + { + $row = clone $row; + $changed = []; + foreach ($overrides as $field => $value) { + if (array_key_exists($field, CourierProfile::EDITABLE)) { + $row->{$field} = $value; + $changed[] = $field; + } + } + $row->_overridden = $changed; + + return $row; + } + + /** 财务系统入驻(还没有工作平台记录)的快递,按快递资料的列铺成一行。 */ + public function onboardingRow(array $info): object + { + /** @var CourierOnboarding $onboarding */ + $onboarding = $info['onboarding']; + + return (object) [ + 'Id' => null, + 'expcode' => $onboarding->workplatform_expcode, + 'expname' => $onboarding->courier_name, + 'paymethod' => $onboarding->settlement_cycle, + 'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0', + 'kuaiyunsgn' => $onboarding->service_type === '快运' ? '1' : '0', + 'tradebtime' => $info['contract_start'] ?? null, + 'tradeetime' => $info['contract_end'] ?? null, + 'linker' => $onboarding->contact_name, + 'linkmobile' => $onboarding->contact_phone, + 'dzqzjbr' => $onboarding->esign_account, + 'modified' => $onboarding->updated_at?->toDateTimeString(), + '_onboarding_only' => true, + '_info' => $info, + ]; + } + + /** 按快递公司代码取当前显示的一行主信息(工作平台镜像优先,没有则用财务系统入驻),已盖上编辑覆盖值。 */ + public function currentRow(string $code): ?object + { + $mirror = app(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class); + $overview = app(CourierOverviewData::class); + $row = null; + try { + if ($mirror->available() && $mirror->ready()) { + $found = $mirror->paginate(array_merge( + array_fill_keys(['expname', 'undefined1', 'undefined2', 'undefined3', 'tradebtime_from', 'tradebtime_to', 'status'], ''), + ['expcode' => $code], + ), 1, 1)['data'][0] ?? null; + if ($found !== null) { + $row = clone $found; + $row->_info = $overview->infoFor($code, $found->expname ?? null); + } + } + } catch (Throwable $exception) { + report($exception); + } + if ($row === null && ($info = $overview->infoFor($code, null)) !== null) { + $row = $this->onboardingRow($info); + } + + return $row !== null ? $this->apply($row, $this->overrides([$code])[$code] ?? []) : null; + } + + /** + * 协议签署页预填:打款信息新增 → 收款账户证明;变更 → 收款账户变更。 + * + * @return array|null + */ + public static function agreementPrefill(mixed $id): ?array + { + if (! is_numeric($id) || ! Schema::hasTable('business_courier_payment_accounts')) { + return null; + } + $record = CourierPaymentAccount::query()->find((int) $id); + if ($record === null) { + return null; + } + $onboarding = app(CourierOverviewData::class)->infoFor($record->expcode, $record->courier_name)['onboarding'] ?? null; + $fields = $record->action === CourierPaymentAccount::ACTION_CHANGE + ? [ + 'account_change_date' => $record->change_date?->toDateString(), + 'old_account_name' => $record->old_account_name, + 'old_account_number' => $record->old_account_number, + 'old_account_stop_date' => $record->old_stop_date?->toDateString(), + 'new_account_effective_date' => $record->effective_date?->toDateString(), + 'new_account_name' => $record->account_name, + 'new_bank_name' => $record->bank_name, + 'new_bank_account' => $record->account_number, + ] + : [ + 'account_proof_effective_date' => $record->effective_date?->toDateString(), + 'account_proof_account_name' => $record->account_name, + 'account_proof_bank_name' => $record->bank_name, + 'account_proof_bank_account' => $record->account_number, + ]; + if ($onboarding !== null && filled($onboarding->company_full_name)) { + $fields['counterparty_organization_name'] = $onboarding->company_full_name; + } + + return [ + 'id' => $record->id, + 'action' => $record->actionLabel(), + 'courier_name' => $record->courier_name, + 'expcode' => $record->expcode, + 'document_type' => $record->documentType(), + 'signer_type' => $onboarding !== null && $onboarding->entity_type === CourierOnboarding::ENTITY_PERSON ? 'person' : 'organization', + 'esign_account' => $onboarding?->esign_account, + 'fields' => array_filter($fields, static fn ($value): bool => filled($value)), + ]; + } + + /** 保存编辑:只记和当前显示值不同的字段,并写一条操作日志。 */ + public function update(string $expcode, ?string $courierName, object $current, array $values, int $userId): int + { + $profile = CourierProfile::query()->firstOrNew(['company_id' => company_id(), 'expcode' => $expcode]); + $overrides = (array) ($profile->overrides ?? []); + $changes = []; + foreach (CourierProfile::EDITABLE as $field => $label) { + if (! array_key_exists($field, $values)) { + continue; + } + $new = $this->normalize($field, $values[$field]); + $old = $this->normalize($field, $current->{$field} ?? null); + if ($new === $old) { + continue; + } + $overrides[$field] = $new; + $changes[$field] = ['label' => $label, 'old' => $old, 'new' => $new]; + } + if ($changes === []) { + return 0; + } + + $profile->fill([ + 'courier_name' => $courierName ?: $profile->courier_name, + 'overrides' => $overrides, + 'updated_by' => $userId, + ]); + $profile->created_by ??= $userId; + $profile->save(); + + $this->log($expcode, 'edit', '修改 ' . implode('、', array_column($changes, 'label')), $changes, $userId); + + return count($changes); + } + + public function log(string $expcode, string $action, string $summary, ?array $changes, int $userId): void + { + CourierProfileEvent::query()->create([ + 'company_id' => company_id(), + 'expcode' => $expcode, + 'action' => $action, + 'summary' => mb_substr($summary, 0, 500), + 'changes' => $changes, + 'created_by' => $userId, + ]); + } + + /** @return Collection */ + public function events(?string $expcode = null, int $limit = 100): Collection + { + if (! Schema::hasTable('business_courier_profile_events')) { + return collect(); + } + + return CourierProfileEvent::query() + ->with('creator') + ->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode)) + ->latest('id') + ->limit($limit) + ->get(); + } + + /** + * 财务系统这一侧的打款信息(完整显示):入驻时填的对公/对私账户 + 打款信息新增/变更记录。 + * + * @return list> + */ + public function financeAccounts(?string $expcode = null, ?CourierOnboarding $onboarding = null): array + { + $rows = []; + if (Schema::hasTable('business_courier_payment_accounts')) { + $records = CourierPaymentAccount::query() + ->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode)) + ->latest('id') + ->get(); + foreach ($records as $record) { + $status = $this->paymentStatus($record); + $rows[] = [ + 'source' => $record->actionLabel(), + 'expcode' => $record->expcode, + 'courier_name' => $record->courier_name, + 'type' => $record->account_type, + 'name' => $record->account_name, + 'bank' => $record->bank_name, + 'number' => $record->account_number, + 'effective_date' => $record->effective_date?->toDateString(), + 'old' => $record->action === CourierPaymentAccount::ACTION_CHANGE + ? trim($record->old_account_name . ' ' . $record->old_account_number) . ($record->old_stop_date ? '(' . $record->old_stop_date->toDateString() . ' 停用)' : '') + : null, + 'status' => $status['label'], + 'tone' => $status['tone'], + 'erp_account_id' => null, + 'created_at' => $record->created_at?->toDateTimeString(), + 'record' => $record, + ]; + } + } + + $onboardings = $onboarding !== null + ? collect([$onboarding]) + : (Schema::hasTable('business_courier_onboardings') + ? CourierOnboarding::query() + ->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED]) + ->when(filled($expcode), static fn ($query) => $query->where('workplatform_expcode', $expcode)) + ->latest('id') + ->get() + : collect()); + foreach ($onboardings as $item) { + $checks = $item->accountChecks(); + foreach ([ + ['对公', $item->bank_account_name, $item->bank_name, $item->bank_account_number, $checks['corporate'] ?? null], + ['对私', $item->private_account_name, $item->private_bank_name, $item->private_account_number, $checks['private'] ?? null], + ] as [$type, $name, $bank, $number, $check]) { + if (! filled($name) && ! filled($number)) { + continue; + } + $rows[] = [ + 'source' => '快递入驻 ' . $item->number, + 'expcode' => $item->workplatform_expcode, + 'courier_name' => $item->courier_name, + 'type' => $type, + 'name' => $name, + 'bank' => $bank, + 'number' => $number, + 'effective_date' => $item->activated_at?->toDateString(), + 'old' => null, + 'status' => $check === false ? ($type === '对公' ? '户名与营业执照主体不一致' : '收款人与法定代表人不一致') : $item->statusLabel(), + 'tone' => $check === false ? 'is-bad' : ($item->status === CourierOnboarding::STATUS_ACTIVATED ? 'is-ok' : 'is-warn'), + 'erp_account_id' => $item->erp_account_id, + 'created_at' => $item->created_at?->toDateTimeString(), + 'onboarding' => $item, + ]; + } + } + + return $rows; + } + + /** + * 打款信息新增/变更的状态:按协议签署里同一快递、同类文件、同一新账号的签署任务推导。 + * + * @return array{label:string,tone:string,signature:?CourierAgreementSignature} + */ + public function paymentStatus(CourierPaymentAccount $record): array + { + $signature = null; + if (Schema::hasTable('business_courier_agreement_signatures') && filled($record->courier_name)) { + $field = $record->action === CourierPaymentAccount::ACTION_CHANGE ? 'new_bank_account' : 'account_proof_bank_account'; + $number = $this->digits($record->account_number); + $signature = CourierAgreementSignature::query() + ->where('courier_name', $record->courier_name) + ->where('document_type', $record->documentType()) + ->where('created_at', '>=', $record->created_at) + ->latest('id') + ->get() + ->first(function (CourierAgreementSignature $signature) use ($field, $number): bool { + try { + return $this->digits((string) data_get($signature->payload, $field)) === $number; + } catch (Throwable) { + return false; + } + }); + } + + return match (true) { + $signature === null => ['label' => '待推送协议', 'tone' => 'is-warn', 'signature' => null], + in_array($signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true) => ['label' => '已生效', 'tone' => 'is-ok', 'signature' => $signature], + in_array($signature->status, [CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED], true) => ['label' => '签署未完成,需重新推送', 'tone' => 'is-bad', 'signature' => $signature], + default => ['label' => '签署中', 'tone' => 'is-warn', 'signature' => $signature], + }; + } + + private function digits(?string $value): string + { + return (string) preg_replace('/[\s\-]+/u', '', (string) $value); + } + + private function normalize(string $field, mixed $value): ?string + { + if ($value === null) { + return null; + } + $value = trim((string) $value); + if ($value === '' || $value === '-') { + return null; + } + if (in_array($field, CourierProfile::BOOLEAN_FIELDS, true)) { + return in_array(strtolower($value), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0'; + } + if (in_array($field, CourierProfile::DATE_FIELDS, true)) { + return substr($value, 0, 10); + } + + return $value; + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 5152b5ee..3807db1d 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -100,6 +100,12 @@ class Handler extends ExceptionHandler */ public function render($request, Throwable $exception) { + // 快递扫码填报页:手机原图超过上传上限时,回到填报页给出能看懂的提示,而不是报错页。 + if ($exception instanceof \Illuminate\Http\Exceptions\PostTooLargeException + && $request->is('onboarding/courier/*')) { + return redirect()->to($request->url() . '?too_large=1'); + } + if (request_is_api($request)) { return $this->handleApiExceptions($request, $exception); } diff --git a/app/Http/Controllers/Business/CourierOnboardings.php b/app/Http/Controllers/Business/CourierOnboardings.php new file mode 100644 index 00000000..d0635468 --- /dev/null +++ b/app/Http/Controllers/Business/CourierOnboardings.php @@ -0,0 +1,339 @@ +ensureReady(); + $data = $request->validate([ + 'courier_name' => ['required', 'string', 'max:100'], + 'invite_label' => ['nullable', 'string', 'max:100'], + 'valid_days' => ['nullable', 'integer', 'min:1', 'max:180'], + 'workplatform_expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/'], + ], [ + 'courier_name.required' => '请先定好系统快递名称(需与报价名、发货数据里的快递名称一致)。', + 'workplatform_expcode.required' => '请填写快递公司代码(与快递资料「快递公司代码」一致)。', + 'workplatform_expcode.regex' => '快递公司代码只能包含字母、数字、下划线和短横线。', + ]); + $code = strtoupper(trim((string) $data['workplatform_expcode'])); + $taken = CourierOnboarding::query() + ->where('workplatform_expcode', $code) + ->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]) + ->exists(); + if ($taken) { + throw ValidationException::withMessages(['workplatform_expcode' => "快递公司代码「{$code}」已被其他入驻申请使用。"]); + } + + $onboarding = $this->service->invite( + $data['invite_label'] ?? null, + (int) ($data['valid_days'] ?? CourierOnboardingService::DEFAULT_VALID_DAYS), + (int) user_id(), + $data['courier_name'], + ); + $onboarding->update(['workplatform_expcode' => $code]); + + return redirect()->route('business.courier-onboardings.show', $onboarding) + ->with('success', '入驻二维码已生成,发给快递方扫码填写即可。'); + } + + public function show(CourierOnboarding $onboarding): View + { + $this->ensureReady(); + try { + app(\App\Support\CourierQuoteSignatureService::class)->refreshPending(); + } catch (\Throwable $exception) { + report($exception); + } + $onboarding->load(['files', 'events.actor', 'quoteVersion.signature']); + + return view('business.pages.courier-payables.courier-onboarding-show', [ + 'onboarding' => $onboarding, + 'formUrl' => $this->formUrl($onboarding), + 'signatures' => $this->service->agreementSignatures($onboarding), + 'signedAgreement' => $this->service->signedAgreement($onboarding), + 'quoteSignature' => $onboarding->quoteSignature(), + 'esignReady' => $this->service->esignReady(), + 'canFinalReview' => $this->isAdmin() && ( + (int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer') + ), + 'isAdmin' => $this->isAdmin(), + 'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(), + ]); + } + + /** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */ + public function requote(CourierOnboarding $onboarding): View|RedirectResponse + { + $this->ensureReady(); + $quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']); + if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) { + return redirect()->to($quoteManagement)->with('success', '这家快递还没开通,开通后才能变价。'); + } + $requotes = $this->requotes(); + $open = $requotes->openChange($onboarding); + if ($open !== null && $open->status === \App\Models\Business\CourierQuoteChange::STATUS_PENDING_REVIEW) { + return redirect()->route('business.courier-quote-changes.show', $open) + ->with('success', "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。"); + } + + $version = $requotes->latestVersion($onboarding); + $payload = $open?->quote_payload ?? $requotes->currentPayload($onboarding); + if ($open === null) { + // 新变价默认从明天起执行。 + $payload['quote_effective_date'] = today()->addDay()->toDateString(); + } + + return view('onboarding.courier-form', [ + 'onboarding' => $requotes->draftModel($onboarding, $payload), + 'token' => '', + 'provinces' => CourierOnboarding::PROVINCES, + 'fileKinds' => CourierOnboardingFile::KINDS, + 'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS, + 'personKinds' => CourierOnboardingFile::PERSON_KINDS, + 'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS, + 'requote' => [ + 'action' => route('business.courier-onboardings.requote.store', $onboarding), + 'back' => $quoteManagement, + 'courier' => $onboarding->courier_name, + 'signer' => trim(($onboarding->isPerson() ? '' : $onboarding->company_full_name . ' · ') . $onboarding->esign_operator_name . ' ' . $onboarding->esign_account), + 'version' => $version, + 'change' => $open, + 'summary' => old('summary', $open?->summary), + ], + ]); + } + + public function storeRequote(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $this->ensureReady(); + $data = $request->validate([ + ...\App\Http\Controllers\Onboarding\CourierOnboardingForms::quoteRules(), + 'summary' => ['required', 'string', 'min:2', 'max:2000'], + 'quote_end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:quote_effective_date'], + ], [ + 'summary.required' => '请写一下这次变价的原因或依据,审核和对账时要看。', + 'quote_end_date.after_or_equal' => '结束日期不能早于执行日期。', + ]); + $change = $this->requotes()->submit($onboarding, $data, (int) user_id()); + + return redirect()->route('business.courier-quote-changes.show', $change) + ->with('success', "变价单 {$change->number} 已提交审核。审核通过后会自动推送到快递的 e签宝签署。"); + } + + private function requotes(): \App\Support\CourierRequoteService + { + return app(\App\Support\CourierRequoteService::class); + } + + public function qr(Request $request, CourierOnboarding $onboarding): Response + { + $this->ensureReady(); + $svg = QrCode::svg($this->formUrl($onboarding), 8); + $headers = ['Content-Type' => 'image/svg+xml; charset=utf-8', 'Cache-Control' => 'private, no-store']; + if ($request->boolean('download')) { + $headers['Content-Disposition'] = 'attachment; filename="courier-onboarding-' . $onboarding->number . '.svg"'; + } + + return response($svg, 200, $headers); + } + + public function file(CourierOnboarding $onboarding, CourierOnboardingFile $file): StreamedResponse + { + $this->ensureReady(); + abort_unless((int) $file->onboarding_id === (int) $onboarding->id, 404); + abort_unless(Storage::disk('uploads')->exists($file->path), 404, '附件不存在。'); + + return Storage::disk('uploads')->response($file->path, $file->original_name, [ + 'Content-Type' => $file->mime ?: 'application/octet-stream', + ], request()->boolean('download') ? 'attachment' : 'inline'); + } + + public function firstApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $note = $request->validate(['note' => ['nullable', 'string', 'max:1000']])['note'] ?? null; + $this->service->firstApprove($onboarding, (int) user_id(), $note); + + return $this->back($onboarding, '初审通过,已转财务复审。'); + } + + public function returnForChanges(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请写明需要快递方补充或修改的内容。'])['reason']; + $this->service->returnForChanges($onboarding, (int) user_id(), $reason); + + return $this->back($onboarding, '已退回补件,快递方用原二维码即可修改后重新提交。'); + } + + public function reject(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请填写不予合作的原因。'])['reason']; + $this->service->reject($onboarding, (int) user_id(), $reason); + + return $this->back($onboarding, '已标记为不予合作。'); + } + + public function cancel(CourierOnboarding $onboarding): RedirectResponse + { + $this->service->cancel($onboarding, (int) user_id()); + + return $this->back($onboarding, '邀请已作废,原二维码不能再打开。'); + } + + public function extend(CourierOnboarding $onboarding): RedirectResponse + { + $this->service->extend($onboarding, (int) user_id()); + + return $this->back($onboarding, '链接有效期已延长 30 天。'); + } + + public function updateInternal(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $data = $request->validate([ + 'courier_name' => ['nullable', 'string', 'max:100'], + 'reconciler' => ['nullable', 'string', 'max:100'], + ]); + $this->service->updateInternal($onboarding, $data, (int) user_id()); + + return $this->back($onboarding, '内部信息已保存。'); + } + + /** + * 手动补录 ERP 开通信息(钉钉流程同步不到、或名称对不上时用)。 + * 快递资料按快递代码把入驻和工作平台老数据合成一行。 + */ + public function erpOpening(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $data = $request->validate([ + 'workplatform_expcode' => ['nullable', 'string', 'max:64'], + 'erp_account_id' => ['nullable', 'string', 'max:64'], + ]); + // 快递公司代码在发码时已必填;这里留空表示沿用原代码。填了代码或 ERP账户ID 视为已开通,都空则恢复「待 ERP 开通」。 + $code = filled($data['workplatform_expcode'] ?? null) ? strtoupper(trim((string) $data['workplatform_expcode'])) : null; + $accountId = filled($data['erp_account_id'] ?? null) ? trim((string) $data['erp_account_id']) : null; + $opened = $code !== null || $accountId !== null; + $onboarding->update([ + 'workplatform_expcode' => $code ?? $onboarding->workplatform_expcode, + 'erp_account_id' => $accountId, + 'erp_opened_at' => $opened ? ($onboarding->erp_opened_at ?? now()) : null, + 'erp_opened_source' => $opened ? 'manual' : null, + 'updated_by' => (int) user_id(), + ]); + + return $this->back($onboarding, $opened ? 'ERP 开通信息已保存(快递公司代码 ' . $onboarding->workplatform_expcode . ')。' : '已清空 ERP 开通信息,恢复为「待 ERP 开通」。'); + } + + public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $this->ensureAdmin(); + $data = $request->validate([ + 'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'], + 'reconciler' => ['nullable', 'string', 'max:100'], + 'note' => ['nullable', 'string', 'max:1000'], + ], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']); + $this->service->finalApprove($onboarding, $data, (int) user_id()); + + if (! $onboarding->quote_version_id) { + return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。'); + } + if (! $this->service->esignReady()) { + return $this->back($onboarding, '财务复审通过,首版报价已生成。E 签宝尚未配置,报价确认单暂不能推送;线下签署后可直接开通。'); + } + + try { + $this->service->pushQuote($onboarding->fresh(), (int) user_id()); + } catch (ValidationException $exception) { + $message = collect($exception->errors())->flatten()->first() ?: '推送失败'; + CourierOnboardingEvent::create([ + 'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'event_type' => 'quote_push_failed', + 'from_status' => $onboarding->status, 'to_status' => $onboarding->status, 'note' => $message, + 'actor_type' => 'user', 'actor_id' => (int) user_id(), + ]); + + return $this->back($onboarding, '财务复审通过,首版报价已生成,但报价确认单推送失败:' . $message . '。可在右侧重新推送。'); + } + + return $this->back($onboarding, '财务复审通过,报价确认单已推送到快递的 e签宝,等待对方签署。'); + } + + public function pushQuote(CourierOnboarding $onboarding): RedirectResponse + { + $this->ensureAdmin(); + $this->service->pushQuote($onboarding, (int) user_id()); + + return $this->back($onboarding, '报价确认单已推送到快递的 e签宝,等待对方签署。'); + } + + public function syncQuote(CourierOnboarding $onboarding): RedirectResponse + { + $signature = $this->service->syncQuote($onboarding, (int) user_id()); + + return $this->back($onboarding, '已同步 e签宝状态:' . $signature->statusLabel() . '。'); + } + + public function signUrl(CourierOnboarding $onboarding): RedirectResponse + { + $url = $this->service->quoteSignUrl($onboarding); + + return $this->back($onboarding, '已获取签署链接,签署人用手机打开或扫码即可签署。')->with('quote_sign_url', $url); + } + + public function activate(Request $request, CourierOnboarding $onboarding): RedirectResponse + { + $this->ensureAdmin(); + $note = $request->validate(['offline_note' => ['nullable', 'string', 'max:1000']])['offline_note'] ?? null; + $this->service->activate($onboarding, (int) user_id(), $note); + + return $this->back($onboarding, '快递已开通,快递资料「入驻快递」里可以查到。'); + } + + private function formUrl(CourierOnboarding $onboarding): string + { + $base = trim((string) config('courier_onboarding.public_url', '')); + if ($base === '') { + return route('courier-onboarding.form', ['token' => $onboarding->token]); + } + + return rtrim($base, '/') . route('courier-onboarding.form', ['token' => $onboarding->token], false); + } + + private function back(CourierOnboarding $onboarding, string $message): RedirectResponse + { + return redirect()->route('business.courier-onboardings.show', $onboarding)->with('success', $message); + } + + private function ensureReady(): void + { + abort_unless($this->service->ready(), 503, '快递入驻数据表尚未完成迁移,请联系管理员执行数据库迁移。'); + } + + private function isAdmin(): bool + { + return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true); + } + + private function ensureAdmin(): void + { + abort_unless($this->isAdmin(), 403, '财务复审和开通需要管理员账号。'); + } +} diff --git a/app/Http/Controllers/Business/CourierProfiles.php b/app/Http/Controllers/Business/CourierProfiles.php new file mode 100644 index 00000000..0512a993 --- /dev/null +++ b/app/Http/Controllers/Business/CourierProfiles.php @@ -0,0 +1,125 @@ +profiles->currentRow($expcode); + abort_if($row === null, 404); + + $rules = []; + foreach (CourierProfile::EDITABLE as $field => $label) { + $rules[$field] = match (true) { + in_array($field, CourierProfile::BOOLEAN_FIELDS, true) => ['nullable', 'in:0,1'], + in_array($field, CourierProfile::DATE_FIELDS, true) => ['nullable', 'date'], + $field === 'expname' => ['required', 'string', 'max:100'], + default => ['nullable', 'string', 'max:255'], + }; + } + $data = $request->validate($rules, ['expname.required' => '快递公司名称不能为空。'], CourierProfile::EDITABLE); + $count = $this->profiles->update($expcode, $row->expname ?? null, $row, $data, (int) user_id()); + + return redirect($this->pageUrl($expcode, 'logs')) + ->with('success', $count > 0 ? "已保存 {$count} 项修改(财务系统维护,不回写工作平台),修改记录见下方操作日志。" : '没有修改任何字段。'); + } + + /** 打款信息页签里发起:快递公司代码在表单里填。 */ + public function storePaymentAccountForCode(Request $request): RedirectResponse + { + $data = $request->validate( + ['expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/']], + ['expcode.required' => '请填写快递公司代码。', 'expcode.regex' => '快递公司代码只能是字母、数字、下划线或短横线。'], + ); + + return $this->storePaymentAccount($request, trim($data['expcode'])); + } + + public function storePaymentAccount(Request $request, string $expcode): RedirectResponse + { + $row = $this->profiles->currentRow($expcode); + if ($row === null) { + throw \Illuminate\Validation\ValidationException::withMessages(['expcode' => "快递资料里没有快递公司代码「{$expcode}」,请核对。"]); + } + + $change = $request->input('action') === CourierPaymentAccount::ACTION_CHANGE; + $number = ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z\- ]+$/']; + $data = $request->validate([ + 'action' => ['required', 'in:add,change'], + 'account_type' => ['required', 'in:' . implode(',', CourierPaymentAccount::ACCOUNT_TYPES)], + 'account_name' => ['required', 'string', 'max:255'], + 'bank_name' => ['required', 'string', 'max:255'], + 'account_number' => $number, + 'effective_date' => ['required', 'date'], + 'old_account_name' => [$change ? 'required' : 'nullable', 'string', 'max:255'], + 'old_account_number' => $change ? $number : ['nullable', 'string', 'max:64'], + 'old_stop_date' => [$change ? 'required' : 'nullable', 'date'], + 'change_date' => [$change ? 'required' : 'nullable', 'date'], + 'note' => ['nullable', 'string', 'max:500'], + ], [ + 'account_number.regex' => '银行账号只能包含数字、字母和短横线。', + 'old_account_number.regex' => '原账号只能包含数字、字母和短横线。', + ], [ + 'account_type' => '账户类型', 'account_name' => $change ? '新开户名称' : '开户名称', 'bank_name' => $change ? '新开户银行' : '开户银行', + 'account_number' => $change ? '新银行账号' : '银行账号', 'effective_date' => $change ? '新账户启用日期' : '生效日期', + 'old_account_name' => '原开户名称', 'old_account_number' => '原账号', 'old_stop_date' => '原账户停止使用日期', 'change_date' => '账户变更日期', + ]); + + $strip = static fn (?string $value): ?string => $value === null ? null : (string) preg_replace('/\s+/u', '', $value); + $record = CourierPaymentAccount::query()->create([ + 'company_id' => company_id(), + 'expcode' => $expcode, + 'courier_name' => $row->expname ?? null, + 'action' => $data['action'], + 'account_type' => $data['account_type'], + 'account_name' => trim($data['account_name']), + 'bank_name' => trim($data['bank_name']), + 'account_number' => $strip($data['account_number']), + 'effective_date' => $data['effective_date'], + 'old_account_name' => $change ? trim((string) $data['old_account_name']) : null, + 'old_account_number' => $change ? $strip($data['old_account_number']) : null, + 'old_stop_date' => $change ? $data['old_stop_date'] : null, + 'change_date' => $change ? $data['change_date'] : null, + 'note' => $data['note'] ?? null, + 'created_by' => (int) user_id(), + 'updated_by' => (int) user_id(), + ]); + + $summary = $change + ? "打款信息变更:{$record->old_account_name} → {$record->account_name}({$record->account_type})" + : "打款信息新增:{$record->account_name}({$record->account_type},{$record->bank_name})"; + $this->profiles->log($expcode, $change ? 'account_change' : 'account_add', $summary . ',待推送' . ($change ? '收款账户变更' : '收款账户证明') . '协议', null, (int) user_id()); + + return redirect()->route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'agreement-signing', + 'agreement_view' => 'create', + 'payment_account' => $record->id, + ])->with('success', $summary . '已保存。请核对下方预填的协议内容,补齐签署人信息后推送给快递签署;签署完成后这条打款信息显示为「已生效」。'); + } + + private function pageUrl(string $expcode, string $detailTab): string + { + return route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-information', + 'selected' => $expcode, + 'detail_tab' => $detailTab, + ]) . '#courier-detail'; + } +} diff --git a/app/Http/Controllers/Business/CourierQuoteChanges.php b/app/Http/Controllers/Business/CourierQuoteChanges.php index 8467806a..2a5b936e 100644 --- a/app/Http/Controllers/Business/CourierQuoteChanges.php +++ b/app/Http/Controllers/Business/CourierQuoteChanges.php @@ -5,7 +5,9 @@ namespace App\Http\Controllers\Business; use App\Models\Business\CourierQuoteChange; use App\Services\Esign\EsignClient; use App\Support\CourierQuoteChangeService; +use App\Support\CourierOnboardingService; use App\Support\CourierQuoteSignatureService; +use App\Support\CourierRequoteService; use App\Support\QuoteManagementSnapshot; use App\Utilities\SystemAccess; use Illuminate\Http\RedirectResponse; @@ -19,6 +21,7 @@ class CourierQuoteChanges extends Controller private readonly CourierQuoteChangeService $service, private readonly QuoteManagementSnapshot $snapshot, private readonly CourierQuoteSignatureService $signatureService, + private readonly CourierRequoteService $requotes, ) { } @@ -35,7 +38,7 @@ class CourierQuoteChanges extends Controller ]); } - public function create(Request $request): View + public function create(Request $request): View|RedirectResponse { $quotes = $this->snapshot->load()['quotes'] ?? []; abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。'); @@ -43,6 +46,11 @@ class CourierQuoteChanges extends Controller $selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']); $source = $this->service->findSourceQuote($selectedId); + // 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。 + if ($request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) { + return redirect()->route('business.courier-onboardings.requote', $onboarding); + } + return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source)); } @@ -56,20 +64,34 @@ class CourierQuoteChanges extends Controller public function show(CourierQuoteChange $quoteChange): View { + $this->refreshSignatures(); $quoteChange->load(['items', 'events.actor', 'version.signature']); + $onboarding = $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null; + $requoteDiff = $onboarding !== null + ? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload) + : null; return view('business.pages.courier-payables.quote-change-show', [ 'change' => $quoteChange, - 'canReview' => $this->canReview() && $quoteChange->created_by !== user_id(), + 'onboarding' => $onboarding, + 'requoteDiff' => $requoteDiff, + 'reviewWarnings' => $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED + ? $this->requotes->reviewWarnings($quoteChange, $requoteDiff) + : [], + 'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null, + 'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')), 'canManageSignature' => $this->canReview(), 'esignConfigured' => EsignClient::configured(), 'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'), ]); } - public function edit(CourierQuoteChange $quoteChange): View + public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse { abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403); + if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) { + return redirect()->route('business.courier-onboardings.requote', $onboarding); + } $quotes = $this->snapshot->load()['quotes'] ?? []; $source = $this->service->findSourceQuote($quoteChange->source_quote_id); $quoteChange->load('items'); @@ -112,10 +134,48 @@ class CourierQuoteChanges extends Controller $message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。'; } + // 已入驻快递:按入驻资料直接推送到快递的 e签宝。 + if ($change->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($change->carrier_name))) { + try { + app(CourierOnboardingService::class)->pushVersion($onboarding, $change->version, (int) user_id()); + $message .= " 已推送到快递 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account}),等待签署。"; + } catch (\Throwable $exception) { + report($exception); + $reason = $exception instanceof \Illuminate\Validation\ValidationException ? collect($exception->errors())->flatten()->first() : '推送失败'; + $message .= " 推送 e签宝失败:{$reason},可在下方重新推送。"; + } + } + return redirect()->route('business.courier-quote-changes.show', $change) ->with('success', $message); } + /** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */ + public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse + { + abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + $onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name); + abort_if($onboarding === null || $quoteChange->version === null, 404); + app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id()); + + return redirect()->route('business.courier-quote-changes.show', $quoteChange) + ->with('success', "{$quoteChange->version->version_code} 已推送到快递 e签宝,等待签署。"); + } + + /** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */ + public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse + { + abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_if($quoteChange->version === null, 404); + $data = $request->validate( + ['void_reason' => ['required', 'string', 'min:2', 'max:500']], + ['void_reason.required' => '请写明作废原因,例如:江苏面单多打了一位。'], + ); + $message = $this->requotes->voidVersion($quoteChange->version, trim($data['void_reason']), (int) user_id()); + + return redirect()->route('business.courier-quote-changes.show', $quoteChange)->with('success', $message); + } + public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); @@ -242,6 +302,15 @@ class CourierQuoteChanges extends Controller return $name; } + private function refreshSignatures(): void + { + try { + $this->signatureService->refreshPending(); + } catch (\Throwable $exception) { + report($exception); + } + } + private function canReview(): bool { return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true); diff --git a/app/Http/Controllers/Business/CourierQuoteSignatures.php b/app/Http/Controllers/Business/CourierQuoteSignatures.php index bc9c31dc..5dbe776f 100644 --- a/app/Http/Controllers/Business/CourierQuoteSignatures.php +++ b/app/Http/Controllers/Business/CourierQuoteSignatures.php @@ -11,6 +11,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedResponse; class CourierQuoteSignatures extends Controller @@ -86,6 +87,146 @@ class CourierQuoteSignatures extends Controller return \Storage::disk('uploads')->download($path, $fileName, ['Content-Type' => 'application/pdf']); } + /** + * 批量导出已签署的回签单电子档(签署 PDF + 签署完成证书)为 zip。 + * codes 为报价版本号,逗号分隔;不传则导出全部已归档的回签单。 + */ + public function export(Request $request): BinaryFileResponse + { + $codes = array_values(array_filter(array_map('trim', explode(',', (string) $request->query('codes', ''))))); + $signatures = CourierQuoteSignature::query() + ->with('version') + ->where('status', CourierQuoteSignature::STATUS_ARCHIVED) + ->whereNotNull('signed_pdf_path') + ->when($codes !== [], fn ($query) => $query->whereHas('version', fn ($version) => $version->whereIn('version_code', $codes))) + ->orderBy('id') + ->get(); + + abort_if($signatures->isEmpty(), 404, '没有可导出的已签署回签单。'); + + $disk = \Storage::disk('uploads'); + $zipPath = tempnam(sys_get_temp_dir(), 'quote-signed-'); + $zip = new \ZipArchive(); + abort_unless($zip->open($zipPath, \ZipArchive::OVERWRITE) === true, 500, '生成压缩包失败。'); + $added = 0; + foreach ($signatures as $signature) { + $version = $signature->version; + $base = $this->safeName(($version->company_name ?: $version->carrier_name) . '-' . $version->version_code); + if ($disk->exists($signature->signed_pdf_path)) { + $zip->addFromString("{$base}-回签单.pdf", $disk->get($signature->signed_pdf_path)); + $added++; + } + if ($signature->certificate_path && $disk->exists($signature->certificate_path)) { + $zip->addFromString("{$base}-签署完成证书.pdf", $disk->get($signature->certificate_path)); + } + } + $zip->close(); + abort_if($added === 0, 404, '回签单归档文件不存在。'); + + $name = $signatures->count() === 1 + ? $this->safeName($signatures->first()->version->version_code) . '-回签单电子档.zip' + : '快递报价回签单-' . now()->format('Ymd-His') . '.zip'; + + return response()->download($zipPath, $name, ['Content-Type' => 'application/zip'])->deleteFileAfterSend(); + } + + /** + * 导出报价电子表格(xlsx,WPS / Excel 都能打开):只保留「省份」表头和各省价格, + * 不带标题、执行时间、计费说明和备注等文字行。 + */ + public function sheet(CourierQuoteVersion $quoteVersion): StreamedResponse + { + $values = json_decode((string) $quoteVersion->values_json, true); + $table = self::quoteTable(is_array($values) ? $values : []); + abort_if($table === [], 404, '这份报价没有可导出的价格表。'); + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle(mb_substr($this->safeName($quoteVersion->version_code), 0, 31)); + foreach ($table as $rowIndex => $row) { + foreach ($row as $columnIndex => $value) { + $cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1) . ($rowIndex + 1); + if (is_int($value) || is_float($value)) { + $sheet->setCellValue($cell, $value); + } elseif ($value !== null && $value !== '') { + $sheet->setCellValueExplicit($cell, (string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); + } + } + } + + $lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($table[0])); + $range = 'A1:' . $lastColumn . count($table); + $sheet->getStyle($range)->getBorders()->getAllBorders()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN); + $sheet->getStyle($range)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER) + ->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER)->setWrapText(true); + $header = $sheet->getStyle('A1:' . $lastColumn . '1'); + $header->getFont()->setBold(true); + $header->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('F3EAD7'); + $sheet->getColumnDimension('A')->setWidth(12); + for ($i = 2; $i <= count($table[0]); $i++) { + $sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setWidth(16); + } + $sheet->freezePane('B2'); + + $fileName = $this->safeName(($quoteVersion->company_name ?: $quoteVersion->carrier_name) . '-' . $quoteVersion->version_code) . '-报价.xlsx'; + + return response()->streamDownload(function () use ($spreadsheet): void { + (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output'); + }, $fileName, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']); + } + + /** + * 从报价矩阵里截出价格表:从「省份」表头行开始,到第一行只有一格文字(说明、备注)为止。 + * + * @param array $values + * @return list> + */ + public static function quoteTable(array $values): array + { + $rows = array_values(array_filter($values, 'is_array')); + $filled = static fn (array $row): int => count(array_filter($row, static fn ($cell): bool => $cell !== null && trim((string) $cell) !== '')); + $start = null; + foreach ($rows as $index => $row) { + $first = trim((string) ($row[0] ?? '')); + if (preg_match('/^(省份|省市|区域|地区|目的地|目的省份)/u', $first) === 1 && $filled($row) >= 2) { + $start = $index; + break; + } + } + if ($start === null) { + foreach ($rows as $index => $row) { + if ($filled($row) >= 2) { + $start = $index; + break; + } + } + } + if ($start === null) { + return []; + } + + $table = []; + for ($i = $start; $i < count($rows) && $filled($rows[$i]) >= 2; $i++) { + $table[] = array_values($rows[$i]); + } + + $width = 0; + foreach ($table as $row) { + foreach ($row as $column => $cell) { + if ($cell !== null && trim((string) $cell) !== '') { + $width = max($width, $column + 1); + } + } + } + + return array_map(static fn (array $row): array => array_pad(array_slice($row, 0, $width), $width, null), $table); + } + + private function safeName(string $name): string + { + return trim(preg_replace('~[\\\\/:*?"<>|\s]+~u', '_', $name), '_') ?: 'quote'; + } + private function validateAccountForNotice(string $account, string $noticeType): void { $valid = $noticeType === '1' diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index b33bbf21..721ccc2e 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -2749,12 +2749,30 @@ class Pages extends Controller } try { - return collect($this->courierInformation->names()); + $names = collect((array) $this->courierInformation->names()); } catch (Throwable $exception) { // 快递镜像不可用时仍允许打开签署页,并明确展示暂无可选资料。 report($exception); + $names = collect(); + } + + // 财务系统入驻的快递也能直接选(不必等进工作平台)。 + return $names->merge($this->onboardedCourierSigning()->keys())->filter()->unique()->values(); + } + + /** @return Collection 已入驻快递:系统快递名称 => E 签宝账号 */ + private function onboardedCourierSigning(): Collection + { + if (! \Illuminate\Support\Facades\Schema::hasTable('business_courier_onboardings')) { return collect(); } + + return \App\Models\Business\CourierOnboarding::query() + ->whereIn('status', [\App\Models\Business\CourierOnboarding::STATUS_APPROVED, \App\Models\Business\CourierOnboarding::STATUS_ACTIVATED]) + ->whereNotNull('courier_name') + ->latest('id') + ->get(['courier_name', 'esign_account']) + ->mapWithKeys(static fn ($onboarding): array => [(string) $onboarding->courier_name => (string) $onboarding->esign_account]); } protected function agreementSupplierOptions(?string $page): array @@ -2776,14 +2794,21 @@ class Pages extends Controller } try { - return $this->courierInformation->signingAccountsByName( + $accounts = (array) $this->courierInformation->signingAccountsByName( ($courierNames ?? $this->agreementCourierNames($page))->values()->all(), ); } catch (Throwable $exception) { // 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。 report($exception); - return []; + $accounts = []; } + foreach ($this->onboardedCourierSigning() as $name => $account) { + if ($account !== '' && blank($accounts[$name] ?? null)) { + $accounts[$name] = $account; + } + } + + return $accounts; } protected function agreementTasks(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator @@ -2989,6 +3014,7 @@ class Pages extends Controller 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), 'courier-information' => $this->contentForCourierInformation(), + 'courier-onboarding' => app(\App\Business\CourierOnboardingPageData::class)->page(), 'freight-reconciliation' => $this->contentForFreightReconciliation(), 'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(), 'warehouse-freight' => ['view' => 'business.pages.courier-payables.warehouse-freight'], @@ -6463,11 +6489,12 @@ class Pages extends Controller public function exportCourierInformation(Request $request): StreamedResponse { - abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503); - + $platformReady = $this->courierInformation->available() && $this->courierInformation->ready(); $filters = $this->courierInformationFilters($request); + $profiles = app(\App\Business\CourierProfileData::class); + $overview = app(\App\Business\CourierOverviewData::class); - return response()->streamDownload(function () use ($filters): void { + return response()->streamDownload(function () use ($filters, $platformReady, $profiles, $overview): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建快递资料导出流。'); @@ -6479,39 +6506,58 @@ class Pages extends Controller '是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号', '停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3', '体积0寻号', '手工单号', '解密', '电子签章经办人', '最后修改', + '入驻单号', '入驻状态', '入驻主体', '财务系统已修改字段', ]); $bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否'; $date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10); - foreach ($this->courierInformation->exportRows($filters) as $row) { + // 财务系统入驻(还没有工作平台记录)的快递排最前面;所有行都用财务系统编辑后的值。 + $extra = array_map([$profiles, 'onboardingRow'], $overview->expressAppend([], 1, $filters['expname'] ?? '', $filters['expcode'] ?? '')['extra']); + $platformRows = $platformReady ? $this->courierInformation->exportRows($filters) : []; + $overrides = null; + $rowsToWrite = (function () use ($extra, $platformRows) { + yield from $extra; + yield from $platformRows; + })(); + foreach ($rowsToWrite as $row) { + $overrides ??= \Illuminate\Support\Facades\Schema::hasTable('business_courier_profiles') + ? \App\Models\Business\CourierProfile::query()->get()->mapWithKeys(static fn ($profile): array => [(string) $profile->expcode => (array) $profile->overrides])->all() + : []; + $row = $profiles->apply($row, $overrides[(string) ($row->expcode ?? '')] ?? []); + $info = $row->_info ?? $overview->infoFor((string) ($row->expcode ?? ''), $row->expname ?? null); + $onboarding = $info['onboarding'] ?? null; fputcsv($csv, [ - $row->Id, - $this->csvText($row->expcode), - $this->csvText($row->expname), - $this->csvText($row->outcode), - $this->csvText($row->scexpcode), - $this->csvText($row->paymethod), - $bool($row->status), - $bool($row->addvalidsgn), - $bool($row->kuaiyunsgn), - $bool($row->autonosgn), - $bool($row->packnosgn), - $bool($row->skucountsplitsgn), - $bool($row->weight0sgn), - $date($row->stopdate), - $date($row->tradebtime), - $date($row->tradeetime), - $this->csvText($row->linker), - $this->csvText($row->linkmobile), - $this->csvText($row->undefined1), - $this->csvText($row->undefined2), - $this->csvText($row->undefined3), - $bool($row->volume0sgn), - $bool($row->handnosgn), - $bool($row->decryptsgn), - $this->csvText($row->dzqzjbr), - $date($row->modified), + $row->Id ?? '', + $this->csvText($row->expcode ?? null), + $this->csvText($row->expname ?? null), + $this->csvText($row->outcode ?? null), + $this->csvText($row->scexpcode ?? null), + $this->csvText($row->paymethod ?? null), + $bool($row->status ?? null), + $bool($row->addvalidsgn ?? null), + $bool($row->kuaiyunsgn ?? null), + $bool($row->autonosgn ?? null), + $bool($row->packnosgn ?? null), + $bool($row->skucountsplitsgn ?? null), + $bool($row->weight0sgn ?? null), + $date($row->stopdate ?? null), + $date($row->tradebtime ?? null), + $date($row->tradeetime ?? null), + $this->csvText($row->linker ?? null), + $this->csvText($row->linkmobile ?? null), + $this->csvText($row->undefined1 ?? null), + $this->csvText($row->undefined2 ?? null), + $this->csvText($row->undefined3 ?? null), + $bool($row->volume0sgn ?? null), + $bool($row->handnosgn ?? null), + $bool($row->decryptsgn ?? null), + $this->csvText($row->dzqzjbr ?? null), + $date($row->modified ?? null), + $this->csvText($onboarding?->number), + $this->csvText($onboarding?->statusLabel()), + $this->csvText($onboarding ? $onboarding->entityLabel() . ' · ' . $onboarding->company_full_name : null), + $this->csvText(implode('、', array_map(static fn (string $field): string => \App\Models\Business\CourierProfile::EDITABLE[$field], $row->_overridden ?? []))), ]); } @@ -6682,6 +6728,7 @@ class Pages extends Controller protected function contentForCourierInformation(): array { $filters = $this->courierInformationFilters(request()); + // 快递资料四类:快递资料(主信息)/ 网点 / 打款信息 / 操作日志;点开主信息在下方看这家快递的明细。 $tab = (string) request()->query('courier_tab', 'express'); $tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express'; $page = max((int) request()->query('page', 1), 1); @@ -6695,8 +6742,15 @@ class Pages extends Controller $state = null; $syncStatus = ['code' => 'unavailable', 'lag_minutes' => null]; $relatedAvailability = $this->courierInformation->relatedAvailability(); + $profiles = app(\App\Business\CourierProfileData::class); + $overview = app(\App\Business\CourierOverviewData::class); try { + // 网点 / 打款信息 / 操作日志页签也显示快递资料主表的同步状态,避免误显示「镜像未建立」。 + if ($tab !== 'express' && $this->courierInformation->available()) { + $state = $this->courierInformation->state(); + $syncStatus = $this->courierInformation->syncStatus($state); + } if ($tab === 'express') { if (! $this->courierInformation->available()) { $error = '共享业务镜像尚未建立,快递资料暂时无法读取。'; @@ -6723,20 +6777,20 @@ class Pages extends Controller } } elseif ($tab === 'accounts') { if (! ($relatedAvailability['accounts'] ?? false)) { - $error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。'; + $error = '工作平台打款账户镜像尚未建立,下面只显示财务系统里的打款信息。'; } elseif (! ($relatedAvailability['account_ready'] ?? false)) { $pending = true; - $error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; + $error = '工作平台打款账户正在执行首次全量同步,下面先显示财务系统里的打款信息。'; } else { $result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit); $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); } } else { if (! ($relatedAvailability['logs'] ?? false)) { - $error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。'; + $error = '工作平台操作日志镜像尚未建立,下面只显示财务系统里的操作日志。'; } elseif (! ($relatedAvailability['log_ready'] ?? false)) { $pending = true; - $error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; + $error = '工作平台操作日志正在执行首次全量同步,下面先显示财务系统里的操作日志。'; } else { $result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit); $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); @@ -6747,11 +6801,47 @@ class Pages extends Controller $error = '快递资料共享镜像暂时无法读取,请稍后重试。'; } + // 主信息:财务系统入驻的快递排在第一页最前面,工作平台的行右侧接上入驻信息;编辑过的字段用财务系统的值。 + $displayRows = []; + $financeAccounts = []; + $financeEvents = collect(); + try { + if ($tab === 'express') { + $append = $overview->expressAppend( + $rows->items(), + $page, + $filters['expname'] ?? '', + $filters['expcode'] ?? '', + ); + $platformRows = array_map(static function (object $row) use ($append): object { + $row = clone $row; + $row->_info = $append['matches'][(string) ($row->Id ?? $row->expcode)] ?? null; + + return $row; + }, $rows->items()); + $displayRows = array_merge(array_map([$profiles, 'onboardingRow'], $append['extra']), $platformRows); + $overrides = $profiles->overrides(array_map(static fn (object $row): string => (string) ($row->expcode ?? ''), $displayRows)); + $displayRows = array_map(static fn (object $row): object => $profiles->apply($row, $overrides[(string) ($row->expcode ?? '')] ?? []), $displayRows); + } elseif ($tab === 'accounts' && $page === 1) { + $financeAccounts = $profiles->financeAccounts($filters['expcode'] ?: null); + } elseif ($tab === 'logs' && $page === 1) { + $financeEvents = $profiles->events($filters['expcode'] ?: null); + } + } catch (Throwable $exception) { + report($exception); + } + + $detail = $tab === 'express' ? $this->courierInformationDetail($displayRows, $relatedAvailability) : null; + return [ 'view' => 'business.pages.courier-payables.courier-information', 'courierTab' => $tab, 'courierFilters' => $filters, 'courierRows' => $rows, + 'courierDisplayRows' => $displayRows, + 'courierDetail' => $detail, + 'courierFinanceAccounts' => $financeAccounts, + 'courierFinanceEvents' => $financeEvents, 'courierState' => $state, 'courierSyncStatus' => $syncStatus, 'courierError' => $error, @@ -6760,6 +6850,53 @@ class Pages extends Controller ]; } + /** 选中一行主信息(?selected=快递公司代码)后,下方显示这家快递的网点 / 打款信息 / 操作日志。 */ + private function courierInformationDetail(array $displayRows, array $availability): ?array + { + $code = mb_substr(trim((string) request()->query('selected', '')), 0, 64); + if ($code === '') { + return null; + } + + $profiles = app(\App\Business\CourierProfileData::class); + $overview = app(\App\Business\CourierOverviewData::class); + $row = collect($displayRows)->first(static fn (object $item): bool => strcasecmp((string) ($item->expcode ?? ''), $code) === 0); + $row ??= $profiles->currentRow($code); + if ($row === null) { + return ['code' => $code, 'row' => null]; + } + + $info = $row->_info ?? $overview->infoFor($code, $row->expname ?? null); + $onboarding = $info['onboarding'] ?? null; + $networks = $platformAccounts = $platformLogs = []; + try { + if (($availability['network_ready'] ?? false)) { + $networks = $this->courierInformation->paginateNetworks($code, 1, 100)['data'] ?? []; + } + if (($availability['account_ready'] ?? false)) { + $platformAccounts = $this->courierInformation->paginateAccounts($code, 1, 100)['data'] ?? []; + } + if (($availability['log_ready'] ?? false)) { + $platformLogs = $this->courierInformation->paginateLogs($code, 1, 100)['data'] ?? []; + } + } catch (Throwable $exception) { + report($exception); + } + + return [ + 'code' => $code, + 'row' => $row, + 'onboarding' => $onboarding, + 'networks' => $networks, + 'platform_accounts' => $platformAccounts, + 'finance_accounts' => $profiles->financeAccounts($code, $onboarding), + 'platform_logs' => $platformLogs, + 'events' => $profiles->events($code), + 'edit' => request()->boolean('edit'), + 'detail_tab' => in_array(request()->query('detail_tab'), ['networks', 'accounts', 'logs'], true) ? request()->query('detail_tab') : 'accounts', + ]; + } + /** @return array */ private function courierInformationFilters(Request $request): array { diff --git a/app/Http/Controllers/Onboarding/CourierOnboardingForms.php b/app/Http/Controllers/Onboarding/CourierOnboardingForms.php new file mode 100644 index 00000000..51b62427 --- /dev/null +++ b/app/Http/Controllers/Onboarding/CourierOnboardingForms.php @@ -0,0 +1,180 @@ +resolve($token); + if ($onboarding === null) { + return $this->unavailable(); + } + $onboarding->load('files'); + + return view('onboarding.courier-form', [ + 'onboarding' => $onboarding, + 'token' => $token, + 'provinces' => CourierOnboarding::PROVINCES, + 'fileKinds' => CourierOnboardingFile::KINDS, + 'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS, + 'personKinds' => CourierOnboardingFile::PERSON_KINDS, + 'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS, + ]); + } + + public function update(Request $request, string $token): RedirectResponse|Response + { + $onboarding = $this->resolve($token); + if ($onboarding === null) { + return $this->unavailable(); + } + $data = $request->validate($this->rules(), $this->messages()); + $files = []; + foreach ((array) $request->file('files', []) as $kind => $uploads) { + $files[(string) $kind] = array_values(array_filter(is_array($uploads) ? $uploads : [$uploads])); + } + + $submit = ($data['action'] ?? 'save') === 'submit'; + $this->service->saveCourierForm($onboarding, $data, $files, $submit); + + return redirect() + ->route('courier-onboarding.form', ['token' => $token]) + ->with('onboarding_success', $submit ? '资料已提交,我们会尽快审核,审核结果请留意对接人通知。' : '资料已暂存,可随时扫码回来继续填写。'); + } + + private function resolve(string $token): ?CourierOnboarding + { + $onboarding = $this->service->ready() ? $this->service->findByToken($token) : null; + if ($onboarding === null || $onboarding->status === CourierOnboarding::STATUS_CANCELLED) { + return null; + } + + // 免登录访问没有当前公司上下文;切到申请所属公司,后续按公司隔离的查询才能命中。 + Company::find($onboarding->company_id)?->makeCurrent(); + + return $onboarding; + } + + /** + * 免登录页不走系统的 404 跳转(会跳到登录页),直接给快递方一个能看懂的提示。 + */ + private function unavailable(): Response + { + return response()->view('onboarding.courier-unavailable', [], 404); + } + + /** + * @return array + */ + private function rules(): array + { + $text = ['nullable', 'string', 'max:255']; + $phone = ['nullable', 'string', 'regex:/^[0-9+\-\s()]{5,20}$/']; + $money = ['nullable', 'numeric', 'min:0', 'max:100000']; + + return [ + 'remark' => ['nullable', 'string', 'max:2000'], + 'action' => ['required', Rule::in(['save', 'submit'])], + 'entity_type' => ['nullable', Rule::in(array_keys(CourierOnboarding::ENTITY_TYPES))], + 'id_number' => ['nullable', 'string', 'regex:/^\s*\d{17}[\dXx]\s*$/'], + 'company_full_name' => $text, + 'unified_social_credit_code' => ['nullable', 'string', 'regex:/^[0-9A-Za-z]{18}$/'], + 'legal_representative' => ['nullable', 'string', 'max:100'], + 'business_address' => $text, + 'carrier_brand' => ['nullable', 'string', 'max:50'], + 'network_name' => ['nullable', 'string', 'max:100'], + 'service_type' => ['nullable', Rule::in(CourierOnboarding::SERVICE_TYPES)], + 'contact_name' => ['nullable', 'string', 'max:100'], + 'contact_phone' => $phone, + 'finance_contact_name' => ['nullable', 'string', 'max:100'], + 'finance_contact_phone' => $phone, + 'pickup_warehouses' => ['nullable', 'string', 'max:1000'], + 'esign_operator_name' => ['nullable', 'string', 'max:100'], + 'esign_account' => ['nullable', 'string', 'max:255'], + 'bank_account_name' => $text, + 'bank_name' => $text, + 'bank_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'], + 'private_account_name' => $text, + 'private_bank_name' => $text, + 'private_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'], + 'invoice_type' => ['nullable', Rule::in(CourierOnboarding::INVOICE_TYPES)], + 'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:20'], + 'settlement_cycle' => ['nullable', Rule::in(CourierOnboarding::SETTLEMENT_CYCLES)], + ...self::quoteRules(), + 'remove_files' => ['nullable', 'array'], + 'remove_files.*' => ['integer'], + 'files' => ['nullable', 'array'], + 'files.*' => ['array', 'max:' . CourierOnboardingService::MAX_FILES_PER_KIND], + 'files.*.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,heic,heif,pdf,xls,xlsx'], + ]; + } + + /** + * 报价部分的校验规则,后台变价页共用。 + * + * @return array + */ + public static function quoteRules(): array + { + $money = ['nullable', 'numeric', 'min:0', 'max:100000']; + + return [ + 'quote_mode' => ['nullable', Rule::in(array_keys(CourierOnboarding::QUOTE_MODES))], + 'volumetric_rule' => ['nullable', Rule::in(CourierOnboarding::VOLUMETRIC_RULES)], + 'volumetric_divisor' => ['nullable', 'integer', 'min:1000', 'max:20000'], + 'quote_effective_date' => ['nullable', 'date'], + 'quote_note' => ['nullable', 'string', 'max:2000'], + 'quote_rows' => ['nullable', 'array', 'max:80'], + 'quote_rows.*.region' => ['nullable', 'string', 'max:50'], + 'quote_rows.*.flat' => ['nullable', 'array', 'max:' . \App\Support\CourierQuoteStructure::MAX_BANDS], + 'quote_rows.*.flat.*' => $money, + 'quote_rows.*.base' => $money, + 'quote_rows.*.rates' => ['nullable', 'array', 'max:' . (\App\Support\CourierQuoteStructure::MAX_TIERS + 1)], + 'quote_rows.*.rates.*' => $money, + 'quote_flat_bands' => ['nullable', 'string', 'max:200'], + 'quote_first_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'], + 'quote_rate_tiers' => ['nullable', 'string', 'max:200'], + 'quote_then_mode' => ['nullable', Rule::in(['waybill_rate', 'first_additional'])], + 'min_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'], + 'weight_rounding' => ['nullable', Rule::in(array_keys(\App\Support\CourierQuoteStructure::ROUNDING))], + 'per_ticket_surcharge' => ['nullable', 'numeric', 'min:0', 'max:1000'], + ]; + } + + /** + * @return array + */ + private function messages(): array + { + return [ + 'unified_social_credit_code.regex' => '统一社会信用代码应为 18 位数字或字母。', + 'contact_phone.regex' => '请填写正确的联系电话。', + 'id_number.regex' => '身份证号应为 18 位。', + 'finance_contact_phone.regex' => '请填写正确的联系电话。', + 'bank_account_number.regex' => '银行账号只能包含数字。', + 'private_account_number.regex' => '银行卡号只能包含数字。', + 'volumetric_divisor.min' => '抛比系数一般为 5000、6000、8000 等。', + 'files.*.*.max' => '单个文件不能超过 10MB。', + 'files.*.*.mimes' => '请上传照片、PDF 或 Excel 文件。', + ]; + } +} diff --git a/app/Models/Business/CourierOnboarding.php b/app/Models/Business/CourierOnboarding.php new file mode 100644 index 00000000..311ac7b0 --- /dev/null +++ b/app/Models/Business/CourierOnboarding.php @@ -0,0 +1,283 @@ + '待快递填写', + self::STATUS_SUBMITTED => '待初审', + self::STATUS_RETURNED => '已退回补件', + self::STATUS_FIRST_APPROVED => '待财务复审', + self::STATUS_APPROVED => '待签署开通', + self::STATUS_ACTIVATED => '已开通', + self::STATUS_REJECTED => '不予合作', + self::STATUS_CANCELLED => '已作废', + ]; + + /** 快递方可以继续编辑资料的状态。 */ + public const COURIER_EDITABLE = [self::STATUS_INVITED, self::STATUS_RETURNED]; + + public const ENTITY_COMPANY = 'company'; + public const ENTITY_PERSON = 'person'; + public const ENTITY_TYPES = [ + self::ENTITY_COMPANY => '公司 / 个体户', + self::ENTITY_PERSON => '个人', + ]; + + public const SERVICE_TYPES = ['快递', '快运', '大件专线', '其他']; + public const INVOICE_TYPES = ['增值税专用发票', '增值税普通发票', '不开票']; + public const SETTLEMENT_CYCLES = ['月结', '半月结', '周结', '其他']; + public const QUOTE_MODES = \App\Support\CourierQuoteStructure::MODES; + public const VOLUMETRIC_RULES = ['不计泡', '计泡']; + + public const PROVINCES = [ + '江苏', '浙江', '上海', '安徽', '北京', '天津', '河北', '山西', '山东', '河南', + '湖北', '湖南', '江西', '福建', '广东', '广西', '海南', '重庆', '四川', '贵州', + '云南', '陕西', '甘肃', '宁夏', '青海', '辽宁', '吉林', '黑龙江', '内蒙古', '新疆', '西藏', + ]; + + protected $table = 'business_courier_onboardings'; + + protected $fillable = [ + 'company_id', 'number', 'token', 'status', 'invite_label', 'expires_at', + 'entity_type', 'id_number', 'company_full_name', 'unified_social_credit_code', 'legal_representative', 'business_address', + 'carrier_brand', 'network_name', 'service_type', 'contact_name', 'contact_phone', + 'finance_contact_name', 'finance_contact_phone', 'pickup_warehouses', 'esign_operator_name', 'esign_account', + 'bank_account_name', 'bank_name', 'bank_account_number', 'invoice_type', 'tax_rate', 'settlement_cycle', + 'private_account_name', 'private_bank_name', 'private_account_number', + 'workplatform_expcode', 'erp_account_id', 'erp_opened_at', 'erp_opened_source', 'dingtalk_instance_id', + 'quote_mode', 'volumetric_rule', 'volumetric_divisor', 'quote_effective_date', 'quote_structure', 'quote_rows', + 'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'remark', + 'courier_name', 'reconciler', 'return_reason', 'submitted_at', + 'first_reviewed_by', 'first_reviewed_at', 'final_reviewed_by', 'final_reviewed_at', + 'quote_change_id', 'quote_version_id', 'activated_by', 'activated_at', 'created_by', 'updated_by', + ]; + + protected $casts = [ + 'company_id' => 'integer', + 'expires_at' => 'datetime', + 'submitted_at' => 'datetime', + 'first_reviewed_at' => 'datetime', + 'final_reviewed_at' => 'datetime', + 'activated_at' => 'datetime', + 'quote_effective_date' => 'date', + 'quote_rows' => 'array', + 'quote_structure' => 'array', + 'min_weight' => 'float', + 'per_ticket_surcharge' => 'float', + 'bank_account_number' => 'encrypted', + 'private_account_number' => 'encrypted', + 'erp_opened_at' => 'datetime', + 'id_number' => 'encrypted', + 'tax_rate' => 'float', + 'volumetric_divisor' => 'integer', + 'first_reviewed_by' => 'integer', + 'final_reviewed_by' => 'integer', + 'activated_by' => 'integer', + 'created_by' => 'integer', + 'deleted_at' => 'datetime', + ]; + + protected $hidden = ['token', 'bank_account_number', 'private_account_number', 'id_number']; + + public function files(): HasMany + { + return $this->hasMany(CourierOnboardingFile::class, 'onboarding_id')->orderBy('id'); + } + + public function events(): HasMany + { + return $this->hasMany(CourierOnboardingEvent::class, 'onboarding_id')->orderBy('id'); + } + + public function quoteVersion(): BelongsTo + { + return $this->belongsTo(CourierQuoteVersion::class, 'quote_version_id'); + } + + public function isPerson(): bool + { + return $this->entity_type === self::ENTITY_PERSON; + } + + public function entityLabel(): string + { + return $this->isPerson() ? '个人' : '公司'; + } + + /** 主体名称:公司为公司全称,个人为姓名。 */ + public function subjectLabel(): string + { + return $this->isPerson() ? '姓名' : '公司全称'; + } + + public function maskedIdNumber(): string + { + $value = (string) ($this->id_number ?? ''); + + return strlen($value) < 8 ? $value : substr($value, 0, 3) . str_repeat('*', strlen($value) - 7) . substr($value, -4); + } + + public function statusLabel(): string + { + return self::STATUS_LABELS[$this->status] ?? $this->status; + } + + public function courierCanEdit(): bool + { + return in_array($this->status, self::COURIER_EDITABLE, true) && ! $this->linkExpired(); + } + + public function linkExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function displayName(): string + { + foreach ([$this->courier_name, $this->network_name, $this->company_full_name, $this->invite_label] as $name) { + if (filled($name)) { + return (string) $name; + } + } + + return $this->number; + } + + /** e签宝通知方式:1 短信(手机号账号),2 邮件(邮箱账号)。 */ + public function esignNoticeType(): string + { + return str_contains((string) $this->esign_account, '@') ? '2' : '1'; + } + + public function quoteSignature(): ?CourierQuoteSignature + { + return $this->quoteVersion?->signature; + } + + public function quoteModeLabel(): string + { + return \App\Support\CourierQuoteStructure::MODES[$this->quote_mode] ?? ''; + } + + /** + * @return array{mode:string,flat_bands:list,then_mode:string,first_weight:?float,rate_tiers:list} + */ + public function quoteStructure(): array + { + $structure = is_array($this->quote_structure) ? $this->quote_structure : []; + + return [ + 'mode' => (string) ($structure['mode'] ?? $this->quote_mode ?? 'waybill_rate'), + 'flat_bands' => array_values($structure['flat_bands'] ?? []), + 'then_mode' => (string) ($structure['then_mode'] ?? ''), + 'first_weight' => isset($structure['first_weight']) ? (float) $structure['first_weight'] : null, + 'rate_tiers' => array_values($structure['rate_tiers'] ?? []), + ]; + } + + /** @return list */ + public function quoteColumns(): array + { + return \App\Support\CourierQuoteStructure::columns($this->quoteStructure()); + } + + /** 附加计费规则的文字(计泡、起算重量、取整、每票加收)。 */ + public function quoteRuleLines(): array + { + $kg = static fn ($value): string => \App\Support\CourierQuoteStructure::kg((float) $value); + + return array_values(array_filter([ + '计费方式:' . \App\Support\CourierQuoteStructure::describe($this->quoteStructure()), + $this->volumetric_rule === '计泡' ? '计泡:长*宽*高/' . ($this->volumetric_divisor ?: 6000) . ',取实重与体积重较大值' : ($this->volumetric_rule ? '不计泡' : null), + $this->min_weight ? '起算重量:不足' . $kg($this->min_weight) . 'kg按' . $kg($this->min_weight) . 'kg计' : null, + $this->weight_rounding && $this->weight_rounding !== 'none' ? '重量取整:' . (\App\Support\CourierQuoteStructure::ROUNDING[$this->weight_rounding] ?? $this->weight_rounding) : null, + $this->per_ticket_surcharge ? '每票加收:' . $kg($this->per_ticket_surcharge) . '元' : null, + ])); + } + + /** ERP 开通信息来源:钉钉「新建快递」流程同步 / 手动补录 / 工作平台老数据。 */ + public const ERP_SOURCES = [ + 'dingtalk' => '钉钉流程同步', + 'manual' => '手动补录', + 'workplatform' => '工作平台', + ]; + + /** 开通后等 ERP 专员在钉钉发起「新建快递」流程、开通发货系统账号。 */ + public function erpPending(): bool + { + return $this->status === self::STATUS_ACTIVATED && $this->erp_opened_at === null; + } + + public function erpSourceLabel(): string + { + return self::ERP_SOURCES[$this->erp_opened_source] ?? ''; + } + + /** 户名比对:去掉空格,全角括号转半角,英文统一大写。 */ + public static function normalizeName(?string $name): string + { + $name = str_replace(['(', ')', ' '], ['(', ')', ''], (string) $name); + + return mb_strtoupper((string) preg_replace('/\s+/u', '', $name)); + } + + /** 对私收款人应为谁:公司入驻是法定代表人,个人入驻是本人。 */ + public function expectedPrivatePayee(): ?string + { + return $this->isPerson() ? $this->company_full_name : $this->legal_representative; + } + + /** + * 打款账户核对结果:对公户名 = 营业执照公司全称,对私收款人 = 法定代表人(个人为本人)。 + * + * @return array{corporate:?bool,private:?bool} + */ + public function accountChecks(): array + { + $same = static fn (?string $a, ?string $b): ?bool => blank($a) || blank($b) ? null : self::normalizeName($a) === self::normalizeName($b); + + return [ + 'corporate' => $this->isPerson() ? null : $same($this->bank_account_name, $this->company_full_name), + 'private' => $same($this->private_account_name, $this->expectedPrivatePayee()), + ]; + } + + public function maskedBankAccount(): string + { + $value = (string) ($this->bank_account_number ?? ''); + if (mb_strlen($value) <= 8) { + return $value; + } + + return mb_substr($value, 0, 4) . str_repeat('*', max(mb_strlen($value) - 8, 4)) . mb_substr($value, -4); + } + + /** + * @return list,base:?float,rates:list}> + */ + public function quoteRowList(): array + { + return array_values(array_filter( + is_array($this->quote_rows) ? $this->quote_rows : [], + static fn ($row): bool => is_array($row) && filled($row['region'] ?? null), + )); + } +} diff --git a/app/Models/Business/CourierOnboardingEvent.php b/app/Models/Business/CourierOnboardingEvent.php new file mode 100644 index 00000000..7d8f9862 --- /dev/null +++ b/app/Models/Business/CourierOnboardingEvent.php @@ -0,0 +1,54 @@ + 'integer', + 'onboarding_id' => 'integer', + 'actor_id' => 'integer', + 'deleted_at' => 'datetime', + ]; + + public function actor(): BelongsTo + { + return $this->belongsTo(user_model_class(), 'actor_id'); + } + + public function eventLabel(): string + { + return match ($this->event_type) { + 'invited' => '生成入驻二维码', + 'saved' => '快递方保存资料', + 'submitted' => '快递方提交审核', + 'first_approved' => '初审通过', + 'returned' => '退回补件', + 'final_approved' => '财务复审通过', + 'activated' => '确认开通', + 'rejected' => '不予合作', + 'cancelled' => '作废邀请', + 'extended' => '延长链接有效期', + 'internal_updated' => '更新内部信息', + 'quote_pushed' => '推送报价确认单到 e签宝', + 'quote_push_failed' => '报价确认单推送失败', + default => $this->event_type, + }; + } + + public function actorLabel(): string + { + if ($this->actor_type === 'courier') { + return '快递方'; + } + + return (string) ($this->actor?->name ?? '系统'); + } +} diff --git a/app/Models/Business/CourierOnboardingFile.php b/app/Models/Business/CourierOnboardingFile.php new file mode 100644 index 00000000..9d864878 --- /dev/null +++ b/app/Models/Business/CourierOnboardingFile.php @@ -0,0 +1,80 @@ + '营业执照', + 'legal_id_card_front' => '法人身份证 · 人像面', + 'legal_id_card_back' => '法人身份证 · 国徽面', + 'bank_card_front' => '对私银行卡 · 正面', + 'bank_card_back' => '对私银行卡 · 反面', + ]; + + /** 计费方式选「其他」时上传的报价表,单独显示在报价区块里。 */ + public const QUOTE_SHEET_KIND = 'quote_sheet'; + + /** @return array */ + public static function allKinds(): array + { + return self::KINDS + [self::QUOTE_SHEET_KIND => '报价表']; + } + + /** 个人入驻时的资料名称。 */ + public const PERSON_KINDS = [ + 'business_license' => '营业执照(有就上传,选填)', + 'legal_id_card_front' => '身份证 · 人像面', + 'legal_id_card_back' => '身份证 · 国徽面', + 'bank_card_front' => '对私银行卡 · 正面', + 'bank_card_back' => '对私银行卡 · 反面', + ]; + + /** 提交审核前必须上传的资料(每项一张,重新上传会替换)。 */ + public const REQUIRED_KINDS = ['business_license', 'legal_id_card_front', 'legal_id_card_back', 'bank_card_front', 'bank_card_back']; + + public const PERSON_REQUIRED_KINDS = ['legal_id_card_front', 'legal_id_card_back', 'bank_card_front', 'bank_card_back']; + + /** @return list */ + public static function requiredKindsFor(?string $entityType): array + { + return $entityType === CourierOnboarding::ENTITY_PERSON ? self::PERSON_REQUIRED_KINDS : self::REQUIRED_KINDS; + } + + public static function labelFor(string $kind, ?string $entityType): string + { + return ($entityType === CourierOnboarding::ENTITY_PERSON ? (self::PERSON_KINDS[$kind] ?? null) : null) + ?? self::allKinds()[$kind] + ?? $kind; + } + + protected $table = 'business_courier_onboarding_files'; + + protected $fillable = ['company_id', 'onboarding_id', 'kind', 'original_name', 'path', 'mime', 'size']; + + protected $casts = [ + 'company_id' => 'integer', + 'onboarding_id' => 'integer', + 'size' => 'integer', + 'deleted_at' => 'datetime', + ]; + + public function kindLabel(): string + { + return self::KINDS[$this->kind] ?? $this->kind; + } + + public function isImage(): bool + { + return str_starts_with((string) $this->mime, 'image/'); + } + + public function sizeLabel(): string + { + $size = (int) $this->size; + + return $size >= 1048576 ? sprintf('%.1f MB', $size / 1048576) : sprintf('%d KB', max(1, (int) ceil($size / 1024))); + } +} diff --git a/app/Models/Business/CourierPaymentAccount.php b/app/Models/Business/CourierPaymentAccount.php new file mode 100644 index 00000000..ce3f944f --- /dev/null +++ b/app/Models/Business/CourierPaymentAccount.php @@ -0,0 +1,47 @@ + '打款信息新增', self::ACTION_CHANGE => '打款信息变更']; + + public const ACCOUNT_TYPES = ['对公', '对私']; + + /** 对应协议签署里的文件类型。 */ + public const DOCUMENT_TYPES = [self::ACTION_ADD => 'account-proof', self::ACTION_CHANGE => 'account-change']; + + protected $table = 'business_courier_payment_accounts'; + + protected $fillable = [ + 'company_id', 'expcode', 'courier_name', 'action', 'account_type', 'account_name', 'bank_name', 'account_number', + 'effective_date', 'old_account_name', 'old_account_number', 'old_stop_date', 'change_date', 'note', + 'created_by', 'updated_by', + ]; + + protected $casts = [ + 'account_number' => 'encrypted', + 'old_account_number' => 'encrypted', + 'effective_date' => 'date', + 'old_stop_date' => 'date', + 'change_date' => 'date', + ]; + + public function documentType(): string + { + return self::DOCUMENT_TYPES[$this->action] ?? 'account-proof'; + } + + public function actionLabel(): string + { + return self::ACTIONS[$this->action] ?? $this->action; + } +} diff --git a/app/Models/Business/CourierProfile.php b/app/Models/Business/CourierProfile.php new file mode 100644 index 00000000..1a5d32f1 --- /dev/null +++ b/app/Models/Business/CourierProfile.php @@ -0,0 +1,38 @@ + '快递公司名称', + 'paymethod' => '运费打款方式', + 'status' => '是否启用', + 'kuaiyunsgn' => '是否快运', + 'stopdate' => '停运时间', + 'tradebtime' => '合同开始时间', + 'tradeetime' => '合同结束时间', + 'linker' => '联系人', + 'linkmobile' => '联系电话', + 'undefined1' => '自定义1', + 'undefined2' => '自定义2', + 'undefined3' => '自定义3', + 'dzqzjbr' => '电子签章经办人', + ]; + + public const BOOLEAN_FIELDS = ['status', 'kuaiyunsgn']; + public const DATE_FIELDS = ['stopdate', 'tradebtime', 'tradeetime']; + + protected $table = 'business_courier_profiles'; + + protected $fillable = ['company_id', 'expcode', 'courier_name', 'overrides', 'created_by', 'updated_by']; + + protected $casts = ['overrides' => 'array']; +} diff --git a/app/Models/Business/CourierProfileEvent.php b/app/Models/Business/CourierProfileEvent.php new file mode 100644 index 00000000..dbaaf2b4 --- /dev/null +++ b/app/Models/Business/CourierProfileEvent.php @@ -0,0 +1,32 @@ + '编辑快递资料', + 'account_add' => '打款信息新增', + 'account_change' => '打款信息变更', + ]; + + protected $table = 'business_courier_profile_events'; + + protected $fillable = ['company_id', 'expcode', 'action', 'summary', 'changes', 'created_by']; + + protected $casts = ['changes' => 'array']; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\Auth\User::class, 'created_by'); + } + + public function actionLabel(): string + { + return self::ACTIONS[$this->action] ?? $this->action; + } +} diff --git a/app/Models/Business/CourierQuoteChange.php b/app/Models/Business/CourierQuoteChange.php index 5ef081e9..726a7eef 100644 --- a/app/Models/Business/CourierQuoteChange.php +++ b/app/Models/Business/CourierQuoteChange.php @@ -21,17 +21,19 @@ class CourierQuoteChange extends Model protected $fillable = [ 'company_id', 'number', 'source_quote_id', 'source_sheet_name', 'carrier_name', 'quote_type', - 'change_type', 'status', 'effective_date', 'is_retrospective', 'recalculation_status', + 'change_type', 'status', 'effective_date', 'end_date', 'is_retrospective', 'recalculation_status', 'recalculation_note', 'summary', 'created_by', 'submitted_by', 'submitted_at', 'approved_by', - 'approved_at', 'new_quote_version_id', + 'approved_at', 'new_quote_version_id', 'quote_payload', 'proposed_values_json', ]; protected $casts = [ 'company_id' => 'integer', 'effective_date' => 'date', + 'end_date' => 'date', 'is_retrospective' => 'boolean', 'submitted_at' => 'datetime', 'approved_at' => 'datetime', + 'quote_payload' => 'array', ]; public function items(): HasMany @@ -49,6 +51,20 @@ class CourierQuoteChange extends Model return $this->hasOne(CourierQuoteVersion::class, 'source_change_id'); } + /** 按完整报价结构提交的变价(快递入驻后的后台变价 / 重新报价)。 */ + /** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */ + public function periodLabel(): string + { + return $this->end_date !== null + ? $this->effective_date?->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)' + : $this->effective_date?->toDateString() . ' 起'; + } + + public function hasQuotePayload(): bool + { + return is_array($this->quote_payload) && $this->quote_payload !== []; + } + public function isEditable(): bool { return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true); diff --git a/app/Models/Business/CourierQuoteChangeEvent.php b/app/Models/Business/CourierQuoteChangeEvent.php index 3f3f7b8e..44a39e60 100644 --- a/app/Models/Business/CourierQuoteChangeEvent.php +++ b/app/Models/Business/CourierQuoteChangeEvent.php @@ -33,6 +33,7 @@ class CourierQuoteChangeEvent extends Model 'submitted' => '提交审核', 'returned' => '退回修改', 'approved' => '审核通过', + 'voided' => '版本作废', default => $this->event_type, }; } diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php index 33f6497e..5d572ed6 100644 --- a/app/Models/Business/CourierQuoteVersion.php +++ b/app/Models/Business/CourierQuoteVersion.php @@ -12,15 +12,55 @@ class CourierQuoteVersion extends Model protected $fillable = [ 'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name', - 'carrier_name', 'quote_type', 'effective_date', 'source_range', 'values_json', 'formulas_json', - 'status', 'signature_status', 'created_by', 'approved_by', + 'carrier_name', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json', + 'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by', ]; + public const STATUS_APPROVED = 'approved'; + + /** 发错作废:不再参与生效判断,原件和回签单保留。 */ + public const STATUS_VOIDED = 'voided'; + protected $casts = [ 'company_id' => 'integer', 'effective_date' => 'date', + 'end_date' => 'date', + 'voided_at' => 'datetime', ]; + /** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */ + public function isTemporary(): bool + { + return $this->end_date !== null; + } + + /** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */ + public function coversDate(\DateTimeInterface $date): bool + { + $day = \Illuminate\Support\Carbon::instance($date)->startOfDay(); + + return $this->effective_date !== null + && $this->effective_date->lte($day) + && ($this->end_date === null || $this->end_date->gte($day)); + } + + /** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */ + public function periodLabel(): string + { + if ($this->effective_date === null) { + return '-'; + } + + return $this->end_date !== null + ? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)' + : $this->effective_date->toDateString() . ' 起'; + } + + public function isVoided(): bool + { + return $this->status === self::STATUS_VOIDED; + } + public function change(): BelongsTo { return $this->belongsTo(CourierQuoteChange::class, 'source_change_id'); diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php index 8b99694a..6521b9d7 100644 --- a/app/Services/Esign/EsignClient.php +++ b/app/Services/Esign/EsignClient.php @@ -172,6 +172,39 @@ final class EsignClient return $payload; } + /** + * 获取签署人的签署页面链接(免登录,扫码或点开即可签署)。企业签署时带上企业名称,直接进入以该企业身份签署的页面。 + */ + public function signUrl(string $signFlowId, string $psnAccount, ?string $orgName = null): string + { + $payload = [ + 'needLogin' => false, + 'urlType' => 2, + 'operator' => ['psnAccount' => $psnAccount], + 'clientType' => 'ALL', + ]; + if ($orgName !== null && $orgName !== '') { + $payload['organization'] = ['orgName' => $orgName]; + } + + $data = $this->request('POST', "/v3/sign-flow/{$signFlowId}/sign-url", $payload); + $url = (string) ($data['shortUrl'] ?? '') ?: (string) ($data['url'] ?? ''); + + if ($url === '') { + throw new RuntimeException('E 签宝未返回签署链接。'); + } + + return $url; + } + + /** 撤回还没签完的签署流程(签署人那边的待签任务随之失效)。 */ + public function revokeSignFlow(string $signFlowId, string $reason): void + { + $this->request('POST', "/v3/sign-flow/{$signFlowId}/revoke", [ + 'revokeReason' => mb_substr($reason, 0, 50), + ]); + } + public function signFlowDetail(string $signFlowId): array { return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail"); diff --git a/app/Support/CourierOnboardingService.php b/app/Support/CourierOnboardingService.php new file mode 100644 index 00000000..6fb1b1ae --- /dev/null +++ b/app/Support/CourierOnboardingService.php @@ -0,0 +1,800 @@ + '公司全称', + 'unified_social_credit_code' => '统一社会信用代码', + 'legal_representative' => '法定代表人', + 'bank_account_name' => '对公账户户名', + 'bank_name' => '对公账户开户行', + 'bank_account_number' => '对公账号', + ]; + + private const PERSON_REQUIRED_FIELDS = [ + 'company_full_name' => '姓名', + 'id_number' => '身份证号', + ]; + + private const REQUIRED_FIELDS = [ + 'carrier_brand' => '快递品牌', + 'network_name' => '网点名称', + 'service_type' => '业务类型', + 'contact_name' => '业务对接人', + 'contact_phone' => '对接人手机', + 'esign_operator_name' => 'e签宝签署人姓名', + 'esign_account' => 'e签宝账号', + 'private_account_name' => '对私收款人', + 'private_bank_name' => '对私账户开户行', + 'private_account_number' => '对私银行卡号', + 'invoice_type' => '开票类型', + 'settlement_cycle' => '结算周期', + 'quote_mode' => '计费方式', + 'volumetric_rule' => '是否计泡', + 'quote_effective_date' => '报价执行日期', + ]; + + public const QUOTE_FIELDS = [ + 'quote_mode', 'quote_structure', 'quote_rows', 'volumetric_rule', 'volumetric_divisor', + 'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'quote_effective_date', + ]; + + public function ready(): bool + { + return Schema::hasTable('business_courier_onboardings') + && Schema::hasTable('business_courier_onboarding_files') + && Schema::hasTable('business_courier_onboarding_events'); + } + + public function invite(?string $label, int $validDays, int $userId, ?string $courierName = null): CourierOnboarding + { + $courierName = trim((string) $courierName); + if ($courierName !== '') { + $this->assertCourierNameAvailable(null, $courierName); + } + + return DB::transaction(function () use ($label, $validDays, $userId, $courierName): CourierOnboarding { + $onboarding = CourierOnboarding::create([ + 'courier_name' => $courierName !== '' ? $courierName : null, + 'company_id' => company_id(), + 'number' => $this->nextNumber(), + 'token' => $this->newToken(), + 'status' => CourierOnboarding::STATUS_INVITED, + 'invite_label' => filled($label) ? trim((string) $label) : null, + 'expires_at' => now()->addDays(max(1, min($validDays, 180)))->endOfDay(), + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + $this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED, + trim('系统快递名称:' . ($onboarding->courier_name ?? '—') . ($onboarding->invite_label ? ';备注:' . $onboarding->invite_label : '')), 'user', $userId); + + return $onboarding; + }); + } + + public function findByToken(string $token): ?CourierOnboarding + { + if (strlen($token) < 32 || strlen($token) > 64 || ! ctype_alnum($token)) { + return null; + } + + return CourierOnboarding::allCompanies()->where('token', $token)->first(); + } + + /** + * 快递方保存或提交资料。 + * + * @param array $data + * @param array> $files + */ + public function saveCourierForm(CourierOnboarding $onboarding, array $data, array $files, bool $submit): CourierOnboarding + { + if (! $onboarding->courierCanEdit()) { + throw ValidationException::withMessages(['form' => $onboarding->linkExpired() + ? '入驻链接已过期,请联系对接人重新获取二维码。' + : '资料已提交审核,暂不能修改。']); + } + + return DB::transaction(function () use ($onboarding, $data, $files, $submit): CourierOnboarding { + $onboarding->fill($this->courierAttributes($data)); + $onboarding->save(); + + foreach ($files as $kind => $uploads) { + foreach ($uploads as $upload) { + $this->storeFile($onboarding, $kind, $upload); + } + } + + $removeIds = array_map('intval', (array) ($data['remove_files'] ?? [])); + if ($removeIds !== []) { + $onboarding->files()->whereIn('id', $removeIds)->get()->each(function (CourierOnboardingFile $file): void { + Storage::disk('uploads')->delete($file->path); + $file->delete(); + }); + } + + $onboarding->unsetRelation('files'); + + if (! $submit) { + $this->log($onboarding, 'saved', $onboarding->status, $onboarding->status, null, 'courier'); + + return $onboarding; + } + + $this->assertCompleteForSubmit($onboarding); + $from = $onboarding->status; + $onboarding->update([ + 'status' => CourierOnboarding::STATUS_SUBMITTED, + 'submitted_at' => now(), + 'return_reason' => null, + ]); + $this->log($onboarding, 'submitted', $from, CourierOnboarding::STATUS_SUBMITTED, null, 'courier'); + + return $onboarding; + }); + } + + public function firstApprove(CourierOnboarding $onboarding, int $userId, ?string $note): CourierOnboarding + { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。'); + + return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [ + 'first_reviewed_by' => $userId, + 'first_reviewed_at' => now(), + ]); + } + + public function returnForChanges(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding + { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。'); + + $expiresAt = $onboarding->expires_at; + if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) { + $expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay(); + } + + return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [ + 'return_reason' => $reason, + 'expires_at' => $expiresAt, + 'first_reviewed_by' => null, + 'first_reviewed_at' => null, + ]); + } + + public function reject(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding + { + $this->assertStatus($onboarding, [ + CourierOnboarding::STATUS_SUBMITTED, + CourierOnboarding::STATUS_FIRST_APPROVED, + CourierOnboarding::STATUS_RETURNED, + ], '当前状态不能标记为不予合作。'); + + return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId); + } + + public function cancel(CourierOnboarding $onboarding, int $userId): CourierOnboarding + { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED], '只有快递方尚未提交的邀请可以作废。'); + + return $this->transition($onboarding, CourierOnboarding::STATUS_CANCELLED, 'cancelled', null, $userId); + } + + public function extend(CourierOnboarding $onboarding, int $userId): CourierOnboarding + { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED], '只有待快递填写的邀请需要延期。'); + $expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay(); + $onboarding->update(['expires_at' => $expiresAt, 'updated_by' => $userId]); + $this->log($onboarding, 'extended', $onboarding->status, $onboarding->status, '有效期至 ' . $expiresAt->format('Y-m-d'), 'user', $userId); + + return $onboarding; + } + + /** + * @param array{courier_name?:?string,reconciler?:?string} $data + */ + public function updateInternal(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding + { + $courierName = filled($data['courier_name'] ?? null) ? trim((string) $data['courier_name']) : null; + if ($courierName !== null && $onboarding->quote_version_id !== null && $courierName !== $onboarding->courier_name) { + throw ValidationException::withMessages(['courier_name' => '已生成首版报价,系统快递名称不能再修改。']); + } + if ($courierName !== null) { + $this->assertCourierNameAvailable($onboarding, $courierName); + } + + $onboarding->update([ + 'courier_name' => $courierName, + 'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : null, + 'updated_by' => $userId, + ]); + $this->log($onboarding, 'internal_updated', $onboarding->status, $onboarding->status, trim('系统快递名称:' . ($courierName ?? '—') . ';对账人:' . ($onboarding->reconciler ?? '—')), 'user', $userId); + + return $onboarding; + } + + /** + * 财务复审:确认系统快递名称,并把快递填的报价写成「报价管理」里的首版报价版本。 + * + * @param array{courier_name:string,reconciler?:?string,note?:?string} $data + */ + public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding + { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。'); + if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) { + throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']); + } + + // 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。 + $courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name); + $this->assertCourierNameAvailable($onboarding, $courierName); + + return DB::transaction(function () use ($onboarding, $data, $courierName, $userId): CourierOnboarding { + $onboarding->forceFill([ + 'courier_name' => $courierName, + 'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : $onboarding->reconciler, + ])->save(); + + $note = filled($data['note'] ?? null) ? trim((string) $data['note']) : null; + $extra = ['final_reviewed_by' => $userId, 'final_reviewed_at' => now()]; + + $version = $this->createQuoteVersion($onboarding, $userId); + if ($version !== null) { + $extra['quote_change_id'] = $version->source_change_id; + $extra['quote_version_id'] = $version->id; + $note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code}。"); + } else { + $note = trim(($note ?? '') . "\n快递未填写报价明细,请在报价管理中补录后再发起报价确认。"); + } + + return $this->transition($onboarding, CourierOnboarding::STATUS_APPROVED, 'final_approved', $note, $userId, $extra); + }); + } + + public function activate(CourierOnboarding $onboarding, int $userId, ?string $offlineNote): CourierOnboarding + { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。'); + + $quoteSignature = $onboarding->quoteSignature(); + $quoteSigned = $quoteSignature?->status === CourierQuoteSignature::STATUS_ARCHIVED; + $signature = $this->signedAgreement($onboarding); + if (! $quoteSigned && $signature === null && blank($offlineNote)) { + throw ValidationException::withMessages(['offline_note' => '报价确认单还没在 e签宝签署完成;如已线下签署,请填写说明后再开通。']); + } + + $note = match (true) { + $quoteSigned => '报价确认单已在 e签宝签署:' . $onboarding->quoteVersion->version_code, + $signature !== null => '电子协议已签署:' . $signature->business_no, + default => '线下签署:' . trim((string) $offlineNote), + }; + + return $this->transition($onboarding, CourierOnboarding::STATUS_ACTIVATED, 'activated', $note, $userId, [ + 'activated_by' => $userId, + 'activated_at' => now(), + ]); + } + + public function esignReady(): bool + { + return EsignClient::configured(); + } + + /** + * 用快递填写的报价生成报价确认单 PDF,推送到快递方的 e签宝账号签署。 + */ + public function pushQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature + { + $onboarding->loadMissing('quoteVersion.signature'); + if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true) || $onboarding->quoteVersion === null) { + throw ValidationException::withMessages(['signature' => '财务复审通过、生成首版报价后才能推送报价确认单。']); + } + + return $this->pushVersion($onboarding, $onboarding->quoteVersion, $userId); + } + + /** + * 按入驻资料(公司 / 个人、e签宝签署人和账号)把某个报价版本推送给快递签署。 + * 首版报价和后续变价共用。 + */ + public function pushVersion(CourierOnboarding $onboarding, CourierQuoteVersion $version, int $userId): CourierQuoteSignature + { + if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true)) { + throw ValidationException::withMessages(['signature' => '这家快递的入驻还没有审核通过,不能推送报价。']); + } + if ($version->isVoided()) { + throw ValidationException::withMessages(['signature' => "{$version->version_code} 已作废,不能推送。"]); + } + $existing = $version->signature()->first(); + if ($existing !== null && ! $existing->canSend()) { + throw ValidationException::withMessages(['signature' => "{$version->version_code} 已经推送过,正在签署或已签署。"]); + } + if (! EsignClient::configured()) { + throw ValidationException::withMessages(['signature' => 'E 签宝尚未配置,暂时无法推送;可线下签署后在开通时填写说明。']); + } + if (blank($onboarding->esign_operator_name) || ! $this->validEsignAccount((string) $onboarding->esign_account)) { + throw ValidationException::withMessages(['signature' => '快递方填写的 e签宝签署人或账号不完整,请退回补件或联系快递确认。']); + } + + $quoteSignatures = app(CourierQuoteSignatureService::class); + $signature = $quoteSignatures->prepareVersion($version, $userId, today()->toDateString()); + $signature = $quoteSignatures->send($signature, [ + 'signer_type' => $onboarding->isPerson() ? 'person' : 'organization', + 'organization_name' => (string) $onboarding->company_full_name, + 'operator_name' => (string) $onboarding->esign_operator_name, + 'operator_account' => (string) $onboarding->esign_account, + 'notice_type' => $onboarding->esignNoticeType(), + 'signature_date' => today()->toDateString(), + 'expires_at' => now()->addDays(30)->toDateTimeString(), + ], $userId); + + $this->log($onboarding, 'quote_pushed', $onboarding->status, $onboarding->status, + "报价确认单 {$version->version_code} 已推送到 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account})", 'user', $userId); + + return $signature; + } + + public function syncQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature + { + $signature = $onboarding->quoteSignature(); + if ($signature === null) { + throw ValidationException::withMessages(['signature' => '报价确认单还没有推送。']); + } + + return app(CourierQuoteSignatureService::class)->synchronize($signature, $userId); + } + + /** + * 取快递签署人的 e签宝 签署链接,可直接发给快递或我方扫码核对。 + */ + public function quoteSignUrl(CourierOnboarding $onboarding): string + { + $signature = $onboarding->quoteSignature(); + if ($signature === null || blank($signature->esign_sign_flow_id)) { + throw ValidationException::withMessages(['signature' => '报价确认单还没有推送到 e签宝。']); + } + if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) { + throw ValidationException::withMessages(['signature' => '报价确认单已签署完成。']); + } + + try { + return app(\App\Services\Esign\EsignClient::class)->signUrl( + (string) $signature->esign_sign_flow_id, + (string) $signature->operator_account, + $onboarding->isPerson() ? null : (string) $signature->organization_name, + ); + } catch (\RuntimeException $exception) { + throw ValidationException::withMessages(['signature' => '获取签署链接失败:' . mb_substr($exception->getMessage(), 0, 300)]); + } + } + + private function validEsignAccount(string $account): bool + { + return preg_match('/^1\d{10}$/', $account) === 1 || filter_var($account, FILTER_VALIDATE_EMAIL) !== false; + } + + /** + * 与该快递相关的协议签署任务(按系统快递名称或公司全称匹配),最近的在前。 + * + * @return \Illuminate\Support\Collection + */ + public function agreementSignatures(CourierOnboarding $onboarding): \Illuminate\Support\Collection + { + if (! Schema::hasTable('business_courier_agreement_signatures')) { + return collect(); + } + + $names = array_values(array_filter([ + $onboarding->courier_name, + $onboarding->company_full_name, + ], 'filled')); + if ($names === []) { + return collect(); + } + + return CourierAgreementSignature::query() + ->where(function ($query) use ($names): void { + $query->whereIn('courier_name', $names) + ->orWhereIn('company_full_name', $names) + ->orWhereIn('organization_name', $names); + }) + ->latest('id') + ->limit(20) + ->get(); + } + + public function signedAgreement(CourierOnboarding $onboarding): ?CourierAgreementSignature + { + return $this->agreementSignatures($onboarding)->first(static fn (CourierAgreementSignature $signature): bool => in_array( + $signature->status, + [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], + true, + )); + } + + /** + * @return array + */ + public function statusCounts(): array + { + return CourierOnboarding::query() + ->select('status', DB::raw('count(*) as total')) + ->groupBy('status') + ->pluck('total', 'status') + ->map(static fn ($total): int => (int) $total) + ->all(); + } + + /** + * @param array $data + * @return array + */ + private function courierAttributes(array $data): array + { + $text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null; + $attributes = []; + foreach ([ + 'company_full_name', 'legal_representative', 'business_address', 'carrier_brand', 'network_name', + 'service_type', 'contact_name', 'contact_phone', 'finance_contact_name', 'finance_contact_phone', + 'pickup_warehouses', 'esign_operator_name', 'esign_account', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle', + 'private_account_name', 'private_bank_name', + 'remark', + ] as $key) { + $attributes[$key] = $text($key); + } + + $code = $text('unified_social_credit_code'); + $attributes['unified_social_credit_code'] = $code === null ? null : strtoupper($code); + $account = $text('bank_account_number'); + $attributes['bank_account_number'] = $account === null ? null : preg_replace('/\s+/', '', $account); + $privateAccount = $text('private_account_number'); + $attributes['private_account_number'] = $privateAccount === null ? null : preg_replace('/\s+/', '', $privateAccount); + $attributes['tax_rate'] = is_numeric($data['tax_rate'] ?? null) ? (float) $data['tax_rate'] : null; + $attributes = [...$attributes, ...$this->quoteAttributes($data)]; + + $attributes['entity_type'] = ($data['entity_type'] ?? null) === CourierOnboarding::ENTITY_PERSON + ? CourierOnboarding::ENTITY_PERSON + : CourierOnboarding::ENTITY_COMPANY; + if ($attributes['entity_type'] === CourierOnboarding::ENTITY_PERSON) { + // 个人以本人身份签署:没有企业信息,e签宝签署人就是本人。 + $idNumber = $text('id_number'); + $attributes['id_number'] = $idNumber === null ? null : strtoupper((string) preg_replace('/\s+/', '', $idNumber)); + $attributes['unified_social_credit_code'] = null; + $attributes['legal_representative'] = null; + // 个人入驻:对私账户必填,对公账户暂为选填(有个体户/公司账户的可以填)。 + $attributes['esign_operator_name'] = $attributes['company_full_name']; + } else { + $attributes['id_number'] = null; + } + + return $attributes; + } + + /** + * 报价相关字段(计费结构、各省价格、附加规则、执行日期),入驻表单和后台变价共用。 + * + * @param array $data + * @return array + */ + public function quoteAttributes(array $data): array + { + $text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null; + $structure = CourierQuoteStructure::normalize($data); + $volumetricRule = $text('volumetric_rule'); + + return [ + 'volumetric_rule' => $volumetricRule, + 'quote_note' => $text('quote_note'), + 'volumetric_divisor' => $volumetricRule === '计泡' && is_numeric($data['volumetric_divisor'] ?? null) + ? (int) $data['volumetric_divisor'] + : null, + 'quote_effective_date' => $text('quote_effective_date'), + 'quote_mode' => filled($data['quote_mode'] ?? null) ? $structure['mode'] : null, + 'quote_structure' => $structure, + 'quote_rows' => CourierQuoteStructure::normalizeRows((array) ($data['quote_rows'] ?? []), $structure), + 'min_weight' => is_numeric($data['min_weight'] ?? null) && (float) $data['min_weight'] > 0 ? round((float) $data['min_weight'], 2) : null, + 'weight_rounding' => array_key_exists((string) ($data['weight_rounding'] ?? ''), CourierQuoteStructure::ROUNDING) ? (string) $data['weight_rounding'] : null, + 'per_ticket_surcharge' => is_numeric($data['per_ticket_surcharge'] ?? null) && (float) $data['per_ticket_surcharge'] > 0 ? round((float) $data['per_ticket_surcharge'], 2) : null, + ]; + } + + /** + * 当前入驻记录里的报价部分,写进报价变更单,后续变价以此为底稿。 + * + * @return array + */ + public function quotePayload(CourierOnboarding $onboarding): array + { + $payload = []; + foreach (self::QUOTE_FIELDS as $field) { + $payload[$field] = $onboarding->getAttribute($field); + } + $payload['quote_effective_date'] = $onboarding->quote_effective_date?->toDateString(); + $payload['quote_structure'] = $onboarding->quoteStructure(); + $payload['quote_rows'] = $onboarding->quoteRowList(); + + return $payload; + } + + private function storeFile(CourierOnboarding $onboarding, string $kind, UploadedFile $upload): void + { + if (! array_key_exists($kind, CourierOnboardingFile::allKinds())) { + return; + } + // 每项资料只保留一张,重新上传即替换。 + $onboarding->files()->where('kind', $kind)->get()->each(function (CourierOnboardingFile $old): void { + Storage::disk('uploads')->delete($old->path); + $old->delete(); + }); + + $extension = strtolower($upload->getClientOriginalExtension() ?: ($upload->guessExtension() ?: 'bin')); + $path = sprintf('courier-onboarding/%d/%d/%s-%s.%s', $onboarding->company_id, $onboarding->id, $kind, Str::uuid()->toString(), $extension); + + if (Storage::disk('uploads')->putFileAs(dirname($path), $upload, basename($path)) === false) { + throw ValidationException::withMessages(["files.{$kind}" => '文件保存失败,请稍后重试。']); + } + + CourierOnboardingFile::create([ + 'company_id' => $onboarding->company_id, + 'onboarding_id' => $onboarding->id, + 'kind' => $kind, + 'original_name' => mb_substr($upload->getClientOriginalName() ?: basename($path), 0, 200), + 'path' => $path, + 'mime' => $upload->getMimeType(), + 'size' => (int) $upload->getSize(), + ]); + } + + private function assertCompleteForSubmit(CourierOnboarding $onboarding): void + { + $errors = []; + $subjectFields = $onboarding->isPerson() ? self::PERSON_REQUIRED_FIELDS : self::COMPANY_REQUIRED_FIELDS; + foreach ($subjectFields + self::REQUIRED_FIELDS as $field => $label) { + if (blank($onboarding->{$field})) { + $errors[$field] = "请填写{$label}。"; + } + } + if ($onboarding->volumetric_rule === '计泡' && blank($onboarding->volumetric_divisor)) { + $errors['volumetric_divisor'] = '计泡请填写抛比系数(如 6000)。'; + } + + $checks = $onboarding->accountChecks(); + if ($checks['corporate'] === false) { + $errors['bank_account_name'] = "对公账户户名「{$onboarding->bank_account_name}」与营业执照公司全称「{$onboarding->company_full_name}」不一致,对公账户须为公司本身的账户。"; + } + if ($checks['private'] === false) { + $who = $onboarding->isPerson() ? '本人姓名' : '营业执照法定代表人'; + $errors['private_account_name'] = "对私收款人「{$onboarding->private_account_name}」与{$who}「{$onboarding->expectedPrivatePayee()}」不一致,对私账户须为{$who}本人的银行卡。"; + } + + if (filled($onboarding->esign_account) && ! $this->validEsignAccount((string) $onboarding->esign_account)) { + $errors['esign_account'] = 'e签宝账号请填写注册时用的 11 位手机号或邮箱。'; + } + $kinds = $onboarding->files()->pluck('kind')->unique()->all(); + $errors += CourierQuoteStructure::validate($onboarding->quoteStructure(), $onboarding->quoteRowList()); + if ($onboarding->quote_mode === 'other' && ! in_array(CourierOnboardingFile::QUOTE_SHEET_KIND, $kinds, true)) { + $errors['files.' . CourierOnboardingFile::QUOTE_SHEET_KIND] = '计费方式选「其他」时,请上传报价表(Excel 或照片)。'; + } + if ($onboarding->isPerson() && filled($onboarding->id_number) && preg_match('/^\d{17}[\dX]$/', (string) $onboarding->id_number) !== 1) { + $errors['id_number'] = '身份证号应为 18 位。'; + } + + foreach (CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) as $kind) { + if (! in_array($kind, $kinds, true)) { + $errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::labelFor($kind, $onboarding->entity_type) . '。'; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + private function assertCourierNameAvailable(?CourierOnboarding $onboarding, string $courierName): void + { + if ($courierName === '') { + throw ValidationException::withMessages(['courier_name' => '请填写系统快递名称。']); + } + + $taken = CourierOnboarding::query() + ->where('courier_name', $courierName) + ->when($onboarding !== null, static fn ($query) => $query->whereKeyNot($onboarding->id)) + ->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]) + ->exists(); + + if ($taken) { + throw ValidationException::withMessages(['courier_name' => "系统快递名称「{$courierName}」已被其他入驻申请使用。"]); + } + } + + private function createQuoteVersion(CourierOnboarding $onboarding, int $userId): ?CourierQuoteVersion + { + $rows = $onboarding->quoteRowList(); + if ($rows === [] || ! Schema::hasTable('business_courier_quote_versions') || ! Schema::hasTable('business_courier_quote_changes')) { + return null; + } + + $effectiveDate = $onboarding->quote_effective_date ?? today(); + $sheetName = $onboarding->courier_name . '报价'; + $quoteType = '新快递入驻报价'; + $values = $this->quoteMatrix($onboarding, $rows, $effectiveDate); + + $change = CourierQuoteChange::create([ + 'company_id' => company_id(), + 'number' => $this->nextChangeNumber(), + 'source_quote_id' => 'RZ-' . $onboarding->id, + 'source_sheet_name' => $sheetName, + 'carrier_name' => $onboarding->courier_name, + 'quote_type' => $quoteType, + 'change_type' => '新快递入驻', + 'status' => CourierQuoteChange::STATUS_APPROVED, + 'effective_date' => $effectiveDate, + 'is_retrospective' => $effectiveDate->copy()->startOfDay()->lt(today()), + 'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED, + 'summary' => "快递入驻 {$onboarding->number} 首版报价({$onboarding->company_full_name})。", + 'quote_payload' => Schema::hasColumn('business_courier_quote_changes', 'quote_payload') ? $this->quotePayload($onboarding) : null, + 'created_by' => (int) ($onboarding->first_reviewed_by ?: $userId), + 'submitted_by' => (int) ($onboarding->first_reviewed_by ?: $userId), + 'submitted_at' => $onboarding->first_reviewed_at ?? now(), + 'approved_by' => $userId, + 'approved_at' => now(), + ]); + + $importedCount = collect(app(QuoteManagementSnapshot::class)->load()['quotes'] ?? []) + ->whereNull('source_change_id') + ->count(); + $version = CourierQuoteVersion::create([ + 'company_id' => company_id(), + 'version_code' => 'PENDING-' . $change->id, + 'source_quote_id' => $change->source_quote_id, + 'source_change_id' => $change->id, + 'sheet_name' => $sheetName, + 'company_name' => $onboarding->courier_name, + 'carrier_name' => $onboarding->courier_name, + 'quote_type' => $quoteType, + 'effective_date' => $effectiveDate, + 'source_range' => null, + 'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'formulas_json' => json_encode([], JSON_THROW_ON_ERROR), + 'status' => 'approved', + 'signature_status' => '未签署', + 'created_by' => $change->created_by, + 'approved_by' => $userId, + ]); + $version->update(['version_code' => sprintf('QB-%03d', $importedCount + $version->id)]); + $change->update(['new_quote_version_id' => $version->id]); + + CourierQuoteChangeEvent::create([ + 'company_id' => company_id(), + 'quote_change_id' => $change->id, + 'event_type' => 'approved', + 'from_status' => null, + 'to_status' => CourierQuoteChange::STATUS_APPROVED, + 'note' => "由快递入驻 {$onboarding->number} 财务复审生成。", + 'created_by' => $userId, + ]); + + return $version; + } + + /** + * 写成与报价单相同的二维表,报价管理和报价确认单可以直接展示。 + * + * @param list $rows + * @return list> + */ + public function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array + { + $columns = $onboarding->quoteColumns(); + $width = count($columns) + 1; + $line = static fn (string $text): array => array_pad([$text], $width, null); + + $matrix = [ + $line($onboarding->courier_name . ' 报价'), + $line('执行时间:' . $effectiveDate->format('Y.m.d')), + ]; + + $matrix[] = ['省份', ...array_column($columns, 'label')]; + foreach ($rows as $row) { + $matrix[] = [$row['region'], ...CourierQuoteStructure::rowValues($row, $columns)]; + } + + // 规则说明放在价格表下方,避免报价确认单抬头的「面单费」误读说明文字。 + foreach ($onboarding->quoteRuleLines() as $rule) { + $matrix[] = $line($rule); + } + + if (filled($onboarding->quote_note)) { + $matrix[] = $line('备注:' . $onboarding->quote_note); + } + $matrix[] = $line('适用快递:' . $onboarding->courier_name); + + return $matrix; + } + + /** + * @param list $allowed + */ + private function assertStatus(CourierOnboarding $onboarding, array $allowed, string $message): void + { + if (! in_array($onboarding->status, $allowed, true)) { + throw ValidationException::withMessages(['status' => $message]); + } + } + + /** + * @param array $extra + */ + private function transition(CourierOnboarding $onboarding, string $to, string $event, ?string $note, int $userId, array $extra = []): CourierOnboarding + { + $from = $onboarding->status; + $onboarding->update(['status' => $to, 'updated_by' => $userId] + $extra); + $this->log($onboarding, $event, $from, $to, $note, 'user', $userId); + + return $onboarding; + } + + private function log(CourierOnboarding $onboarding, string $event, ?string $from, ?string $to, ?string $note, string $actorType, ?int $actorId = null): void + { + CourierOnboardingEvent::create([ + 'company_id' => $onboarding->company_id, + 'onboarding_id' => $onboarding->id, + 'event_type' => $event, + 'from_status' => $from, + 'to_status' => $to, + 'note' => filled($note) ? $note : null, + 'actor_type' => $actorType, + 'actor_id' => $actorId, + ]); + } + + private function nextNumber(): string + { + $prefix = 'RZ-' . now()->format('Ymd') . '-'; + $last = CourierOnboarding::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); + $sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1; + + return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); + } + + private function nextChangeNumber(): string + { + $prefix = 'BG-' . now()->format('Ymd') . '-'; + $last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); + $sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1; + + return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); + } + + private function newToken(): string + { + do { + $token = Str::random(40); + } while (CourierOnboarding::allCompanies()->withTrashed()->where('token', $token)->exists()); + + return $token; + } +} diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index 90fccc09..e3a3b28f 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -120,7 +120,8 @@ final class CourierQuoteChangeService throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']); } - if ($change->created_by === $userId) { + // 测试环境可用 COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 放开(一人走完流程),正式环境保持发起人不能自审。 + if ($change->created_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) { throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']); } @@ -128,8 +129,15 @@ final class CourierQuoteChangeService return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange { $values = $source['values']; + $formulas = $source['formulas']; - foreach ($change->items as $item) { + // 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。 + if ($change->hasQuotePayload() && filled($change->proposed_values_json)) { + $values = json_decode((string) $change->proposed_values_json, true, 512, JSON_THROW_ON_ERROR); + $formulas = []; + } + + foreach ($change->hasQuotePayload() ? [] : $change->items as $item) { if (! array_key_exists($item->source_row, $values) || ! array_key_exists($item->source_column, $values[$item->source_row])) { throw ValidationException::withMessages(['items' => "报价单元格 {$item->source_cell} 已不存在,请退回重新核对。"]); } @@ -150,9 +158,10 @@ final class CourierQuoteChangeService 'carrier_name' => $source['company_name'], 'quote_type' => $source['quote_type'], 'effective_date' => $change->effective_date, + 'end_date' => $change->end_date, 'source_range' => $source['source_range'], 'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), - 'formulas_json' => json_encode($source['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'status' => 'approved', 'signature_status' => '未签署', 'created_by' => $change->created_by, @@ -282,6 +291,7 @@ final class CourierQuoteChangeService return [ 'change_type' => $change->change_type, 'effective_date' => $change->effective_date->toDateString(), + 'end_date' => $change->end_date?->toDateString(), 'is_retrospective' => $change->isRetrospective(), 'recalculation_status' => $change->recalculation_status, 'recalculation_note' => $change->recalculation_note, diff --git a/app/Support/CourierQuoteConfirmation.php b/app/Support/CourierQuoteConfirmation.php index 5633dd0f..6c663b16 100644 --- a/app/Support/CourierQuoteConfirmation.php +++ b/app/Support/CourierQuoteConfirmation.php @@ -13,9 +13,19 @@ final class CourierQuoteConfirmation $nextVersion = CourierQuoteVersion::query() ->where('carrier_name', $version->carrier_name) ->where('quote_type', $version->quote_type) + ->where('status', 'approved') ->where('effective_date', '>', $version->effective_date) + ->whereNull('end_date') ->orderBy('effective_date') ->first(); + // 短期变价到期后恢复的长期报价(打印在确认单上,让快递知道到期后按哪份执行)。 + $restore = $version->isTemporary() + ? \App\Support\CourierRequoteService::resolveAt( + CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)->where('status', 'approved')->where('id', '!=', $version->id)->get(), + $version->end_date->copy()->addDay(), + false, + ) + : null; return [ 'version_id' => $version->id, @@ -25,7 +35,13 @@ final class CourierQuoteConfirmation 'carrier_name' => $version->carrier_name, 'quote_type' => $version->quote_type, 'effective_date' => $version->effective_date?->toDateString(), - 'expiry_date' => $nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期', + 'expiry_date' => $version->isTemporary() + ? $version->end_date->toDateString() + : ($nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期'), + 'temporary_note' => $version->isTemporary() + ? '本报价为短期(临时)报价,仅在 ' . $version->effective_date->toDateString() . ' 至 ' . $version->end_date->toDateString() . ' 期间执行;' + . $version->end_date->copy()->addDay()->toDateString() . ' 起自动恢复' . ($restore ? '原报价 ' . $restore->version_code . '(' . $restore->effective_date->toDateString() . ' 起执行)' : '原长期报价') . ',无需另行签署。' + : null, 'face_fee' => $this->matrixMetric($values, ['面单费', '面单费用', '面单']), 'bubble_coefficient' => $this->matrixMetric($values, ['计泡系数', '泡系数', '计泡']), 'values' => $values, diff --git a/app/Support/CourierQuotePdf.php b/app/Support/CourierQuotePdf.php index 04c267de..5c01977b 100644 --- a/app/Support/CourierQuotePdf.php +++ b/app/Support/CourierQuotePdf.php @@ -23,6 +23,7 @@ final class CourierQuotePdf 'formulas' => $data['formulas'], 'partyA' => $data['party_a'], 'expiryDate' => $data['expiry_date'], + 'temporaryNote' => $data['temporary_note'] ?? null, 'faceFee' => $data['face_fee'], 'bubbleCoefficient' => $data['bubble_coefficient'], 'signatureDate' => $signatureDate, diff --git a/app/Support/CourierQuoteSignatureService.php b/app/Support/CourierQuoteSignatureService.php index 9852b546..29bef6e7 100644 --- a/app/Support/CourierQuoteSignatureService.php +++ b/app/Support/CourierQuoteSignatureService.php @@ -96,13 +96,23 @@ final class CourierQuoteSignatureService $bytes = Storage::disk('uploads')->get($signature->source_pdf_path); $fileName = 'quote-' . $signature->version->version_code . '.pdf'; $fileId = $client->uploadPdf($bytes, $fileName); - $signFlowId = $client->createSignFlow($fileId, $fileName, [ + $signer = [ 'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认', 'organization_name' => $signature->organization_name, 'operator_name' => $signature->operator_name, 'operator_account' => $signature->operator_account, 'notice_types' => $signature->notice_types, - ], $signature->expires_at->getTimestampMs()); + ]; + if (($data['signer_type'] ?? 'organization') === 'person') { + // 个人快递以本人身份签署,不走企业经办人。 + $signer['signers'] = [[ + 'signer_type' => 0, + 'operator_name' => $signature->operator_name, + 'operator_account' => $signature->operator_account, + 'notice_types' => $signature->notice_types, + ]]; + } + $signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs()); $signature->update([ 'status' => CourierQuoteSignature::STATUS_SIGNING, @@ -139,6 +149,26 @@ final class CourierQuoteSignatureService return $signature; } + // 同一份确认单同时只查一次:几个页面同时打开时,避免后到的查询把已归档的状态又写回「签署完成,待归档」。 + $lock = \Illuminate\Support\Facades\Cache::lock('esign-quote-sync-' . $signature->id, 120); + if (! $lock->get()) { + return $signature->fresh() ?? $signature; + } + + try { + $signature = $signature->fresh() ?? $signature; + if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) { + return $signature; + } + + return $this->synchronizeUnlocked($signature, $userId); + } finally { + $lock->release(); + } + } + + private function synchronizeUnlocked(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature + { try { $client = app(EsignClient::class); $detail = $client->signFlowDetail($signature->esign_sign_flow_id); @@ -162,6 +192,52 @@ final class CourierQuoteSignatureService } } + /** + * 打开报价页面时顺手查一下还在签署中的报价确认单(未开启定时轮询、回调又到不了本系统时, + * 签完能尽快显示「已签署并归档」)。每次最多查几条,距上次查询不足 $minAgeSeconds 秒的跳过,失败不影响页面。 + */ + public function refreshPending(int $limit = 5, int $minAgeSeconds = 60): int + { + if (! EsignClient::configured() || ! \Illuminate\Support\Facades\Schema::hasTable('business_courier_quote_signatures')) { + return 0; + } + + // 已归档但版本上的文字状态没跟上的,补成「已签署并归档」。 + $archivedVersionIds = CourierQuoteSignature::query() + ->where('status', CourierQuoteSignature::STATUS_ARCHIVED) + ->pluck('quote_version_id'); + if ($archivedVersionIds->isNotEmpty()) { + CourierQuoteVersion::query() + ->whereIn('id', $archivedVersionIds->all()) + ->where('signature_status', '<>', '已签署并归档') + ->update(['signature_status' => '已签署并归档']); + } + + $environment = strtolower(trim((string) config('services.esign.environment', 'sandbox'))); + $cutoff = now()->subSeconds($minAgeSeconds); + $pending = CourierQuoteSignature::query() + ->where('environment', $environment) + ->whereNotNull('esign_sign_flow_id') + ->where('esign_sign_flow_id', '<>', '') + ->whereIn('status', [CourierQuoteSignature::STATUS_SIGNING, CourierQuoteSignature::STATUS_COMPLETED]) + ->where(fn ($query) => $query->whereNull('last_synced_at')->orWhere('last_synced_at', '<=', $cutoff)) + ->orderBy('last_synced_at') + ->limit($limit) + ->get(); + + $refreshed = 0; + foreach ($pending as $signature) { + try { + $this->synchronize($signature, (int) ($signature->updated_by ?: $signature->created_by)); + $refreshed++; + } catch (Throwable) { + // 查询失败已记在 last_error,页面照常显示,可手动「同步签署状态」。 + } + } + + return $refreshed; + } + public function synchronizeById(int $signatureId): void { $signature = CourierQuoteSignature::allCompanies()->find($signatureId); diff --git a/app/Support/CourierQuoteStructure.php b/app/Support/CourierQuoteStructure.php new file mode 100644 index 00000000..3ae31c6d --- /dev/null +++ b/app/Support/CourierQuoteStructure.php @@ -0,0 +1,293 @@ + '面单 + 费率×重量', + 'first_additional' => '首重 + 续重', + 'flat_bands' => '按重量段一口价', + 'flat_then' => '小重量一口价 + 超出另算', + 'other' => '其他(上传报价表)', + ]; + + public const MODE_HINTS = [ + 'waybill_rate' => '运费 = 面单 + 重量 × 费率;费率可按重量区间分段,如 0-35kg / 35-50kg / 50kg 以上', + 'first_additional' => '运费 = 首重价 +(重量 − 首重公斤数)× 续重;首重内按首重价', + 'flat_bands' => '可设多档,如 0-1kg、1-2kg、2-3kg 各一个固定价;只设一档就是纯一口价,最后一档以上不收', + 'flat_then' => '如 0-1、1-2、2-3kg 各档一口价,3kg 以上改按 面单+费率×重量 或 首重+续重', + 'other' => '按条码、含货全包、按城市流向等特殊报价:直接上传报价表(Excel 或照片),由我方录入', + ]; + + public const ROUNDING = [ + 'none' => '不取整', + 'ceil' => '向上取整(如 2.1kg 按 3kg)', + 'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)', + ]; + + public const MAX_BANDS = 12; + + public const MAX_TIERS = 6; + + /** + * @param array $input + * @return array{mode:string,flat_bands:list,then_mode:string,first_weight:?float,rate_tiers:list} + */ + public static function normalize(array $input): array + { + $mode = array_key_exists((string) ($input['quote_mode'] ?? ''), self::MODES) ? (string) $input['quote_mode'] : 'waybill_rate'; + $thenMode = ($input['quote_then_mode'] ?? '') === 'first_additional' ? 'first_additional' : 'waybill_rate'; + + return [ + 'mode' => $mode, + 'flat_bands' => in_array($mode, ['flat_bands', 'flat_then'], true) + ? array_slice(self::parseList($input['quote_flat_bands'] ?? ''), 0, self::MAX_BANDS) + : [], + 'then_mode' => $mode === 'flat_then' ? $thenMode : '', + 'first_weight' => self::usesFirstWeight($mode, $thenMode) && is_numeric($input['quote_first_weight'] ?? null) + ? round((float) $input['quote_first_weight'], 2) + : null, + 'rate_tiers' => self::usesRates($mode) + ? array_slice(self::parseList($input['quote_rate_tiers'] ?? ''), 0, self::MAX_TIERS) + : [], + ]; + } + + /** + * 解析「3,4,5」「3、4、5」这类重量节点,去重并升序。 + * + * @return list + */ + public static function parseList(mixed $value): array + { + if (is_array($value)) { + $parts = $value; + } else { + $parts = preg_split('/[\s,,、;;\/]+/u', (string) $value) ?: []; + } + + $numbers = []; + foreach ($parts as $part) { + $part = preg_replace('/(kg|KG|公斤)$/u', '', trim((string) $part)); + if (is_numeric($part) && (float) $part > 0) { + $numbers[] = round((float) $part, 2); + } + } + $numbers = array_values(array_unique($numbers, SORT_REGULAR)); + sort($numbers); + + return $numbers; + } + + /** + * @param array $structure + * @return list + */ + public static function columns(array $structure): array + { + $mode = $structure['mode'] ?? 'waybill_rate'; + if ($mode === 'other') { + return []; + } + + $columns = []; + $bands = $structure['flat_bands'] ?? []; + if (in_array($mode, ['flat_bands', 'flat_then'], true)) { + $lower = 0.0; + foreach ($bands as $index => $upper) { + $columns[] = ['key' => "flat.{$index}", 'label' => self::kg($lower) . '-' . self::kg($upper) . 'kg(含' . self::kg($upper) . ') 一口价']; + $lower = $upper; + } + } + + if (! self::usesRates($mode)) { + return $columns; + } + + $thenMode = $mode === 'flat_then' ? ($structure['then_mode'] ?? 'waybill_rate') : $mode; + $prefix = $mode === 'flat_then' && $bands !== [] ? self::kg((float) end($bands)) . 'kg 以上 ' : ''; + if ($thenMode === 'first_additional') { + $columns[] = ['key' => 'base', 'label' => $prefix . '首重' . self::kg((float) ($structure['first_weight'] ?? 0)) . 'kg(元)']; + $rateName = '续重'; + } else { + $columns[] = ['key' => 'base', 'label' => $prefix . '面单']; + $rateName = '费率'; + } + + $tiers = $structure['rate_tiers'] ?? []; + if ($tiers === []) { + $columns[] = ['key' => 'rates.0', 'label' => "{$rateName}(元/kg)"]; + + return $columns; + } + + foreach (array_values($tiers) as $index => $edge) { + $lower = $index === 0 ? ($mode === 'flat_then' && $bands !== [] ? (float) end($bands) : 0.0) : (float) $tiers[$index - 1]; + $label = self::kg($lower) . '-' . self::kg($edge) . 'kg'; + $columns[] = ['key' => "rates.{$index}", 'label' => "{$rateName} {$label}"]; + } + $columns[] = ['key' => 'rates.' . count($tiers), 'label' => "{$rateName} " . self::kg((float) end($tiers)) . 'kg以上']; + + return $columns; + } + + /** + * 按列顺序取一行的值。 + * + * @param array $row + * @param list $columns + * @return list + */ + public static function rowValues(array $row, array $columns): array + { + return array_map(static function (array $column) use ($row): ?float { + $value = data_get($row, $column['key']); + + return is_numeric($value) ? (float) $value : null; + }, $columns); + } + + /** + * 只保留填了价格的省份行,并按当前结构裁剪列。 + * + * @param array $rows + * @param array $structure + * @return list,base:?float,rates:list}> + */ + public static function normalizeRows(array $rows, array $structure): array + { + $flatCount = in_array($structure['mode'] ?? '', ['flat_bands', 'flat_then'], true) ? count($structure['flat_bands'] ?? []) : 0; + $rateCount = self::usesRates($structure['mode'] ?? '') ? count($structure['rate_tiers'] ?? []) + 1 : 0; + $number = static fn ($value): ?float => is_numeric($value) && (float) $value >= 0 ? round((float) $value, 4) : null; + $result = []; + + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + $region = mb_substr(trim((string) ($row['region'] ?? '')), 0, 50); + $flat = []; + for ($i = 0; $i < $flatCount; $i++) { + $flat[] = $number(data_get($row, "flat.{$i}")); + } + $rates = []; + for ($i = 0; $i < $rateCount; $i++) { + $rates[] = $number(data_get($row, "rates.{$i}")); + } + $base = $rateCount > 0 ? $number($row['base'] ?? null) : null; + + $filled = array_filter([...$flat, $base, ...$rates], static fn ($value): bool => $value !== null); + if ($region === '' || $filled === []) { + continue; + } + $result[] = ['region' => $region, 'flat' => $flat, 'base' => $base, 'rates' => $rates]; + } + + return $result; + } + + /** + * 提交前的结构和逐行完整性检查,返回 字段 => 错误信息。 + * + * @param array $structure + * @param list> $rows + * @return array + */ + public static function validate(array $structure, array $rows): array + { + $mode = $structure['mode'] ?? ''; + if ($mode === 'other') { + return []; + } + + $errors = []; + if (in_array($mode, ['flat_bands', 'flat_then'], true) && ($structure['flat_bands'] ?? []) === []) { + $errors['quote_flat_bands'] = '请至少设置一档一口价重量,如 0-3kg,或 0-1、1-2、2-3kg 三档。'; + } + if (self::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! ((float) ($structure['first_weight'] ?? 0) > 0)) { + $errors['quote_first_weight'] = '请填写首重公斤数,如 3 或 5。'; + } + if ($mode === 'flat_then' && ($structure['flat_bands'] ?? []) !== [] && ($structure['rate_tiers'] ?? []) !== [] + && min($structure['rate_tiers']) <= max($structure['flat_bands'])) { + $errors['quote_rate_tiers'] = '超出部分的费率分段应大于一口价的最后一段重量。'; + } + if ($errors !== []) { + return $errors; + } + + if ($rows === []) { + return ['quote_rows' => '请至少填写一个省份的报价,系统会据此生成报价确认单发到 e签宝签署。']; + } + + $columns = self::columns($structure); + foreach ($rows as $row) { + $values = self::rowValues($row, $columns); + $missing = []; + foreach ($values as $index => $value) { + if ($value === null) { + $missing[] = $columns[$index]['label']; + } + } + if ($missing !== []) { + $errors['quote_rows'] = "{$row['region']}还有价格没填:" . implode('、', $missing) . '。不发的省份整行留空即可。'; + break; + } + } + + return $errors; + } + + /** + * 计费方式的文字说明,用于报价矩阵、PDF 和后台展示。 + * + * @param array $structure + */ + public static function describe(array $structure): string + { + $mode = $structure['mode'] ?? 'waybill_rate'; + $bands = $structure['flat_bands'] ?? []; + $tierNote = ($structure['rate_tiers'] ?? []) !== [] ? '(按总重量所在区间取费率)' : ''; + $fw = self::kg((float) ($structure['first_weight'] ?? 0)); + + return match ($mode) { + 'first_additional' => "首重{$fw}kg内按首重价,超出:首重价+(重量-{$fw})*续重{$tierNote}", + 'flat_bands' => '按重量段一口价,' . self::kg((float) (end($bands) ?: 0)) . 'kg以上不在报价范围', + 'flat_then' => self::kg((float) (end($bands) ?: 0)) . 'kg以内按段一口价,超出:' + . (($structure['then_mode'] ?? '') === 'first_additional' + ? "首重{$fw}kg价+(重量-{$fw})*续重" + : '面单+重量*费率') . $tierNote, + 'other' => '特殊计费,见附件报价表', + default => '面单+重量*费率' . $tierNote, + }; + } + + public static function usesRates(string $mode): bool + { + return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true); + } + + public static function usesFirstWeight(string $mode, string $thenMode = ''): bool + { + return $mode === 'first_additional' || ($mode === 'flat_then' && $thenMode === 'first_additional'); + } + + public static function kg(float $value): string + { + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } +} diff --git a/app/Support/CourierRequoteService.php b/app/Support/CourierRequoteService.php new file mode 100644 index 00000000..1c85a09e --- /dev/null +++ b/app/Support/CourierRequoteService.php @@ -0,0 +1,483 @@ +where('courier_name', $courierName) + ->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED]) + ->latest('id') + ->first(); + } + + /** + * 已入驻快递的系统名称 => 入驻 id,报价管理列表一次取出。 + * + * @return array + */ + public function onboardedCourierIds(): array + { + return CourierOnboarding::query() + ->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED]) + ->whereNotNull('courier_name') + ->orderBy('id') + ->pluck('id', 'courier_name') + ->all(); + } + + /** 这家快递最新的报价版本(含待签署的)。 */ + public function latestVersion(CourierOnboarding $onboarding): ?CourierQuoteVersion + { + return CourierQuoteVersion::query() + ->with('signature') + ->where('company_name', $onboarding->courier_name) + ->where('status', 'approved') + ->latest('id') + ->first(); + } + + /** @return \Illuminate\Support\Collection */ + public function versions(CourierOnboarding $onboarding): \Illuminate\Support\Collection + { + return CourierQuoteVersion::query() + ->with('signature') + ->where('company_name', $onboarding->courier_name) + ->where('status', 'approved') + ->latest('id') + ->get(); + } + + /** 还没审核完的变价单(待审核或已退回),同一时间只允许一张。 */ + public function openChange(CourierOnboarding $onboarding): ?CourierQuoteChange + { + return CourierQuoteChange::query() + ->where('carrier_name', $onboarding->courier_name) + ->whereIn('status', [CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_DRAFT]) + ->whereNotNull('quote_payload') + ->latest('id') + ->first(); + } + + /** + * 当前生效(最新)版本的报价结构,作为变价底稿。 + * + * @return array + */ + public function currentPayload(CourierOnboarding $onboarding): array + { + // 以最新的长期报价为底稿:临时变价到期就作废,不该成为下一次变价的起点。 + $version = $this->versions($onboarding)->first(static fn (CourierQuoteVersion $item): bool => ! $item->isTemporary()) + ?? $this->latestVersion($onboarding); + $change = $version?->source_change_id ? CourierQuoteChange::query()->find($version->source_change_id) : null; + + return $change?->hasQuotePayload() ? $change->quote_payload : $this->onboardings->quotePayload($onboarding); + } + + /** 用报价结构生成一份临时的入驻对象,复用入驻的报价列、规则文字和报价矩阵。 */ + public function draftModel(CourierOnboarding $onboarding, array $payload): CourierOnboarding + { + $model = $onboarding->replicate(); + $model->id = $onboarding->id; + foreach (CourierOnboardingService::QUOTE_FIELDS as $field) { + $model->setAttribute($field, $payload[$field] ?? null); + } + $model->setRelation('files', collect()); + + return $model; + } + + /** + * 提交变价单(新建或更新已退回的那张),进入待审核。 + * + * @param array $data + */ + public function submit(CourierOnboarding $onboarding, array $data, int $userId): CourierQuoteChange + { + if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) { + throw ValidationException::withMessages(['status' => '快递开通后才能发起变价。']); + } + + $attributes = $this->onboardings->quoteAttributes($data); + $structure = $attributes['quote_structure']; + if (($structure['mode'] ?? '') === 'other') { + throw ValidationException::withMessages(['quote_mode' => '后台变价请选择具体的计费方式。']); + } + $errors = CourierQuoteStructure::validate($structure, $attributes['quote_rows']); + if (blank($attributes['quote_effective_date'])) { + $errors['quote_effective_date'] = '请填写新报价的执行日期。'; + } + // 结束日期选填:填了就是短期(临时)变价,到期后自动回到原来的长期报价。 + $endDate = filled($data['quote_end_date'] ?? null) ? Carbon::parse((string) $data['quote_end_date'])->startOfDay() : null; + if ($endDate !== null && filled($attributes['quote_effective_date']) && $endDate->lt(Carbon::parse($attributes['quote_effective_date'])->startOfDay())) { + $errors['quote_end_date'] = '结束日期不能早于执行日期。'; + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + $payload = []; + foreach (CourierOnboardingService::QUOTE_FIELDS as $field) { + $payload[$field] = $attributes[$field] ?? null; + } + $current = $this->currentPayload($onboarding); + if ($this->comparable($payload) === $this->comparable($current)) { + throw ValidationException::withMessages(['quote_rows' => '报价和当前版本完全一样,没有需要变更的内容。']); + } + + $open = $this->openChange($onboarding); + if ($open !== null && $open->status === CourierQuoteChange::STATUS_PENDING_REVIEW) { + throw ValidationException::withMessages(['status' => "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。"]); + } + + $version = $this->latestVersion($onboarding); + $effectiveDate = Carbon::parse($payload['quote_effective_date'])->startOfDay(); + $draft = $this->draftModel($onboarding, $payload); + $values = $this->onboardings->quoteMatrix($draft, $draft->quoteRowList(), $effectiveDate); + $type = $this->sameStructure($payload, $current) ? self::CHANGE_PRICE : self::CHANGE_STRUCTURE; + + return DB::transaction(function () use ($onboarding, $data, $userId, $payload, $values, $version, $effectiveDate, $endDate, $type, $open): CourierQuoteChange { + $attributes = [ + 'source_quote_id' => $version?->version_code ?? ('RZ-' . $onboarding->id), + 'source_sheet_name' => $version?->sheet_name ?? ($onboarding->courier_name . '报价'), + 'carrier_name' => $onboarding->courier_name, + 'quote_type' => $version?->quote_type ?? '快递报价', + 'change_type' => $type, + 'status' => CourierQuoteChange::STATUS_PENDING_REVIEW, + 'effective_date' => $effectiveDate, + 'end_date' => $endDate, + 'is_retrospective' => $effectiveDate->lt(today()), + 'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED, + 'summary' => trim((string) $data['summary']), + 'submitted_by' => $userId, + 'submitted_at' => now(), + 'quote_payload' => $payload, + 'proposed_values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]; + + if ($open !== null) { + $from = $open->status; + $open->update($attributes); + $change = $open; + } else { + $from = null; + $change = CourierQuoteChange::create([ + 'company_id' => company_id(), + 'number' => $this->nextChangeNumber(), + 'created_by' => $userId, + ...$attributes, + ]); + } + + CourierQuoteChangeEvent::create([ + 'company_id' => company_id(), + 'quote_change_id' => $change->id, + 'event_type' => 'submitted', + 'from_status' => $from, + 'to_status' => CourierQuoteChange::STATUS_PENDING_REVIEW, + 'note' => "后台{$type}:{$onboarding->courier_name},自 {$effectiveDate->toDateString()} 起执行。", + 'created_by' => $userId, + ]); + + return $change->fresh(); + }); + } + + /** + * 新旧报价对比:同一计费方式时逐格列出变动;计费方式变了则说明结构变化。 + * + * @return array{same_structure:bool,old_mode:string,new_mode:string,old_columns:list,new_columns:list,cells:list,added:list,removed:list,rules:list} + */ + public function diff(CourierOnboarding $onboarding, array $old, array $new): array + { + $oldModel = $this->draftModel($onboarding, $old); + $newModel = $this->draftModel($onboarding, $new); + $oldColumns = $oldModel->quoteColumns(); + $newColumns = $newModel->quoteColumns(); + $oldRows = collect($oldModel->quoteRowList())->keyBy('region'); + $newRows = collect($newModel->quoteRowList())->keyBy('region'); + $same = $this->sameStructure($new, $old); + + $cells = []; + if ($same) { + foreach ($newRows as $region => $row) { + if (! $oldRows->has($region)) { + continue; + } + $before = CourierQuoteStructure::rowValues($oldRows->get($region), $oldColumns); + $after = CourierQuoteStructure::rowValues($row, $newColumns); + foreach ($newColumns as $index => $column) { + if (round((float) ($before[$index] ?? 0), 4) !== round((float) ($after[$index] ?? 0), 4) || ($before[$index] === null) !== ($after[$index] === null)) { + $cells[] = ['region' => (string) $region, 'label' => $column['label'], 'old' => $before[$index], 'new' => $after[$index]]; + } + } + } + } + + $oldRules = $oldModel->quoteRuleLines(); + $newRules = $newModel->quoteRuleLines(); + $rules = []; + foreach (array_unique([...array_keys($oldRules), ...array_keys($newRules)]) as $index) { + if (($oldRules[$index] ?? '') !== ($newRules[$index] ?? '')) { + $rules[] = ['old' => (string) ($oldRules[$index] ?? '—'), 'new' => (string) ($newRules[$index] ?? '—')]; + } + } + if (($old['quote_note'] ?? null) !== ($new['quote_note'] ?? null)) { + $rules[] = ['old' => '备注:' . ($old['quote_note'] ?: '无'), 'new' => '备注:' . ($new['quote_note'] ?: '无')]; + } + + return [ + 'same_structure' => $same, + 'old_mode' => CourierQuoteStructure::describe($oldModel->quoteStructure()), + 'new_mode' => CourierQuoteStructure::describe($newModel->quoteStructure()), + 'old_columns' => array_column($oldColumns, 'label'), + 'new_columns' => array_column($newColumns, 'label'), + 'cells' => $cells, + 'added' => $newRows->keys()->diff($oldRows->keys())->values()->all(), + 'removed' => $oldRows->keys()->diff($newRows->keys())->values()->all(), + 'rules' => $rules, + ]; + } + + /** 变价单对应的底稿(变价前的版本)报价结构。 */ + public function basePayload(CourierOnboarding $onboarding, CourierQuoteChange $change): array + { + $source = CourierQuoteVersion::query()->where('version_code', $change->source_quote_id)->first(); + $sourceChange = $source?->source_change_id ? CourierQuoteChange::query()->find($source->source_change_id) : null; + + return $sourceChange?->hasQuotePayload() ? $sourceChange->quote_payload : $this->onboardings->quotePayload($onboarding); + } + + /** + * 作废发错的报价版本。还没签完的同时撤回 e签宝 签署任务;已签完的只做内部作废(原件、回签单保留), + * 需另发一版正确报价让快递重新签。 + * + * @return string 给操作人看的结果说明 + */ + public function voidVersion(CourierQuoteVersion $version, string $reason, int $userId): string + { + if ($version->isVoided()) { + throw ValidationException::withMessages(['void_reason' => "{$version->version_code} 已经作废。"]); + } + + $signature = $version->signature()->first(); + $signed = $signature !== null && in_array($signature->status, [ + \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED, + \App\Models\Business\CourierQuoteSignature::STATUS_COMPLETED, + ], true); + $note = ''; + + if ($signature !== null && ! $signed && filled($signature->esign_sign_flow_id) + && $signature->status === \App\Models\Business\CourierQuoteSignature::STATUS_SIGNING) { + try { + app(\App\Services\Esign\EsignClient::class)->revokeSignFlow((string) $signature->esign_sign_flow_id, '报价发错作废:' . $reason); + } catch (\Throwable $exception) { + throw ValidationException::withMessages(['void_reason' => '撤回 e签宝 签署任务失败:' . mb_substr($exception->getMessage(), 0, 200) . '。可先点「同步签署状态」确认快递是否已签。']); + } + $signature->update(['status' => \App\Models\Business\CourierQuoteSignature::STATUS_REVOKED, 'updated_by' => $userId]); + $note = '已撤回快递 e签宝 里的待签任务。'; + } elseif ($signed) { + $note = '快递已签署,回签单保留;请另发一版正确报价让快递重新签署。'; + } + + DB::transaction(function () use ($version, $reason, $userId, $signature, $signed, $note): void { + $version->update([ + 'status' => CourierQuoteVersion::STATUS_VOIDED, + 'signature_status' => $signed ? $version->signature_status : ($signature !== null ? '已撤销' : $version->signature_status), + 'void_reason' => $reason, + 'voided_at' => now(), + 'voided_by' => $userId, + ]); + + if ($version->source_change_id) { + CourierQuoteChangeEvent::create([ + 'company_id' => $version->company_id, + 'quote_change_id' => $version->source_change_id, + 'event_type' => 'voided', + 'from_status' => null, + 'to_status' => null, + 'note' => "报价版本 {$version->version_code} 已作废:{$reason}。{$note}", + 'created_by' => $userId, + ]); + } + }); + + return "{$version->version_code} 已作废,不再参与生效。" . $note; + } + + /** 这家快递当前生效的版本:已签署、未作废、执行日期已到中最新的一版。 */ + /** + * 某一天(默认今天)执行的报价版本:已签署、未作废、执行期间覆盖这一天,取开始日期最晚的一版。 + * 短期变价期间用临时价,到期后自动回到原来的长期报价。 + */ + public function effectiveVersion(string $courierName, ?\DateTimeInterface $date = null): ?CourierQuoteVersion + { + $versions = CourierQuoteVersion::query() + ->with('signature') + ->where('company_name', $courierName) + ->where('status', CourierQuoteVersion::STATUS_APPROVED) + ->get(); + + return self::resolveAt($versions, $date ?? today()); + } + + /** + * @param \Illuminate\Support\Collection $versions 同一家快递的版本 + * @param bool $includeTemporary false 时只看长期报价(算「临时价到期后回到哪一版」) + */ + public static function resolveAt(\Illuminate\Support\Collection $versions, \DateTimeInterface $date, bool $includeTemporary = true): ?CourierQuoteVersion + { + return $versions + ->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided() + && self::isSignedVersion($version) + && ($includeTemporary || ! $version->isTemporary()) + && $version->coversDate($date)) + ->sortBy([ + static fn ($a, $b) => $b->effective_date <=> $a->effective_date, + static fn ($a, $b) => $b->id <=> $a->id, + ]) + ->first(); + } + + public static function isSignedVersion(CourierQuoteVersion $version): bool + { + return $version->signature_status === '已签署并归档' + || $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED; + } + + /** + * 审核提醒:倒签、价格为 0、涨跌幅过大。 + * + * @param array|null $diff + * @return list + */ + public function reviewWarnings(CourierQuoteChange $change, ?array $diff): array + { + $warnings = []; + $current = $this->effectiveVersion((string) $change->carrier_name); + if ($current !== null && $change->effective_date->lt($current->effective_date)) { + $warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于当前生效版本 {$current->version_code}({$current->effective_date->toDateString()} 起),属于倒签,会改动已发生运单的运费。"; + } elseif ($change->effective_date->lt(today())) { + $warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于今天,属于倒签,会改动已发生运单的运费。"; + } + + if ($change->end_date !== null) { + $others = CourierQuoteVersion::query() + ->where('company_name', (string) $change->carrier_name) + ->where('status', CourierQuoteVersion::STATUS_APPROVED) + ->where(static fn ($query) => $query->whereNull('source_change_id')->orWhere('source_change_id', '!=', $change->id)) + ->get(); + $base = self::resolveAt($others, $change->end_date->copy()->addDay(), false); + $warnings[] = $base !== null + ? "这是短期变价:{$change->effective_date->toDateString()} 至 {$change->end_date->toDateString()} 执行,{$change->end_date->copy()->addDay()->toDateString()} 起自动恢复 {$base->version_code}({$base->periodLabel()})。" + : "这是短期变价,但到期后({$change->end_date->copy()->addDay()->toDateString()} 起)没有可以恢复的已签署长期报价,到期后这家快递将没有生效报价。"; + $overlap = $others->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $version->isTemporary() + && $version->effective_date->lte($change->end_date) && $version->end_date->gte($change->effective_date)); + if ($overlap->isNotEmpty()) { + $warnings[] = '和已有的短期变价执行期间重叠:' . $overlap->map(static fn (CourierQuoteVersion $version): string => $version->version_code . '(' . $version->periodLabel() . ')')->implode('、') . ',重叠的日期以开始日期较晚的一份为准。'; + } + } + + $big = []; + foreach ($diff['cells'] ?? [] as $cell) { + $old = $cell['old']; + $new = $cell['new']; + if ($old !== null && $new !== null && (float) $old > 0 && abs((float) $new - (float) $old) / (float) $old > self::LARGE_CHANGE) { + $big[] = $cell['region'] . ' ' . $cell['label'] . ' ' . CourierQuoteStructure::kg((float) $old) . '→' . CourierQuoteStructure::kg((float) $new); + } + } + if ($big !== []) { + $warnings[] = '涨跌超过 ' . (int) (self::LARGE_CHANGE * 100) . '%:' . implode(';', array_slice($big, 0, 8)) . (count($big) > 8 ? ' 等 ' . count($big) . ' 处' : '') . ',请核对是否多打或少打了位数。'; + } + + $zeros = []; + $table = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable(json_decode((string) $change->proposed_values_json, true) ?: []); + foreach (array_slice($table, 1) as $row) { + foreach (array_slice($row, 1, null, true) as $index => $value) { + if (is_numeric($value) && (float) $value === 0.0) { + $zeros[] = $row[0] . ' ' . ($table[0][$index] ?? ''); + } + } + } + if ($zeros !== []) { + $warnings[] = '价格为 0:' . implode('、', array_slice($zeros, 0, 8)) . (count($zeros) > 8 ? ' 等 ' . count($zeros) . ' 处' : '') . '。'; + } + + return $warnings; + } + + private function sameStructure(array $a, array $b): bool + { + $shape = static fn (array $payload): array => CourierQuoteStructure::columns( + is_array($payload['quote_structure'] ?? null) ? $payload['quote_structure'] : [] + ); + + return $shape($a) === $shape($b); + } + + private function comparable(array $payload): string + { + $rows = array_map(static fn (array $row): array => [ + 'region' => $row['region'] ?? '', + 'flat' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['flat'] ?? [])), + 'base' => isset($row['base']) ? round((float) $row['base'], 4) : null, + 'rates' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['rates'] ?? [])), + ], array_values(array_filter((array) ($payload['quote_rows'] ?? []), 'is_array'))); + $copy = $payload; + $copy['quote_rows'] = $rows; + unset($copy['quote_effective_date']); + foreach (['min_weight', 'per_ticket_surcharge'] as $key) { + $copy[$key] = isset($copy[$key]) ? round((float) $copy[$key], 4) : null; + } + $copy['volumetric_divisor'] = isset($copy['volumetric_divisor']) ? (int) $copy['volumetric_divisor'] : null; + ksort($copy); + + return json_encode($copy, JSON_UNESCAPED_UNICODE); + } + + private function nextChangeNumber(): string + { + $prefix = 'BG-' . now()->format('Ymd') . '-'; + $last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); + $sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1; + + return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); + } +} diff --git a/app/Support/QrCode.php b/app/Support/QrCode.php new file mode 100644 index 00000000..1fda89ec --- /dev/null +++ b/app/Support/QrCode.php @@ -0,0 +1,500 @@ +> */ + private array $modules = []; + + /** @var list> */ + private array $isFunction = []; + + private function __construct(string $text) + { + $bytes = array_values(unpack('C*', $text) ?: []); + $this->version = $this->chooseVersion(count($bytes)); + $this->size = $this->version * 4 + 17; + $row = array_fill(0, $this->size, false); + $this->modules = array_fill(0, $this->size, $row); + $this->isFunction = array_fill(0, $this->size, $row); + + $this->drawFunctionPatterns(); + $this->drawCodewords($this->addEccAndInterleave($this->dataCodewords($bytes))); + $this->applyBestMask(); + } + + public static function svg(string $text, int $scale = 8, int $quietZone = 4): string + { + if ($text === '') { + throw new InvalidArgumentException('二维码内容不能为空。'); + } + + $qr = new self($text); + $dimension = $qr->size + $quietZone * 2; + $path = ''; + + for ($y = 0; $y < $qr->size; $y++) { + for ($x = 0; $x < $qr->size; $x++) { + if ($qr->modules[$y][$x]) { + $path .= sprintf('M%d %dh1v1h-1z', $x + $quietZone, $y + $quietZone); + } + } + } + + $pixels = $dimension * $scale; + + return sprintf( + '' + . '', + $dimension, + $pixels, + $path, + ); + } + + /** + * @return list> 供测试校验的模块矩阵(true 为黑)。 + */ + public static function matrix(string $text): array + { + return (new self($text))->modules; + } + + private function chooseVersion(int $byteCount): int + { + for ($version = 1; $version <= 40; $version++) { + $capacityBits = $this->numDataCodewords($version) * 8; + $usedBits = 4 + ($version < 10 ? 8 : 16) + $byteCount * 8; + if ($usedBits <= $capacityBits) { + return $version; + } + } + + throw new InvalidArgumentException('二维码内容过长。'); + } + + /** + * @param list $bytes + * @return list + */ + private function dataCodewords(array $bytes): array + { + $bits = []; + $append = static function (int $value, int $length) use (&$bits): void { + for ($i = $length - 1; $i >= 0; $i--) { + $bits[] = ($value >> $i) & 1; + } + }; + + $append(0b0100, 4); + $append(count($bytes), $this->version < 10 ? 8 : 16); + foreach ($bytes as $byte) { + $append($byte, 8); + } + + $capacityBits = $this->numDataCodewords($this->version) * 8; + $append(0, min(4, $capacityBits - count($bits))); + $append(0, (8 - count($bits) % 8) % 8); + + $codewords = []; + foreach (array_chunk($bits, 8) as $chunk) { + $value = 0; + foreach ($chunk as $bit) { + $value = ($value << 1) | $bit; + } + $codewords[] = $value; + } + + for ($pad = 0xEC; count($codewords) < $capacityBits / 8; $pad ^= 0xEC ^ 0x11) { + $codewords[] = $pad; + } + + return $codewords; + } + + /** + * @param list $data + * @return list + */ + private function addEccAndInterleave(array $data): array + { + $numBlocks = self::NUM_ERROR_CORRECTION_BLOCKS[$this->version]; + $blockEccLength = self::ECC_CODEWORDS_PER_BLOCK[$this->version]; + $rawCodewords = intdiv($this->numRawDataModules($this->version), 8); + $numShortBlocks = $numBlocks - $rawCodewords % $numBlocks; + $shortBlockLength = intdiv($rawCodewords, $numBlocks); + $divisor = $this->reedSolomonDivisor($blockEccLength); + + $blocks = []; + $offset = 0; + for ($i = 0; $i < $numBlocks; $i++) { + $length = $shortBlockLength - $blockEccLength + ($i < $numShortBlocks ? 0 : 1); + $block = array_slice($data, $offset, $length); + $offset += $length; + $ecc = $this->reedSolomonRemainder($block, $divisor); + if ($i < $numShortBlocks) { + $block[] = 0; + } + $blocks[] = array_merge($block, $ecc); + } + + $result = []; + $blockLength = count($blocks[0]); + for ($i = 0; $i < $blockLength; $i++) { + foreach ($blocks as $j => $block) { + if ($i !== $shortBlockLength - $blockEccLength || $j >= $numShortBlocks) { + $result[] = $block[$i]; + } + } + } + + return $result; + } + + private function drawFunctionPatterns(): void + { + for ($i = 0; $i < $this->size; $i++) { + $this->setFunction(6, $i, $i % 2 === 0); + $this->setFunction($i, 6, $i % 2 === 0); + } + + $this->drawFinder(3, 3); + $this->drawFinder($this->size - 4, 3); + $this->drawFinder(3, $this->size - 4); + + $positions = $this->alignmentPositions(); + $count = count($positions); + foreach ($positions as $i => $x) { + foreach ($positions as $j => $y) { + if (($i === 0 && $j === 0) || ($i === 0 && $j === $count - 1) || ($i === $count - 1 && $j === 0)) { + continue; + } + for ($dy = -2; $dy <= 2; $dy++) { + for ($dx = -2; $dx <= 2; $dx++) { + $this->setFunction($x + $dx, $y + $dy, max(abs($dx), abs($dy)) !== 1); + } + } + } + } + + $this->drawFormatBits(0); + $this->drawVersion(); + } + + private function drawFinder(int $x, int $y): void + { + for ($dy = -4; $dy <= 4; $dy++) { + for ($dx = -4; $dx <= 4; $dx++) { + $xx = $x + $dx; + $yy = $y + $dy; + if ($xx >= 0 && $xx < $this->size && $yy >= 0 && $yy < $this->size) { + $distance = max(abs($dx), abs($dy)); + $this->setFunction($xx, $yy, $distance !== 2 && $distance !== 4); + } + } + } + } + + private function drawFormatBits(int $mask): void + { + $data = (self::FORMAT_BITS_M << 3) | $mask; + $remainder = $data; + for ($i = 0; $i < 10; $i++) { + $remainder = ($remainder << 1) ^ (($remainder >> 9) * 0x537); + } + $bits = (($data << 10) | $remainder) ^ 0x5412; + $bit = static fn (int $index): bool => (($bits >> $index) & 1) !== 0; + + for ($i = 0; $i <= 5; $i++) { + $this->setFunction(8, $i, $bit($i)); + } + $this->setFunction(8, 7, $bit(6)); + $this->setFunction(8, 8, $bit(7)); + $this->setFunction(7, 8, $bit(8)); + for ($i = 9; $i < 15; $i++) { + $this->setFunction(14 - $i, 8, $bit($i)); + } + + for ($i = 0; $i < 8; $i++) { + $this->setFunction($this->size - 1 - $i, 8, $bit($i)); + } + for ($i = 8; $i < 15; $i++) { + $this->setFunction(8, $this->size - 15 + $i, $bit($i)); + } + $this->setFunction(8, $this->size - 8, true); + } + + private function drawVersion(): void + { + if ($this->version < 7) { + return; + } + + $remainder = $this->version; + for ($i = 0; $i < 12; $i++) { + $remainder = ($remainder << 1) ^ (($remainder >> 11) * 0x1F25); + } + $bits = ($this->version << 12) | $remainder; + + for ($i = 0; $i < 18; $i++) { + $dark = (($bits >> $i) & 1) !== 0; + $a = $this->size - 11 + $i % 3; + $b = intdiv($i, 3); + $this->setFunction($a, $b, $dark); + $this->setFunction($b, $a, $dark); + } + } + + /** + * @param list $codewords + */ + private function drawCodewords(array $codewords): void + { + $totalBits = count($codewords) * 8; + $index = 0; + + for ($right = $this->size - 1; $right >= 1; $right -= 2) { + if ($right === 6) { + $right = 5; + } + for ($vertical = 0; $vertical < $this->size; $vertical++) { + for ($j = 0; $j < 2; $j++) { + $x = $right - $j; + $upward = (($right + 1) & 2) === 0; + $y = $upward ? $this->size - 1 - $vertical : $vertical; + if (! $this->isFunction[$y][$x] && $index < $totalBits) { + $this->modules[$y][$x] = (($codewords[$index >> 3] >> (7 - ($index & 7))) & 1) !== 0; + $index++; + } + } + } + } + } + + private function applyBestMask(): void + { + $bestMask = 0; + $bestPenalty = PHP_INT_MAX; + + for ($mask = 0; $mask < 8; $mask++) { + $this->applyMask($mask); + $this->drawFormatBits($mask); + $penalty = $this->penaltyScore(); + if ($penalty < $bestPenalty) { + $bestPenalty = $penalty; + $bestMask = $mask; + } + $this->applyMask($mask); + } + + $this->applyMask($bestMask); + $this->drawFormatBits($bestMask); + } + + private function applyMask(int $mask): void + { + for ($y = 0; $y < $this->size; $y++) { + for ($x = 0; $x < $this->size; $x++) { + if ($this->isFunction[$y][$x]) { + continue; + } + $invert = match ($mask) { + 0 => ($x + $y) % 2 === 0, + 1 => $y % 2 === 0, + 2 => $x % 3 === 0, + 3 => ($x + $y) % 3 === 0, + 4 => (intdiv($x, 3) + intdiv($y, 2)) % 2 === 0, + 5 => $x * $y % 2 + $x * $y % 3 === 0, + 6 => ($x * $y % 2 + $x * $y % 3) % 2 === 0, + default => (($x + $y) % 2 + $x * $y % 3) % 2 === 0, + }; + if ($invert) { + $this->modules[$y][$x] = ! $this->modules[$y][$x]; + } + } + } + } + + private function penaltyScore(): int + { + $size = $this->size; + $penalty = 0; + $dark = 0; + + $lineScore = function (callable $cell) use ($size): int { + $score = 0; + for ($line = 0; $line < $size; $line++) { + $run = 1; + $sequence = ''; + for ($i = 0; $i < $size; $i++) { + $current = $cell($line, $i); + $sequence .= $current ? '1' : '0'; + if ($i > 0 && $current === $cell($line, $i - 1)) { + $run++; + } else { + if ($run >= 5) { + $score += $run - 2; + } + $run = 1; + } + } + if ($run >= 5) { + $score += $run - 2; + } + $padded = '0000' . $sequence . '0000'; + $score += 40 * (substr_count($padded, '00001011101') + substr_count($padded, '10111010000')); + } + + return $score; + }; + + $penalty += $lineScore(fn (int $line, int $i): bool => $this->modules[$line][$i]); + $penalty += $lineScore(fn (int $line, int $i): bool => $this->modules[$i][$line]); + + for ($y = 0; $y < $size; $y++) { + for ($x = 0; $x < $size; $x++) { + if ($this->modules[$y][$x]) { + $dark++; + } + if ($x < $size - 1 && $y < $size - 1) { + $color = $this->modules[$y][$x]; + if ($color === $this->modules[$y][$x + 1] && $color === $this->modules[$y + 1][$x] && $color === $this->modules[$y + 1][$x + 1]) { + $penalty += 3; + } + } + } + } + + $total = $size * $size; + $k = intdiv(abs($dark * 20 - $total * 10) + $total - 1, $total) - 1; + + return $penalty + max(0, $k) * 10; + } + + /** + * @return list + */ + private function alignmentPositions(): array + { + if ($this->version === 1) { + return []; + } + + $count = intdiv($this->version, 7) + 2; + $step = $this->version === 32 + ? 26 + : (int) ceil(($this->version * 4 + 4) / ($count * 2 - 2)) * 2; + $positions = [6]; + for ($position = $this->size - 7; count($positions) < $count; $position -= $step) { + array_splice($positions, 1, 0, [$position]); + } + + return $positions; + } + + private function setFunction(int $x, int $y, bool $dark): void + { + $this->modules[$y][$x] = $dark; + $this->isFunction[$y][$x] = true; + } + + private function numRawDataModules(int $version): int + { + $result = (16 * $version + 128) * $version + 64; + if ($version >= 2) { + $alignmentCount = intdiv($version, 7) + 2; + $result -= (25 * $alignmentCount - 10) * $alignmentCount - 55; + if ($version >= 7) { + $result -= 36; + } + } + + return $result; + } + + private function numDataCodewords(int $version): int + { + return intdiv($this->numRawDataModules($version), 8) + - self::ECC_CODEWORDS_PER_BLOCK[$version] * self::NUM_ERROR_CORRECTION_BLOCKS[$version]; + } + + /** + * @return list + */ + private function reedSolomonDivisor(int $degree): array + { + $result = array_fill(0, $degree, 0); + $result[$degree - 1] = 1; + $root = 1; + + for ($i = 0; $i < $degree; $i++) { + for ($j = 0; $j < $degree; $j++) { + $result[$j] = $this->gfMultiply($result[$j], $root); + if ($j + 1 < $degree) { + $result[$j] ^= $result[$j + 1]; + } + } + $root = $this->gfMultiply($root, 0x02); + } + + return $result; + } + + /** + * @param list $data + * @param list $divisor + * @return list + */ + private function reedSolomonRemainder(array $data, array $divisor): array + { + $result = array_fill(0, count($divisor), 0); + + foreach ($data as $byte) { + $factor = $byte ^ array_shift($result); + $result[] = 0; + foreach ($divisor as $i => $coefficient) { + $result[$i] ^= $this->gfMultiply($coefficient, $factor); + } + } + + return $result; + } + + private function gfMultiply(int $x, int $y): int + { + $z = 0; + for ($i = 7; $i >= 0; $i--) { + $z = ($z << 1) ^ (($z >> 7) * 0x11D); + $z ^= (($y >> $i) & 1) * $x; + } + + return $z & 0xFF; + } +} diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index 36e7b73b..db9620d0 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -2,6 +2,7 @@ namespace App\Support; +use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; use Illuminate\Support\Facades\Schema; use JsonException; @@ -17,24 +18,32 @@ final class QuoteManagementSnapshot public function load(): array { $path = $this->path ?? storage_path(self::RELATIVE_PATH); + $versions = $this->versionQuotes(); + $payload = null; + $failure = null; if (! is_file($path)) { - return $this->emptySnapshot('missing', '尚未导入本机报价文件。'); + $failure = $this->emptySnapshot('missing', '尚未导入本机报价文件。'); + } else { + try { + $payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $failure = $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。'); + } + + if ($failure === null && (! is_array($payload) || ! is_array($payload['sheets'] ?? null))) { + $failure = $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。'); + } } - try { - $payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - return $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。'); - } - - if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) { - return $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。'); + // 没有 Excel 快照时,系统内生成的报价版本(变价、快递入驻)照常显示。 + if ($failure !== null && $versions === []) { + return $failure; } $quotes = []; - foreach (array_values($payload['sheets']) as $position => $sheet) { + foreach (array_values($failure === null ? $payload['sheets'] : []) as $position => $sheet) { if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') { continue; } @@ -42,17 +51,13 @@ final class QuoteManagementSnapshot $quotes[] = $this->normalizeSheet($sheet, $position); } - if (Schema::hasTable('business_courier_quote_versions')) { - foreach (CourierQuoteVersion::query()->orderBy('id')->get() as $version) { - $quotes[] = $this->normalizeVersion($version); - } - } + array_push($quotes, ...$versions); return [ 'available' => true, 'state' => 'ready', 'message' => null, - 'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '报价单合并.xlsx'))), + 'source_file' => basename(str_replace('\\', '/', (string) (($payload['sourcePath'] ?? null) ?: '报价单合并.xlsx'))), 'quotes' => $quotes, 'summary' => [ 'quote_count' => count($quotes), @@ -63,6 +68,44 @@ final class QuoteManagementSnapshot ]; } + /** @return list> */ + private function versionQuotes(): array + { + if (! Schema::hasTable('business_courier_quote_versions')) { + return []; + } + + // 已入驻快递的版本,「变价」直接进后台变价页(不经过 …/create,避免被套餐限制中间件拦到用户页)。 + $onboarded = Schema::hasTable('business_courier_onboardings') + ? app(CourierRequoteService::class)->onboardedCourierIds() + : []; + + $versions = CourierQuoteVersion::query()->with('signature')->orderBy('id')->get(); + $superseded = $this->supersededVersions($versions); + $changeTypes = \App\Models\Business\CourierQuoteChange::query() + ->whereIn('id', $versions->pluck('source_change_id')->filter()->unique()->all()) + ->pluck('change_type', 'id') + ->all(); + + return $versions + ->map(function (CourierQuoteVersion $version) use ($onboarded, $changeTypes, $superseded): array { + $quote = $this->normalizeVersion($version); + $quote['void_reason'] = $version->isVoided() ? $version->void_reason : null; + if (isset($superseded[$version->id])) { + $quote = [...$quote, ...$superseded[$version->id]]; + } + $changeType = $changeTypes[$version->source_change_id] ?? null; + if (in_array($changeType, [CourierRequoteService::CHANGE_PRICE, CourierRequoteService::CHANGE_STRUCTURE], true)) { + $quote['quote_type'] = CourierRequoteService::quoteTypeFor($changeType); + } + $onboardingId = $onboarded[$version->company_name] ?? null; + $quote['requote_url'] = $onboardingId ? route('business.courier-onboardings.requote', $onboardingId) : null; + + return $quote; + }) + ->all(); + } + private function normalizeSheet(array $sheet, int $position): array { $name = trim((string) $sheet['name']); @@ -84,6 +127,8 @@ final class QuoteManagementSnapshot 'formula_count' => $this->formulaCount($formulas), 'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期', 'signature_status' => 'Excel 未提供', + 'signed_files' => [], + 'signed_at' => null, 'values' => $values, 'formulas' => $formulas, ]; @@ -110,6 +155,8 @@ final class QuoteManagementSnapshot 'carrier' => $version->carrier_name, 'quote_type' => $version->quote_type, 'effective_date' => $version->effective_date?->toDateString(), + 'end_date' => $version->end_date?->toDateString(), + 'period_label' => $version->isTemporary() ? $version->periodLabel() : null, 'source_range' => $version->source_range ?? '', 'row_count' => count($values), 'column_count' => $columnCount, @@ -119,9 +166,35 @@ final class QuoteManagementSnapshot 'values' => $values, 'formulas' => $formulas, 'source_change_id' => $version->source_change_id, + 'signed_files' => $this->signedFiles($version), + 'signed_at' => $version->signature?->completed_at?->format('Y-m-d H:i') + ?? $version->signature?->archived_at?->format('Y-m-d H:i'), ]; } + /** + * 已签署归档的回签单电子档下载地址(签署 PDF、签署完成证书、批量导出 zip)。 + * + * @return array + */ + private function signedFiles(CourierQuoteVersion $version): array + { + $signature = $version->signature; + if ($signature === null || $signature->status !== CourierQuoteSignature::STATUS_ARCHIVED || blank($signature->signed_pdf_path)) { + return []; + } + + $files = [ + 'signed' => route('business.courier-quote-signatures.download', [$signature, 'signed']), + 'export' => route('business.courier-quote-signatures.sheet', $version), + ]; + if (filled($signature->certificate_path)) { + $files['certificate'] = route('business.courier-quote-signatures.download', [$signature, 'certificate']); + } + + return $files; + } + private function decodeMatrix(?string $json): array { if ($json === null || trim($json) === '') { @@ -137,11 +210,63 @@ final class QuoteManagementSnapshot private function versionLifecycleStatus(CourierQuoteVersion $version): string { - if ($version->signature_status !== '已签署并归档') { + if ($version->isVoided()) { + return '已作废'; + } + + if (! $this->isSigned($version)) { return '待生效(待签章)'; } - return $version->effective_date?->isFuture() ? '待生效' : '生效中'; + if ($version->effective_date?->isFuture()) { + return $version->isTemporary() ? '待生效(临时)' : '待生效'; + } + + return $version->isTemporary() ? '生效中(临时)' : '生效中'; + } + + /** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */ + private function isSigned(CourierQuoteVersion $version): bool + { + return $version->signature_status === '已签署并归档' + || $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED; + } + + /** + * 同一家快递有多个已签署版本时,今天执行的那一版是「生效中」; + * 短期变价期间,原来的长期报价显示「暂停中」,到期后自动恢复;过了结束日期的短期变价显示「已到期」; + * 其余更早的版本标为「已失效」并注明被哪一版替代。 + * + * @param \Illuminate\Support\Collection $versions + * @return array 版本 id => 状态覆盖 + */ + private function supersededVersions(\Illuminate\Support\Collection $versions): array + { + $result = []; + $today = today(); + foreach ($versions->groupBy('company_name') as $group) { + $current = CourierRequoteService::resolveAt($group, $today); + $base = CourierRequoteService::resolveAt($group, $today, false); + $started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version) + && $version->effective_date !== null && $version->effective_date->lte($today)); + foreach ($started as $version) { + if ($current !== null && $version->id === $current->id) { + if ($version->isTemporary() && $base !== null) { + $result[$version->id] = ['status' => '生效中(临时)', 'status_note' => $version->end_date->copy()->addDay()->toDateString() . ' 起恢复 ' . $base->version_code]; + } + continue; + } + if ($version->isTemporary() && $version->end_date->lt($today)) { + $result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $version->periodLabel()]; + } elseif ($base !== null && $version->id === $base->id && $current?->isTemporary()) { + $result[$version->id] = ['status' => '暂停中', 'status_note' => $current->version_code . ' 临时价执行中,' . $current->end_date->copy()->addDay()->toDateString() . ' 起恢复']; + } elseif ($current !== null) { + $result[$version->id] = ['status' => '已失效', 'superseded_by' => $current->version_code]; + } + } + } + + return $result; } private function normalizeMatrix(mixed $matrix): array diff --git a/config/business_navigation.php b/config/business_navigation.php index 8dfb0429..0095ec02 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -706,6 +706,7 @@ $navigation = [ 'title' => '快递报价', 'children' => [ ['slug' => 'quote-management', 'title' => '报价管理'], + ['slug' => 'courier-onboarding', 'title' => '快递入驻'], ['slug' => 'courier-information', 'title' => '快递资料'], ['slug' => 'agreement-signing', 'title' => '协议签署'], [ diff --git a/config/courier_onboarding.php b/config/courier_onboarding.php new file mode 100644 index 00000000..9130d608 --- /dev/null +++ b/config/courier_onboarding.php @@ -0,0 +1,10 @@ + env('COURIER_ONBOARDING_PUBLIC_URL'), + + // 是否允许初审人自己做财务复审。默认不允许(两人把关);测试/预览环境可打开方便一个人走完流程。 + 'allow_same_reviewer' => (bool) env('COURIER_ONBOARDING_ALLOW_SAME_REVIEWER', false), +]; diff --git a/database/migrations/2026_10_12_000000_create_business_courier_onboarding_tables.php b/database/migrations/2026_10_12_000000_create_business_courier_onboarding_tables.php new file mode 100644 index 00000000..a7db0868 --- /dev/null +++ b/database/migrations/2026_10_12_000000_create_business_courier_onboarding_tables.php @@ -0,0 +1,127 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('number', 32); + $table->string('token', 64); + $table->string('status', 32)->default('invited'); + $table->string('invite_label', 100)->nullable(); + $table->timestamp('expires_at')->nullable(); + + // 快递方填写:主体与联系人(company 公司 / person 个人) + $table->string('entity_type', 16)->default('company'); + $table->text('id_number')->nullable(); + $table->string('company_full_name')->nullable(); + $table->string('unified_social_credit_code', 32)->nullable(); + $table->string('legal_representative', 100)->nullable(); + $table->string('business_address')->nullable(); + $table->string('carrier_brand', 50)->nullable(); + $table->string('network_name', 100)->nullable(); + $table->string('service_type', 32)->nullable(); + $table->string('contact_name', 100)->nullable(); + $table->string('contact_phone', 32)->nullable(); + $table->string('finance_contact_name', 100)->nullable(); + $table->string('finance_contact_phone', 32)->nullable(); + $table->text('pickup_warehouses')->nullable(); + $table->string('esign_operator_name', 100)->nullable(); + $table->string('esign_account')->nullable(); + + // 快递方填写:结算与开票 + $table->string('bank_account_name')->nullable(); + $table->string('bank_name')->nullable(); + $table->text('bank_account_number')->nullable(); + $table->string('invoice_type', 32)->nullable(); + $table->decimal('tax_rate', 6, 2)->nullable(); + $table->string('settlement_cycle', 32)->nullable(); + + // 快递方填写:首版报价 + $table->string('quote_mode', 32)->nullable(); + $table->string('volumetric_rule', 32)->nullable(); + $table->unsignedInteger('volumetric_divisor')->nullable(); + $table->date('quote_effective_date')->nullable(); + $table->longText('quote_structure')->nullable(); + $table->longText('quote_rows')->nullable(); + $table->decimal('min_weight', 8, 2)->nullable(); + $table->string('weight_rounding', 16)->nullable(); + $table->decimal('per_ticket_surcharge', 8, 2)->nullable(); + $table->text('quote_note')->nullable(); + $table->text('remark')->nullable(); + + // 内部审核与开通 + $table->string('courier_name')->nullable(); + $table->string('reconciler', 100)->nullable(); + $table->text('return_reason')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->unsignedInteger('first_reviewed_by')->nullable(); + $table->timestamp('first_reviewed_at')->nullable(); + $table->unsignedInteger('final_reviewed_by')->nullable(); + $table->timestamp('final_reviewed_at')->nullable(); + $table->unsignedInteger('quote_change_id')->nullable(); + $table->unsignedInteger('quote_version_id')->nullable(); + $table->unsignedInteger('activated_by')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('token', 'courier_onboardings_token_unique'); + $table->unique(['company_id', 'number'], 'courier_onboardings_company_number_unique'); + $table->index(['company_id', 'status'], 'courier_onboardings_company_status_index'); + $table->index(['company_id', 'courier_name'], 'courier_onboardings_company_courier_index'); + }); + } + + if (! Schema::hasTable('business_courier_onboarding_files')) { + Schema::create('business_courier_onboarding_files', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('onboarding_id'); + $table->string('kind', 40); + $table->string('original_name'); + $table->string('path', 1024); + $table->string('mime', 100)->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'onboarding_id'], 'courier_onboarding_files_onboarding_index'); + }); + } + + if (! Schema::hasTable('business_courier_onboarding_events')) { + Schema::create('business_courier_onboarding_events', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('onboarding_id'); + $table->string('event_type', 32); + $table->string('from_status', 32)->nullable(); + $table->string('to_status', 32)->nullable(); + $table->text('note')->nullable(); + $table->string('actor_type', 16)->default('user'); + $table->unsignedInteger('actor_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'onboarding_id'], 'courier_onboarding_events_onboarding_index'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('business_courier_onboarding_events'); + Schema::dropIfExists('business_courier_onboarding_files'); + Schema::dropIfExists('business_courier_onboardings'); + } +}; diff --git a/database/migrations/2026_10_13_000000_add_quote_payload_to_business_courier_quote_changes.php b/database/migrations/2026_10_13_000000_add_quote_payload_to_business_courier_quote_changes.php new file mode 100644 index 00000000..f9daff81 --- /dev/null +++ b/database/migrations/2026_10_13_000000_add_quote_payload_to_business_courier_quote_changes.php @@ -0,0 +1,43 @@ +longText('quote_payload')->nullable(); + } + if (! Schema::hasColumn('business_courier_quote_changes', 'proposed_values_json')) { + $table->longText('proposed_values_json')->nullable(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('business_courier_quote_changes')) { + return; + } + + Schema::table('business_courier_quote_changes', function (Blueprint $table): void { + foreach (['quote_payload', 'proposed_values_json'] as $column) { + if (Schema::hasColumn('business_courier_quote_changes', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2026_10_13_100000_add_void_columns_to_business_courier_quote_versions.php b/database/migrations/2026_10_13_100000_add_void_columns_to_business_courier_quote_versions.php new file mode 100644 index 00000000..227c6f8e --- /dev/null +++ b/database/migrations/2026_10_13_100000_add_void_columns_to_business_courier_quote_versions.php @@ -0,0 +1,45 @@ +text('void_reason')->nullable(); + } + if (! Schema::hasColumn('business_courier_quote_versions', 'voided_at')) { + $table->timestamp('voided_at')->nullable(); + } + if (! Schema::hasColumn('business_courier_quote_versions', 'voided_by')) { + $table->unsignedInteger('voided_by')->nullable(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('business_courier_quote_versions')) { + return; + } + + Schema::table('business_courier_quote_versions', function (Blueprint $table): void { + foreach (['void_reason', 'voided_at', 'voided_by'] as $column) { + if (Schema::hasColumn('business_courier_quote_versions', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2026_10_13_200000_add_private_account_to_business_courier_onboardings.php b/database/migrations/2026_10_13_200000_add_private_account_to_business_courier_onboardings.php new file mode 100644 index 00000000..b4383da8 --- /dev/null +++ b/database/migrations/2026_10_13_200000_add_private_account_to_business_courier_onboardings.php @@ -0,0 +1,59 @@ +string('private_account_name')->nullable(); + } + if (! Schema::hasColumn('business_courier_onboardings', 'private_bank_name')) { + $table->string('private_bank_name')->nullable(); + } + if (! Schema::hasColumn('business_courier_onboardings', 'private_account_number')) { + $table->text('private_account_number')->nullable(); + } + }); + + DB::table('business_courier_onboardings') + ->where('entity_type', 'person') + ->whereNull('private_account_name') + ->update([ + 'private_account_name' => DB::raw('bank_account_name'), + 'private_bank_name' => DB::raw('bank_name'), + 'private_account_number' => DB::raw('bank_account_number'), + 'bank_account_name' => null, + 'bank_name' => null, + 'bank_account_number' => null, + ]); + } + + public function down(): void + { + if (! Schema::hasTable('business_courier_onboardings')) { + return; + } + + Schema::table('business_courier_onboardings', function (Blueprint $table): void { + foreach (['private_account_name', 'private_bank_name', 'private_account_number'] as $column) { + if (Schema::hasColumn('business_courier_onboardings', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2026_10_14_000000_add_erp_opening_to_business_courier_onboardings.php b/database/migrations/2026_10_14_000000_add_erp_opening_to_business_courier_onboardings.php new file mode 100644 index 00000000..5e4cd90b --- /dev/null +++ b/database/migrations/2026_10_14_000000_add_erp_opening_to_business_courier_onboardings.php @@ -0,0 +1,54 @@ +string('workplatform_expcode', 64)->nullable(); + } + if (! Schema::hasColumn('business_courier_onboardings', 'erp_account_id')) { + $table->string('erp_account_id', 64)->nullable(); + } + if (! Schema::hasColumn('business_courier_onboardings', 'erp_opened_at')) { + $table->timestamp('erp_opened_at')->nullable(); + } + if (! Schema::hasColumn('business_courier_onboardings', 'erp_opened_source')) { + $table->string('erp_opened_source', 32)->nullable(); + } + if (! Schema::hasColumn('business_courier_onboardings', 'dingtalk_instance_id')) { + $table->string('dingtalk_instance_id', 64)->nullable(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('business_courier_onboardings')) { + return; + } + + Schema::table('business_courier_onboardings', function (Blueprint $table): void { + foreach (self::COLUMNS as $column) { + if (Schema::hasColumn('business_courier_onboardings', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2026_10_14_100000_create_business_courier_profile_tables.php b/database/migrations/2026_10_14_100000_create_business_courier_profile_tables.php new file mode 100644 index 00000000..598f77c7 --- /dev/null +++ b/database/migrations/2026_10_14_100000_create_business_courier_profile_tables.php @@ -0,0 +1,79 @@ +id(); + $table->unsignedInteger('company_id'); + $table->string('expcode', 64); + $table->string('courier_name')->nullable(); + $table->text('overrides')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['company_id', 'expcode']); + }); + } + + if (! Schema::hasTable('business_courier_profile_events')) { + Schema::create('business_courier_profile_events', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('company_id'); + $table->string('expcode', 64); + $table->string('action', 32); + $table->string('summary', 500); + $table->text('changes')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['company_id', 'expcode']); + }); + } + + if (! Schema::hasTable('business_courier_payment_accounts')) { + Schema::create('business_courier_payment_accounts', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('company_id'); + $table->string('expcode', 64); + $table->string('courier_name')->nullable(); + $table->string('action', 16); + $table->string('account_type', 16); + $table->string('account_name'); + $table->string('bank_name'); + $table->text('account_number'); + $table->date('effective_date')->nullable(); + $table->string('old_account_name')->nullable(); + $table->text('old_account_number')->nullable(); + $table->date('old_stop_date')->nullable(); + $table->date('change_date')->nullable(); + $table->string('note', 500)->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['company_id', 'expcode']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('business_courier_payment_accounts'); + Schema::dropIfExists('business_courier_profile_events'); + Schema::dropIfExists('business_courier_profiles'); + } +}; diff --git a/database/migrations/2026_10_14_200000_add_end_date_to_business_courier_quote_tables.php b/database/migrations/2026_10_14_200000_add_end_date_to_business_courier_quote_tables.php new file mode 100644 index 00000000..ff1bfd83 --- /dev/null +++ b/database/migrations/2026_10_14_200000_add_end_date_to_business_courier_quote_tables.php @@ -0,0 +1,36 @@ +date('end_date')->nullable()->after('effective_date'); + }); + } + } + } + + public function down(): void + { + foreach (self::TABLES as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'end_date')) { + Schema::table($table, function (Blueprint $blueprint): void { + $blueprint->dropColumn('end_date'); + }); + } + } + } +}; diff --git a/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php index 4d14024b..9549b98a 100644 --- a/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierInformationMirrorRepository.php @@ -230,6 +230,39 @@ class CourierInformationMirrorRepository ]; } + /** + * 快递总览用:工作平台全部快递 + 按快递代码归组的打款账户。镜像未就绪返回 null。 + * + * @return array{express:list,accounts:array>}|null + */ + public function overviewSource(): ?array + { + if (! $this->available() || ! $this->ready()) { + return null; + } + + $express = $this->connection->table($this->dataset()->targetTable()) + ->select(['Id', 'expcode', 'expname', 'status', 'linker', 'linkmobile', 'paymethod', 'is_delete', 'modified']) + ->orderBy('Id') + ->get() + ->filter(static fn ($row): bool => trim((string) ($row->expname ?? '')) !== '' && (string) ($row->is_delete ?? '0') !== '1') + ->values() + ->all(); + + $accounts = []; + $accountDataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY); + if ($this->mirror->ready($accountDataset)) { + foreach ($this->connection->table($accountDataset->targetTable()) + ->select(['expcode', 'expname', 'accountType', 'account', 'accountName', 'accountkhh', 'erpAccountId']) + ->orderBy('Id') + ->get() as $row) { + $accounts[(string) $row->expcode][] = $row; + } + } + + return ['express' => $express, 'accounts' => $accounts]; + } + /** * 返回指定快递名称对应的电子签章经办人账号。 * diff --git a/resources/views/business/pages/courier-payables/agreement-signing.blade.php b/resources/views/business/pages/courier-payables/agreement-signing.blade.php index 40eb75e8..6c3f7a87 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -17,6 +17,7 @@ @php($agreementType = trim((string) request('agreement_type', ''))) @php($agreementScope = trim((string) request('agreement_scope', $agreementStatus === 'archived' ? 'archived' : 'active'))) @php($agreementScope = in_array($agreementScope, ['active', 'archived', 'all'], true) ? $agreementScope : 'active') +@php($agreementPaymentPrefill = \App\Business\CourierProfileData::agreementPrefill(request('payment_account'))) @php($agreementFiltersActive = $agreementSearch !== '' || $agreementStatus !== '' || $agreementType !== '')
@@ -1575,6 +1576,12 @@
+ @if ($agreementPaymentPrefill) +
+ +
来自快递资料 · {{ $agreementPaymentPrefill['action'] }}:{{ $agreementPaymentPrefill['courier_name'] }}({{ $agreementPaymentPrefill['expcode'] }})

已按打款信息预填「{{ $agreementPaymentPrefill['document_type'] === 'account-change' ? '收款账户变更' : '收款账户证明' }}」。请补齐 E 签宝账号、签署人等变更资料后推送给快递签署,签署完成后快递资料里这条打款信息显示为「已生效」。

+
+ @endif

基础信息

@@ -3951,6 +3958,46 @@ syncEsignAccount(); refreshPreview(); + // 快递资料「打款信息新增 / 变更」跳过来:预填快递、文件类型和账户信息,提示补齐后推送。 + var paymentPrefill = @json($agreementPaymentPrefill); + if (paymentPrefill) { + showTab('create', { updateUrl: false }); + var courierPartner = root.querySelector('[data-partner-type][value="courier"]'); + if (courierPartner && !courierPartner.checked) { + courierPartner.checked = true; + courierPartner.dispatchEvent(new Event('change', { bubbles: true })); + } + if (courierSelect && paymentPrefill.courier_name) { + var prefillOption = Array.prototype.find.call(courierSelect.options, function (option) { return option.value === paymentPrefill.courier_name; }); + if (!prefillOption) { + prefillOption = new Option(paymentPrefill.courier_name, paymentPrefill.courier_name); + courierSelect.appendChild(prefillOption); + courierOptions.push({ value: paymentPrefill.courier_name, account: paymentPrefill.esign_account || '' }); + } + if (paymentPrefill.esign_account && !prefillOption.dataset.esignAccount) { + prefillOption.dataset.esignAccount = paymentPrefill.esign_account; + } + courierSelect.value = paymentPrefill.courier_name; + courierSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + documentSelect.value = paymentPrefill.document_type; + documentSelect.dispatchEvent(new Event('change', { bubbles: true })); + var prefillSigner = form.querySelector('[name="counterparty_signer_type"][value="' + paymentPrefill.signer_type + '"]'); + if (prefillSigner) { + prefillSigner.checked = true; + prefillSigner.dispatchEvent(new Event('change', { bubbles: true })); + } + Object.keys(paymentPrefill.fields || {}).forEach(function (name) { + var input = form.querySelector('[name="' + name + '"]'); + if (input) { + input.value = paymentPrefill.fields[name]; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + refreshPreview(); + if (announcer) announcer.textContent = '已按快递资料的打款信息预填协议,请补齐签署人信息后推送。'; + } + if (initialUrl.searchParams.has('agreement_step')) { updateUrl(initialView, true); } diff --git a/resources/views/business/pages/courier-payables/courier-information.blade.php b/resources/views/business/pages/courier-payables/courier-information.blade.php index 67d23d3f..fe66c010 100644 --- a/resources/views/business/pages/courier-payables/courier-information.blade.php +++ b/resources/views/business/pages/courier-payables/courier-information.blade.php @@ -49,6 +49,21 @@ array_filter($filters, static fn ($value) => $value !== '' && $value !== null), )); }; + $displayRows = $courierDisplayRows ?? []; + $detail = $courierDetail ?? null; + $selectedCode = (string) ($detail['code'] ?? ''); + $selectedRow = $detail['row'] ?? null; + $selectUrl = static function (string $code, array $extra = []) use ($filters): string { + $page = (int) request('page', 1); + + return route('business.pages.show', array_merge( + ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'express'], + array_filter($filters, static fn ($value) => $value !== '' && $value !== null), + $page > 1 ? ['page' => $page] : [], + ['selected' => $code], + $extra, + )); + }; @endphp -
+
@@ -341,11 +384,11 @@
- +
- +
@@ -380,17 +423,23 @@ 导出 重置 + @if ($selectedRow) + 编辑 + @else + + @endif
- @elseif (in_array($tab, ['networks', 'accounts'], true)) -
+ @else +
- +
+ 重置
@endif @@ -407,25 +456,7 @@
@endif - @if ($tab === 'logs') -
-
- - - - @forelse ($rows ?? [] as $row) - - @empty - - @endforelse - -
ID快递代码操作时间创建时间修改时间
{{ $row->Id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->doTime ?? '-' }}{{ $datetime($row->createTime ?? null) }}{{ $datetime($row->modifyTime ?? null) }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有操作日志。' }}
-
- @if ($rows && !$courierError) - - @endif -
- @elseif ($tab === 'express') + @if ($tab === 'express')
@@ -435,37 +466,64 @@ + + - @forelse ($rows ?? [] as $row) - @php $enabled = $bool($row->status ?? null) === '是'; @endphp - + @forelse ($displayRows as $row) + @php + $code = (string) ($row->expcode ?? ''); + $onlyOnboarding = ! empty($row->_onboarding_only); + $overridden = $row->_overridden ?? []; + $cell = static fn (string $field): string => in_array($field, $overridden, true) ? 'is-overridden' : ''; + $enabled = $bool($row->status ?? null) === '是'; + $isSelected = $code !== '' && strcasecmp($code, $selectedCode) === 0; + @endphp + - - + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @include('business.pages.courier-payables.partials.courier-onboarding-cells', ['info' => $row->_info ?? null, 'platformName' => $row->expname ?? null, 'platformCode' => $row->expcode ?? null]) @empty - + @endforelse
是否启用可达服务是否快运自动寻号配货单号寻号数量拆单重量0寻号 停运时间合同开始时间合同结束时间联系人联系电话自定义1自定义2自定义3 体积0寻号手工单号解密电子签章经办人最后修改入驻单号入驻状态ERP 开通入驻主体证照生效报价
{{ $row->Id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->expname ?? '-' }} + @if ($code !== '') + {{ $code }} + @else + 未填代码 + @endif + {{ $row->expname ?? '-' }} {{ $row->outcode ?? '-' }} {{ $row->scexpcode ?? '-' }}{{ $row->paymethod ?? '-' }}{{ $enabled ? '是' : '否' }}{{ $bool($row->addvalidsgn ?? null) }}{{ $bool($row->kuaiyunsgn ?? null) }}{{ $bool($row->autonosgn ?? null) }}{{ $bool($row->packnosgn ?? null) }}{{ $bool($row->skucountsplitsgn ?? null) }}{{ $bool($row->weight0sgn ?? null) }}{{ $date($row->stopdate ?? null) }}{{ $date($row->tradebtime ?? null) }}{{ $date($row->tradeetime ?? null) }}{{ $row->linker ?? '-' }}{{ $row->linkmobile ?? '-' }}{{ $row->undefined1 ?? '-' }}{{ $row->undefined2 ?? '-' }}{{ $row->undefined3 ?? '-' }}{{ $bool($row->volume0sgn ?? null) }}{{ $bool($row->handnosgn ?? null) }}{{ $bool($row->decryptsgn ?? null) }}{{ $row->dzqzjbr ?? '-' }}{{ $row->paymethod ?? '-' }}{{ $enabled ? '是' : '否' }}{{ $onlyOnboarding ? '-' : $bool($row->addvalidsgn ?? null) }}{{ $bool($row->kuaiyunsgn ?? null) }}{{ $onlyOnboarding ? '-' : $bool($row->autonosgn ?? null) }}{{ $onlyOnboarding ? '-' : $bool($row->packnosgn ?? null) }}{{ $onlyOnboarding ? '-' : $bool($row->skucountsplitsgn ?? null) }}{{ $onlyOnboarding ? '-' : $bool($row->weight0sgn ?? null) }}{{ $date($row->stopdate ?? null) }}{{ $date($row->tradebtime ?? null) }}{{ $date($row->tradeetime ?? null) }}{{ $row->linker ?? '-' }}{{ $row->linkmobile ?? '-' }}{{ $row->undefined1 ?? '-' }}{{ $row->undefined2 ?? '-' }}{{ $row->undefined3 ?? '-' }}{{ $onlyOnboarding ? '-' : $bool($row->volume0sgn ?? null) }}{{ $onlyOnboarding ? '-' : $bool($row->handnosgn ?? null) }}{{ $onlyOnboarding ? '-' : $bool($row->decryptsgn ?? null) }}{{ $row->dzqzjbr ?? '-' }} {{ $datetime($row->modified ?? null) }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}
@if ($rows && !$courierError) @endif
+ + @if ($detail) + @include('business.pages.courier-payables.partials.courier-information-detail', ['detail' => $detail]) + @endif @elseif ($tab === 'networks')
- - - - @forelse ($rows ?? [] as $row) - - @empty - - @endforelse - -
ID快递代码网点代码网点名称网点号省市区乡镇地址联系人联系电话是否启用接口平台创建时间剩余单号
{{ $row->id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->netcode ?? '-' }}{{ $row->netname ?? '-' }}{{ $row->netno ?? '-' }}{{ $row->province ?? '-' }}{{ $row->city ?? '-' }}{{ $row->area ?? '-' }}{{ $row->town ?? '-' }}{{ $row->address ?? '-' }}{{ $row->linker ?? '-' }}{{ $row->linkermobile ?? '-' }}{{ $bool($row->status ?? null) }}{{ $row->apipltmname ?? ($row->apitype ?? '-') }}{{ $datetime($row->created ?? null) }}{{ $row->expnum ?? '-' }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。' }}
+ @include('business.pages.courier-payables.partials.courier-network-table', ['networkRows' => $rows ?? [], 'emptyText' => $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。'])
@if ($rows && !$courierError) @endif
+ @elseif ($tab === 'accounts') + @php + $tabOldAccounts = collect($courierFinanceAccounts ?? []) + ->map(static fn (array $account): array => ['type' => $account['type'], 'name' => $account['name'], 'number' => $account['number'], 'bank' => $account['bank'], 'source' => $account['source']]) + ->merge(($filters['expcode'] ?? '') !== '' && $rows ? collect($rows->items())->map(static fn ($account): array => ['type' => in_array($account->accountType ?? '', ['对公', '对私'], true) ? $account->accountType : '', 'name' => $account->accountName ?? '', 'number' => $account->account ?? '', 'bank' => $account->accountkhh ?? '', 'source' => '工作平台']) : []) + ->filter(static fn (array $account): bool => filled($account['number']) && ($filters['expcode'] ?? '') !== '') + ->unique('number') + ->values(); + @endphp +
+ @if ($errors->any() && in_array((string) old('form'), ['add', 'change'], true)) + + @endif + @include('business.pages.courier-payables.partials.courier-payment-account-forms', ['code' => null, 'prefillCode' => $filters['expcode'] ?? '', 'oldAccounts' => $tabOldAccounts]) +
+
+
+ @include('business.pages.courier-payables.partials.courier-account-table', ['financeAccounts' => $courierFinanceAccounts ?? [], 'platformAccounts' => $rows ?? [], 'showCourier' => true, 'rowActions' => true, 'emptyText' => '当前筛选条件下没有打款信息。']) +
+ @if ($rows && !$courierError) + + @endif +
@else
- - - - @forelse ($rows ?? [] as $row) - - @empty - - @endforelse - -
ID快递代码快递名称账户类型账户账户名称开户行创建时间发票信息ERP账户ID
{{ $row->Id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->expname ?? '-' }}{{ $row->accountType ?? '-' }}{{ $row->account ?? '-' }}{{ $row->accountName ?? '-' }}{{ $row->accountkhh ?? '-' }}{{ $datetime($row->createTime ?? null) }}{{ $row->invoice ?? '-' }}{{ $row->erpAccountId ?? '-' }}
{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有打款账户。' }}
+ @include('business.pages.courier-payables.partials.courier-log-table', ['financeEvents' => $courierFinanceEvents ?? collect(), 'platformLogs' => $rows ?? [], 'showCourier' => true, 'emptyText' => '当前筛选条件下没有操作日志。'])
@if ($rows && !$courierError) - + @endif
@endif
+ + diff --git a/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php b/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php new file mode 100644 index 00000000..645c67b4 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php @@ -0,0 +1,444 @@ +@php + /** @var \App\Models\Business\CourierOnboarding $onboarding */ + $status = $onboarding->status; + $expired = $onboarding->linkExpired() && in_array($status, ['invited', 'returned'], true); + $badge = match ($status) { + 'submitted', 'first_approved', 'approved' => 'is-pending', + 'returned' => 'is-warning', + 'activated' => 'is-success', + 'rejected', 'cancelled' => 'is-muted', + default => 'is-info', + }; + $show = static fn ($value): string => filled($value) ? (string) $value : '—'; + $num = static fn ($v): string => $v === null || $v === '' ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'); + $quoteColumns = $onboarding->quoteColumns(); + $quoteRows = $onboarding->quoteRowList(); + $quoteSheet = $onboarding->files->where('kind', \App\Models\Business\CourierOnboardingFile::QUOTE_SHEET_KIND)->last(); + $quoteUrl = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']); +@endphp + + 快递入驻 {{ $onboarding->number }} + + + +
+
+
+ 返回快递入驻 +

{{ $onboarding->displayName() }}{{ $expired ? '链接已过期' : $onboarding->statusLabel() }}{{ $onboarding->number }}

+
+
+ + @if (session('success')) +
{{ session('success') }}
+ @endif + @if ($errors->any()) + + @endif + @if ($status === 'returned' && $onboarding->return_reason) +
已退回补件:{{ $onboarding->return_reason }}
+ @endif + +
+
+ @if (in_array($status, ['invited', 'returned'], true) && blank($onboarding->company_full_name)) +
快递方还没有填写资料。把右侧二维码发给对方,扫码后即可在手机上填写;填写过程中可以随时暂存。
+ @endif + +
+

入驻主体与联系人

{{ $onboarding->entityLabel() }}入驻
+
+
+
{{ $onboarding->subjectLabel() }}
{{ $show($onboarding->company_full_name) }}
+ @if ($onboarding->isPerson()) +
身份证号
{{ $show($onboarding->maskedIdNumber()) }}
+ @else +
统一社会信用代码
{{ $show($onboarding->unified_social_credit_code) }}
+
法定代表人
{{ $show($onboarding->legal_representative) }}
+ @endif +
快递品牌 / 业务类型
{{ $show($onboarding->carrier_brand) }} · {{ $show($onboarding->service_type) }}
+
网点名称
{{ $show($onboarding->network_name) }}
+
经营地址
{{ $show($onboarding->business_address) }}
+
业务对接人
{{ $show($onboarding->contact_name) }} {{ $onboarding->contact_phone }}
+
财务对账人
{{ $show($onboarding->finance_contact_name) }} {{ $onboarding->finance_contact_phone }}
+
e签宝签署人
{{ $show($onboarding->esign_operator_name) }} {{ $onboarding->esign_account }}
+
提交时间
{{ $onboarding->submitted_at?->format('Y-m-d H:i') ?? '—' }}
+
可揽收仓库 / 区域
{{ $show($onboarding->pickup_warehouses) }}
+
+
+
+ +
+

结算与开票

+
+ @php + $accountChecks = $onboarding->accountChecks(); + $checkBadge = static fn (?bool $ok, string $okText, string $badText): string => $ok === null ? '' : '' . e($ok ? '✓ ' . $okText : '✗ ' . $badText) . ''; + @endphp +
+
对公户名
{{ $show($onboarding->bank_account_name) }}{!! $checkBadge($accountChecks['corporate'], '与营业执照公司全称一致', '与公司全称不一致') !!}
+
对公开户行
{{ $show($onboarding->bank_name) }}
+
对公账号
{{ $show($onboarding->bank_account_number) }}
+
对私收款人
{{ $show($onboarding->private_account_name) }}{!! $checkBadge($accountChecks['private'], $onboarding->isPerson() ? '与本人姓名一致' : '与法定代表人一致', $onboarding->isPerson() ? '与本人姓名不一致' : '与法定代表人不一致') !!}
+
对私开户行
{{ $show($onboarding->private_bank_name) }}
+
对私卡号
{{ $show($onboarding->private_account_number) }}
+
开票类型
{{ $show($onboarding->invoice_type) }}
+
税率
{{ $onboarding->tax_rate !== null ? $num($onboarding->tax_rate) . '%' : '—' }}
+
结算周期
{{ $show($onboarding->settlement_cycle) }}
+
+

核对依据:快递填写的营业执照公司全称 / 法定代表人{{ $onboarding->isPerson() ? '(个人入驻为本人姓名)' : '' }}。请对照下方营业执照、身份证和银行卡照片确认填写无误。

+
+
+ +
+
+

{{ $quoteVersions->count() > 1 ? "入驻首版报价" : "报价" }}

+ @if ($onboarding->quoteVersion) + 报价管理 {{ $onboarding->quoteVersion->version_code }} + @endif +
+
+
+
计费方式
{{ $show($onboarding->quoteModeLabel()) }}
+
执行日期
{{ $onboarding->quote_effective_date?->format('Y-m-d') ?? '—' }}
+
重量取整
{{ \App\Support\CourierQuoteStructure::ROUNDING[$onboarding->weight_rounding ?? 'none'] ?? '不取整' }}
+
计费规则
@foreach ($onboarding->quoteRuleLines() as $rule)
{{ $rule }}
@endforeach
+
报价备注
{{ $show($onboarding->quote_note) }}
+
+ @if ($onboarding->quote_mode === 'other') +
特殊计费,快递上传了报价表{{ $quoteSheet ? ':' . $quoteSheet->original_name : '' }}。复审通过后请在「报价管理」里按报价表录入首版报价,再发起报价确认。
+ @if ($quoteSheet)

下载报价表

@endif + @elseif ($quoteRows === []) +

快递方还没有填写报价明细。

+ @else +
+ + @foreach ($quoteColumns as $column)@endforeach + + @foreach ($quoteRows as $row) + @foreach (\App\Support\CourierQuoteStructure::rowValues($row, $quoteColumns) as $cell)@endforeach + @endforeach + +
省份 / 区域{{ $column['label'] }}
{{ $row['region'] }}{{ $num($cell) }}
+
+

共 {{ count($quoteRows) }} 个地区。复审通过后这份报价会作为首版报价写进报价管理,并生成报价确认单推送 e签宝。

+ @endif +
+
+ +
+

资料附件

{{ $onboarding->files->count() }} 个
+
+ @if ($onboarding->files->isEmpty()) +

暂未上传附件。

+ @else + + @endif +
+
+ +
+

处理记录

+
+
+ @foreach ($onboarding->events->sortByDesc('id') as $event) +
+ {{ $event->created_at?->format('Y-m-d H:i') }} +
{{ $event->eventLabel() }} · {{ $event->actorLabel() }}
@if ($event->note)
{{ $event->note }}
@endif
+
+ @endforeach +
+
+
+
+ + +
+
+ + +
+
diff --git a/resources/views/business/pages/courier-payables/courier-onboarding.blade.php b/resources/views/business/pages/courier-payables/courier-onboarding.blade.php new file mode 100644 index 00000000..c98c0b98 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-onboarding.blade.php @@ -0,0 +1,185 @@ +@php + $ready = (bool) ($onboardingReady ?? false); + $rows = $onboardingRows ?? null; + $group = $onboardingGroup ?? 'pending'; + $groups = $onboardingGroups ?? []; + $counts = $onboardingCounts ?? []; + $search = $onboardingSearch ?? ''; + $groupUrl = static fn (string $key): string => route('business.pages.show', array_filter([ + 'section' => 'courier-payables', + 'page' => 'courier-onboarding', + 'group' => $key, + 'search' => $search !== '' ? $search : null, + ])); + $badge = static fn (string $status): string => match ($status) { + 'submitted', 'first_approved', 'approved' => 'is-pending', + 'returned' => 'is-warning', + 'activated' => 'is-success', + 'rejected', 'cancelled' => 'is-muted', + default => 'is-info', + }; +@endphp + +
+ + + @if (! $ready) + + @else + @if (session('success')) +
{{ session('success') }}
+ @endif + +
+
+
+

入驻流程

+ 快递方无需账号,扫码即可填写 +
+
    +
  1. 生成二维码右侧填个备注,生成后发给快递方
  2. +
  3. 快递扫码填写主体、联系人、结算账户、报价、资质照片
  4. +
  5. 对接人初审资料不全可退回,快递用原码补件
  6. +
  7. 财务复审确认系统快递名称,首版报价进报价管理
  8. +
  9. 签署并开通协议签署后开通,进快递资料「入驻快递」
  10. +
+
+
+ @csrf +
+

生成入驻二维码

+
+
+
+ + + 与快递资料「快递公司代码」一致,入驻信息按这个代码对齐到快递资料。 + @error('workplatform_expcode'){{ $message }}@enderror +
+
+ + + 我方规定的名称,需与报价名、发货数据里的快递名称完全一致;快递方看不到。 + @error('courier_name'){{ $message }}@enderror +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ + +
+
+ + + + + + @forelse ($rows ?? [] as $row) + + + + + + + + + + + + + + + @empty + + @endforelse + +
入驻单号状态备注类型公司 / 姓名网点 / 品牌对接人系统快递名称附件提交时间最近更新操作
{{ $row->number }}{{ $row->linkExpired() && in_array($row->status, ['invited', 'returned'], true) ? '链接已过期' : $row->statusLabel() }}{{ $row->invite_label ?: '—' }}{{ $row->company_full_name ? $row->entityLabel() : '—' }}{{ $row->company_full_name ?: '—' }}{{ $row->network_name ?: '—' }}{{ $row->carrier_brand ? ' · ' . $row->carrier_brand : '' }}{{ $row->contact_name ?: '—' }}{{ $row->contact_phone ? ' ' . $row->contact_phone : '' }}{{ $row->courier_name ?: '—' }}{{ $row->files_count }}{{ $row->submitted_at?->format('Y-m-d H:i') ?? '—' }}{{ $row->updated_at?->format('Y-m-d H:i') ?? '—' }}{{ in_array($row->status, ['submitted', 'first_approved', 'approved'], true) ? '去处理' : '查看' }}
{{ $search !== '' ? '没有符合条件的入驻申请。' : '这里还没有入驻申请,先在上方生成一个二维码发给快递方。' }}
+
+ @if ($rows && $rows->total() > 0) + + @endif +
+ @endif +
diff --git a/resources/views/business/pages/courier-payables/partials/courier-account-table.blade.php b/resources/views/business/pages/courier-payables/partials/courier-account-table.blade.php new file mode 100644 index 00000000..0783ea95 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-account-table.blade.php @@ -0,0 +1,66 @@ +{{-- 打款信息(完整显示):财务系统(入驻对公/对私、新增/变更)在前,工作平台账户在后。 --}} +@php + $showCourier = $showCourier ?? false; + $rowActions = $rowActions ?? false; + $colspan = ($showCourier ? 12 : 10) + ($rowActions ? 1 : 0); + $changeButton = static fn (?string $code, ?string $type, ?string $name, ?string $number): string => filled($code) && filled($number) + ? '' + : '-'; +@endphp + + + + + @if ($showCourier)@endif + @if ($rowActions)@endif + + + + @foreach ($financeAccounts as $account) + + + @if ($showCourier)@endif + + + + + + + + + + @if ($rowActions)@endif + + @endforeach + @foreach ($platformAccounts as $row) + + + @if ($showCourier)@endif + + + + + + + + + + @if ($rowActions)@endif + + @endforeach + @if (count($financeAccounts) === 0 && count($platformAccounts) === 0) + + @endif + +
来源快递公司代码快递公司名称账户类型开户名称开户银行银行账号生效日期原账户(变更前)状态ERP账户ID创建时间操作
+ @if (! empty($account['onboarding'])) + {{ $account['source'] }} + @else + {{ $account['source'] }} + @endif + {{ $account['expcode'] ?: '-' }}{{ $account['courier_name'] ?: '-' }}{{ $account['type'] }}{{ $account['name'] ?: '-' }}{{ $account['bank'] ?: '-' }}{{ $account['number'] ?: '-' }}{{ $account['effective_date'] ?: '-' }}{{ $account['old'] ?: '-' }} + {{ $account['status'] }} + @if (($account['status'] ?? '') === '待推送协议' && ! empty($account['record'])) + 去推送 + @endif + {{ $account['erp_account_id'] ?: '-' }}{{ $account['created_at'] ? substr($account['created_at'], 0, 16) : '-' }}{!! $changeButton($account['expcode'] ?? null, $account['type'] ?? null, $account['name'] ?? null, $account['number'] ?? null) !!}
工作平台{{ filled($row->invoice ?? null) ? ' · 发票 ' . $row->invoice : '' }}{{ $row->expcode ?? '-' }}{{ $row->expname ?? '-' }}{{ $row->accountType ?? '-' }}{{ $row->accountName ?? '-' }}{{ $row->accountkhh ?? '-' }}{{ $row->account ?? '-' }}--工作平台登记{{ $row->erpAccountId ?? '-' }}{{ filled($row->createTime ?? null) ? substr((string) $row->createTime, 0, 16) : '-' }}{!! $changeButton($row->expcode ?? null, $row->accountType ?? null, $row->accountName ?? null, $row->account ?? null) !!}
{{ $emptyText }}
diff --git a/resources/views/business/pages/courier-payables/partials/courier-information-detail.blade.php b/resources/views/business/pages/courier-payables/partials/courier-information-detail.blade.php new file mode 100644 index 00000000..4e08ca5a --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-information-detail.blade.php @@ -0,0 +1,101 @@ +{{-- 点开快递资料主信息后的明细:网点 / 打款信息 / 操作日志,和编辑、打款信息新增/变更。 --}} +@php + $row = $detail['row']; + $onb = $detail['onboarding'] ?? null; + $code = $detail['code']; + $formFailed = (string) old('form', ''); + $editing = ($detail['edit'] ?? false) || $formFailed === 'edit'; + $activeTab = in_array($formFailed, ['add', 'change'], true) ? 'accounts' : ($detail['detail_tab'] ?? 'accounts'); + $value = static function (string $field) use ($row): string { + $current = old($field, $row->{$field} ?? ''); + if (in_array($field, \App\Models\Business\CourierProfile::DATE_FIELDS, true)) { + return $current ? substr((string) $current, 0, 10) : ''; + } + if (in_array($field, \App\Models\Business\CourierProfile::BOOLEAN_FIELDS, true)) { + return in_array(strtolower((string) $current), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0'; + } + + return (string) $current; + }; + $oldAccounts = collect($detail['finance_accounts'] ?? []) + ->filter(static fn (array $account): bool => filled($account['number'] ?? null)) + ->map(static fn (array $account): array => ['type' => $account['type'], 'name' => $account['name'], 'number' => $account['number'], 'bank' => $account['bank'], 'source' => $account['source']]) + ->merge(collect($detail['platform_accounts'] ?? [])->map(static fn ($account): array => ['type' => in_array($account->accountType ?? '', ['对公', '对私'], true) ? $account->accountType : '', 'name' => $account->accountName ?? '', 'number' => $account->account ?? '', 'bank' => $account->accountkhh ?? '', 'source' => '工作平台'])) + ->unique('number') + ->values(); + $today = now()->toDateString(); +@endphp +
+ @if ($row === null) +
没有找到快递公司代码为「{{ $code }}」的快递资料。
+ @else +
+
+

{{ $row->expname ?: '-' }} {{ $code }}

+

+ {{ ! empty($row->_onboarding_only) ? '财务系统入驻' : '工作平台 + 财务系统' }} + @if ($onb) · 入驻 {{ $onb->number }}({{ $onb->entityLabel() }} · {{ $onb->company_full_name ?: '未填主体' }})@endif +

+
+ +
+ + @if ($errors->any() && in_array($formFailed, ['edit', 'add', 'change'], true)) + + @endif + + @if ($editing) +
+ @csrf + +

编辑快递资料 财务系统维护,不回写工作平台;改动记到操作日志

+
+ @foreach (\App\Models\Business\CourierProfile::EDITABLE as $field => $label) +
+ + @if (in_array($field, \App\Models\Business\CourierProfile::BOOLEAN_FIELDS, true)) + + @elseif (in_array($field, \App\Models\Business\CourierProfile::DATE_FIELDS, true)) + + @else + + @endif +
+ @endforeach +
+
+ + 取消 +
+
+ @endif + +
+ @foreach (['networks' => ['网点', count($detail['networks'])], 'accounts' => ['打款信息', count($detail['finance_accounts']) + count($detail['platform_accounts'])], 'logs' => ['操作日志', count($detail['events']) + count($detail['platform_logs'])]] as $key => [$label, $count]) + + @endforeach +
+ + + + + + + @endif +
diff --git a/resources/views/business/pages/courier-payables/partials/courier-log-table.blade.php b/resources/views/business/pages/courier-payables/partials/courier-log-table.blade.php new file mode 100644 index 00000000..da9f22b3 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-log-table.blade.php @@ -0,0 +1,35 @@ +{{-- 操作日志:财务系统(编辑、打款信息新增/变更)在前,工作平台日志在后。 --}} +@php + $showCourier = $showCourier ?? false; +@endphp + + @if ($showCourier)@endif + + @foreach ($financeEvents as $event) + @php + $details = collect((array) $event->changes)->map(static fn ($change): string => ($change['label'] ?? '') . ':' . ($change['old'] ?? '空') . ' → ' . ($change['new'] ?? '空'))->implode(';'); + @endphp + + + @if ($showCourier)@endif + + + + + + @endforeach + @foreach ($platformLogs as $row) + + + @if ($showCourier)@endif + + + + + + @endforeach + @if (count($financeEvents) === 0 && count($platformLogs) === 0) + + @endif + +
来源快递公司代码操作内容操作人操作时间
财务系统{{ $event->expcode }}{{ $event->actionLabel() }}{{ $event->summary }}@if ($details)
{{ $details }}
@endif
{{ $event->creator?->name ?? '-' }}{{ $event->created_at?->format('Y-m-d H:i') ?? '-' }}
工作平台{{ $row->expcode ?? '-' }}-{{ $row->doTime ?? '-' }}-{{ filled($row->modifyTime ?? $row->createTime ?? null) ? substr((string) ($row->modifyTime ?? $row->createTime), 0, 16) : '-' }}
{{ $emptyText }}
diff --git a/resources/views/business/pages/courier-payables/partials/courier-network-table.blade.php b/resources/views/business/pages/courier-payables/partials/courier-network-table.blade.php new file mode 100644 index 00000000..24dc4f10 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-network-table.blade.php @@ -0,0 +1,14 @@ +{{-- 快递网点表(工作平台 wp_express_expNet)。 --}} +@php + $yes = static fn ($value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否'; +@endphp + + + + @forelse ($networkRows as $row) + + @empty + + @endforelse + +
ID快递公司代码网点代码网点名称网点号省市区乡镇地址联系人联系电话是否启用接口平台创建时间剩余单号
{{ $row->id ?? '-' }}{{ $row->expcode ?? '-' }}{{ $row->netcode ?? '-' }}{{ $row->netname ?? '-' }}{{ $row->netno ?? '-' }}{{ $row->province ?? '-' }}{{ $row->city ?? '-' }}{{ $row->area ?? '-' }}{{ $row->town ?? '-' }}{{ $row->address ?? '-' }}{{ $row->linker ?? '-' }}{{ $row->linkermobile ?? '-' }}{{ $yes($row->status ?? null) }}{{ $row->apipltmname ?? ($row->apitype ?? '-') }}{{ filled($row->created ?? null) ? substr((string) $row->created, 0, 16) : '-' }}{{ $row->expnum ?? '-' }}
{{ $emptyText }}
diff --git a/resources/views/business/pages/courier-payables/partials/courier-onboarding-cells.blade.php b/resources/views/business/pages/courier-payables/partials/courier-onboarding-cells.blade.php new file mode 100644 index 00000000..1579f43b --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-onboarding-cells.blade.php @@ -0,0 +1,22 @@ +{{-- 快递资料表格右侧:财务系统入驻信息($info 为空则留空)。对公/对私收款信息在下方「打款信息」里完整显示。 --}} +@php + $onb = $info['onboarding'] ?? null; +@endphp +@if ($onb) + {{ $onb->number }} + {{ $onb->statusLabel() }} + + @if (($info['erp_status'] ?? '') === 'pending') + 待 ERP 开通 + @elseif (($info['erp_status'] ?? '') === 'opened') + 已开通 + @else + - + @endif + + {{ $onb->entityLabel() }} · {{ $onb->company_full_name ?: '-' }} + @if ($info['missing_files'] ?? [])缺 {{ count($info['missing_files']) }} 项@else齐全@endif + {{ $info['effective_version'] ?? '-' }} +@else + 未入驻@if (! empty($platformName)) · 发码补资料@endif +@endif diff --git a/resources/views/business/pages/courier-payables/partials/courier-onboarding-return.blade.php b/resources/views/business/pages/courier-payables/partials/courier-onboarding-return.blade.php new file mode 100644 index 00000000..b02c73c3 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-onboarding-return.blade.php @@ -0,0 +1,22 @@ +
+ 退回补件 / 不予合作 +
+
+ @csrf +
+ + + 快递方用原二维码打开即可看到原因并修改。 +
+ +
+
+ @csrf +
+ + +
+ +
+
+
diff --git a/resources/views/business/pages/courier-payables/partials/courier-payment-account-forms.blade.php b/resources/views/business/pages/courier-payables/partials/courier-payment-account-forms.blade.php new file mode 100644 index 00000000..9e474086 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/courier-payment-account-forms.blade.php @@ -0,0 +1,72 @@ +{{-- 打款信息新增 / 变更:按钮 + 两张表单。$code 为空时(打款信息页签)表单里自己填快递公司代码。 --}} +@php + $code = $code ?? null; + $oldAccounts = collect($oldAccounts ?? []); + $prefillCode = $prefillCode ?? ''; + $formFailed = (string) old('form', ''); + $today = now()->toDateString(); + $formAction = $code !== null + ? route('business.courier-profiles.payment-accounts.store', $code) + : route('business.courier-profiles.payment-accounts.store-any'); +@endphp +
+ + + {{ $code === null ? '先填快递公司代码;也可以点表格里某个账户的「变更」直接带出原账户。' : '' }}保存后自动跳到协议签署,推送「收款账户证明 / 收款账户变更」给快递签署,签完即生效。 +
+ + + + diff --git a/resources/views/business/pages/courier-payables/quote-change-show.blade.php b/resources/views/business/pages/courier-payables/quote-change-show.blade.php index ee8a4082..fc444e73 100644 --- a/resources/views/business/pages/courier-payables/quote-change-show.blade.php +++ b/resources/views/business/pages/courier-payables/quote-change-show.blade.php @@ -62,14 +62,65 @@
返回报价历史
@if($change->isEditable() && $change->created_by===user_id())编辑草稿
@csrf
@endif @if($canReview && $change->status==='pending_review')
@csrf
@endif
@if(is_string(session('success')))
{{ session('success') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif + @if($reviewWarnings)@endif @if($change->isRetrospective())
追溯变价 · {{ $change->recalculationStatusLabel() }}
自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}
@endif -
{{ $change->number }} · {{ $change->statusLabel() }}
原报价版本
{{ $change->source_quote_id }}
快递公司
{{ $change->carrier_name }}
报价类型
{{ $change->quote_type }}
变更类型
{{ $change->change_type }}
生效日期
{{ $change->effective_date->toDateString() }}
生效方式
{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}
追溯核算
{{ $change->recalculationStatusLabel() }}
变价明细
{{ $change->items->count() }} 项
新报价版本
{{ $change->version?->version_code ?? '尚未生成' }}
签署状态
{{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
+
{{ $change->number }} · {{ $change->statusLabel() }}
原报价版本
{{ $change->source_quote_id }}
快递公司
{{ $change->carrier_name }}
报价类型
{{ $change->quote_type }}
变更类型
{{ $change->change_type }}
执行期间
{{ $change->periodLabel() }}
生效方式
{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}
追溯核算
{{ $change->recalculationStatusLabel() }}
变价明细
{{ $requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项' }}
新报价版本
{{ $change->version?->version_code ?? '尚未生成' }}
签署状态
{{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
+ @if($requoteDiff) + @php($fmt = static fn ($v) => $v === null ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.')) +
+
新旧报价对比 · {{ $change->change_type }}
+
+ @if($requoteDiff['same_structure']) +

计费方式不变:{{ $requoteDiff['new_mode'] }}。共 {{ count($requoteDiff['cells']) }} 处价格变动,未列出的价格不变。

+ @else +
计费方式调整
原:{{ $requoteDiff['old_mode'] }}({{ implode(' / ', $requoteDiff['old_columns']) }})
新:{{ $requoteDiff['new_mode'] }}({{ implode(' / ', $requoteDiff['new_columns']) }})
新报价全表见下方「新报价」。
+ @endif + @if($requoteDiff['added'] || $requoteDiff['removed']) +

@if($requoteDiff['added'])新增省份:{{ implode('、', $requoteDiff['added']) }}。@endif @if($requoteDiff['removed'])不再报价:{{ implode('、', $requoteDiff['removed']) }}。@endif

+ @endif + @foreach($requoteDiff['rules'] as $rule) +

规则:{{ $rule['old'] }} → {{ $rule['new'] }}

+ @endforeach +
+ @if($requoteDiff['same_structure'] && $requoteDiff['cells']) +
+ @foreach($requoteDiff['cells'] as $cell) + @php($delta = (float) ($cell['new'] ?? 0) - (float) ($cell['old'] ?? 0)) + + @endforeach +
省份计费项原价格新价格差额
{{ $cell['region'] }}{{ $cell['label'] }}{{ $fmt($cell['old']) }}{{ $fmt($cell['new']) }}{{ $delta > 0 ? '+' : '' }}{{ $fmt($delta) }}
+ @endif +
+ @php($proposed = json_decode((string) $change->proposed_values_json, true) ?: []) + @php($proposedTable = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable($proposed)) + @if($proposedTable) +
+
新报价({{ $change->end_date ? $change->effective_date->toDateString() . ' 至 ' . $change->end_date->toDateString() . ' 临时执行,到期恢复原报价' : $change->effective_date->toDateString() . ' 起执行' }})
+
+ @foreach($proposedTable[0] as $i => $head)@endforeach + @foreach(array_slice($proposedTable, 1) as $row)@foreach($row as $i => $value)@endforeach@endforeach +
$i > 0])>{{ $head }}
$i > 0])>{{ $i === 0 ? $value : $fmt($value) }}
+
+ @endif + @else
新旧价格差异
@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)@endforeach
单元格地区/规则重量段/计费项原价格新价格差额备注
{{ $item->source_cell }}{{ $item->region ?: '—' }}{{ $item->weight_band ?: ($item->charge_item ?: '—') }}{{ rtrim(rtrim($item->original_amount,'0'),'.') }}{{ rtrim(rtrim($item->new_amount,'0'),'.') }}{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}{{ $item->note ?: '—' }}
+ @endif @if($change->version) @php($quoteSignature=$change->version->signature)
E 签宝签署与归档
+ @if($change->version->isVoided()) +
{{ $change->version->version_code }} 已作废
原因:{{ $change->version->void_reason }}
{{ $change->version->voided_at?->format('Y-m-d H:i') }}{{ $voidedBy ? ' · ' . $voidedBy : '' }}
+ @endif + @if($onboarding && $canManageSignature && !$change->version->isVoided() && (!$quoteSignature || $quoteSignature->canSend())) +
@csrf +
+ 已入驻快递,按入驻资料签署:{{ $onboarding->isPerson() ? '个人 ' : $onboarding->company_full_name . ' · ' }}{{ $onboarding->esign_operator_name }} {{ $onboarding->esign_account }} + +
+
+ @endif @if(!$quoteSignature)
该历史报价版本尚未生成签署原文。
@if($canManageSignature)
@csrf
日期将直接写入待签原文。
@endif @@ -91,7 +142,7 @@ @if($quoteSignature->certificate_path)完成证书@endif @if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')
@csrf
@endif
- @if($canManageSignature && $quoteSignature->canSend()) + @if($canManageSignature && $quoteSignature->canSend() && !$onboarding && !$change->version->isVoided())
@csrf
@@ -108,6 +159,12 @@ @endif @endif + @if($canManageSignature && !$change->version->isVoided()) +
@csrf +
+
{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署:作废只在我方停用此版本,回签单保留,之后请发起一版正确的变价让快递重新签。' : '未签完:作废会撤回快递 e签宝 里的待签任务,此版本不再生效。' }}
+
+ @endif
@endif diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 9aae654d..a7e6d459 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -1,4 +1,9 @@ @php + try { + app(\App\Support\CourierQuoteSignatureService::class)->refreshPending(); + } catch (\Throwable $exception) { + report($exception); + } $quoteSnapshot = app(\App\Support\QuoteManagementSnapshot::class)->load(); $quotes = $quoteSnapshot['quotes']; $quoteCompanies = collect($quotes) @@ -360,12 +365,45 @@ color: #53793E; } +.quote-badge--muted { + border-color: #DDD8CE; + background: #F4F2EE; + color: #8A8478; + } + .quote-badge--attention { border-color: #D7B77D; background: #FFF8E9; color: #935F07; } + .quote-signed { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0.75rem 1rem 0; + padding: 0.6rem 0.8rem; + border: 1px solid #CFE3C8; + border-radius: 0.5rem; + background: #F1F8EE; + color: #2F6B2A; + } + + .quote-modal__panel > .quote-detail-table-wrap { + grid-row: 4; + } + + .quote-signed[hidden], + .quote-signed [hidden] { + display: none; + } + + .quote-signed__text { + margin-right: auto; + font-weight: 600; + } + .quote-action { min-width: 3.2rem; border: 0; @@ -452,7 +490,7 @@ .quote-modal__panel { display: grid; - grid-template-rows: auto auto minmax(0, 1fr); + grid-template-rows: auto auto auto minmax(0, 1fr); width: min(78rem, calc(100vw - 3rem)); height: 100%; background: #FFFFFF; @@ -727,6 +765,12 @@ + @if ($quoteSnapshot['available']) + + + 导出回签单 + + @endif 报价历史 @@ -818,6 +862,13 @@
执行日期
公式单元格
+
@@ -843,6 +894,15 @@ var tableWrap = root.querySelector('.quote-table-wrap'); var pagination = root.querySelector('[data-quote-pagination]'); var modal = root.querySelector('[data-quote-modal]'); + var exportBase = @json(route('business.courier-quote-signatures.export')); + var exportAnchor = root.querySelector('[data-quote-export]'); + if (exportAnchor) { + exportAnchor.addEventListener('click', function (event) { + if (exportAnchor.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + } + }); + } var lastFocusedElement = null; var fields = { keyword: root.querySelector('[data-quote-filter="keyword"]'), @@ -934,15 +994,32 @@ appendCell(row, quote.quote_type); appendCell(row, text(quote.source_range, '-')); appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列'); - appendCell(row, text(quote.effective_date, '-')); - appendCell(row, quote.signature_status, 'quote-table__secondary'); + appendCell(row, text(quote.period_label || quote.effective_date, '-')); + var signCell = appendCell(row, quote.signature_status, 'quote-table__secondary'); + if (quote.signed_files && quote.signed_files.signed) { + signCell.appendChild(document.createElement('br')); + var signedLink = create('a', 'quote-action', '回签单'); + signedLink.href = quote.signed_files.signed; + signedLink.title = '下载 ' + quote.id + ' 已签署的回签单 PDF' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : ''); + signedLink.setAttribute('aria-label', '下载回签单 ' + quote.id); + signCell.appendChild(signedLink); + } var statusCell = appendCell(row, ''); statusCell.appendChild(create( 'span', - 'quote-badge ' + (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention'), + 'quote-badge ' + (['已失效', '已作废', '已到期', '暂停中'].indexOf(quote.status) !== -1 ? 'quote-badge--muted' : (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention')), quote.status )); + if (quote.void_reason) { + statusCell.appendChild(create('div', 'quote-table__secondary', '作废:' + quote.void_reason)); + } + if (quote.superseded_by) { + statusCell.appendChild(create('div', 'quote-table__secondary', '被 ' + quote.superseded_by + ' 替代')); + } + if (quote.status_note) { + statusCell.appendChild(create('div', 'quote-table__secondary', quote.status_note)); + } var actionCell = appendCell(row, ''); var action = create('button', 'quote-action', '查看'); @@ -951,10 +1028,12 @@ action.setAttribute('aria-label', '查看 ' + quote.sheet_name); actionCell.appendChild(action); var changeAction = create('a', 'quote-action', '变价'); - changeAction.href = @json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id); + changeAction.href = quote.requote_url || (@json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id)); changeAction.title = '发起 ' + quote.sheet_name + ' 的变更草稿'; changeAction.setAttribute('aria-label', '变价 ' + quote.sheet_name); - actionCell.appendChild(changeAction); + if (quote.status !== '已作废') { + actionCell.appendChild(changeAction); + } return row; } @@ -969,6 +1048,15 @@ }); root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本'; + var exportLink = root.querySelector('[data-quote-export]'); + if (exportLink) { + var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.signed; }) + .map(function (quote) { return quote.id; }); + exportLink.href = exportBase + (signedCodes.length ? '?codes=' + encodeURIComponent(signedCodes.join(',')) : ''); + exportLink.setAttribute('aria-disabled', signedCodes.length ? 'false' : 'true'); + exportLink.style.opacity = signedCodes.length ? '' : '0.48'; + exportLink.querySelector('[data-quote-export-label]').textContent = '导出回签单' + (signedCodes.length ? '(' + signedCodes.length + ')' : ''); + } root.querySelector('[data-quote-page-label]').textContent = filteredQuotes.length ? '共 ' + filteredQuotes.length + ' 条 · 共 ' + pageCount + ' 页 · 当前第 ' + currentPage + ' 页' : '共 0 条 · 共 0 页'; @@ -1025,8 +1113,17 @@ setDetail('carrier', quote.company_name); setDetail('type', quote.quote_type); setDetail('range', text(quote.source_range, '-')); - setDetail('date', text(quote.effective_date, 'Excel 未标注')); + setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注')); setDetail('formulas', String(quote.formula_count)); + var signedBox = root.querySelector('[data-quote-signed]'); + var files = quote.signed_files || {}; + signedBox.hidden = !files.signed; + root.querySelector('[data-quote-signed-text]').textContent = '已签署回传' + (quote.signed_at ? ' · ' + quote.signed_at : ''); + signedBox.querySelectorAll('[data-quote-signed-link]').forEach(function (link) { + var url = files[link.getAttribute('data-quote-signed-link')]; + link.hidden = !url; + if (url) { link.href = url; } else { link.removeAttribute('href'); } + }); var table = root.querySelector('[data-quote-detail-table]'); var head = create('thead'); diff --git a/resources/views/business/pdf/courier-quote.blade.php b/resources/views/business/pdf/courier-quote.blade.php index 9837f254..2a283884 100644 --- a/resources/views/business/pdf/courier-quote.blade.php +++ b/resources/views/business/pdf/courier-quote.blade.php @@ -89,6 +89,9 @@ +@if(! empty($temporaryNote)) +
{!! $mixedText($temporaryNote) !!}
+@endif
本文件由财务系统根据已审核报价版本自动生成。乙方签章后文件与完成证书将按本版本单独归档;后续变价须生成新版本并重新签章,旧确认单不会被覆盖。
@if($change) diff --git a/resources/views/onboarding/courier-form.blade.php b/resources/views/onboarding/courier-form.blade.php new file mode 100644 index 00000000..52b7a41d --- /dev/null +++ b/resources/views/onboarding/courier-form.blade.php @@ -0,0 +1,1026 @@ +@php + /** @var \App\Models\Business\CourierOnboarding $onboarding */ + // 后台变价复用本页的报价区:$requote 为后台变价上下文(只显示报价,提交到后台)。 + $requote = $requote ?? null; + $editable = $requote !== null || $onboarding->courierCanEdit(); + $value = static fn (string $key, $default = null) => old($key, $onboarding->{$key} ?? $default); + $savedRows = collect($onboarding->quoteRowList())->keyBy('region'); + $oldRows = old('quote_rows'); + $quoteRows = []; + $customRows = $savedRows->except($provinces)->values()->all(); + foreach ([...$provinces, null, null, null, null] as $index => $province) { + $row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($province !== null ? ($savedRows->get($province) ?? []) : ($customRows[$index - count($provinces)] ?? [])); + $quoteRows[] = [ + 'region' => $province ?? ($row['region'] ?? ''), + 'fixed' => $province !== null, + 'flat' => array_values((array) ($row['flat'] ?? [])), + 'base' => $row['base'] ?? null, + 'rates' => array_values((array) ($row['rates'] ?? [])), + ]; + } + $structure = $onboarding->quoteStructure(); + $kgList = static fn (array $list): string => implode(',', array_map(static fn ($v) => \App\Support\CourierQuoteStructure::kg((float) $v), $list)); + $quoteSheet = $onboarding->files->where('kind', \App\Models\Business\CourierOnboardingFile::QUOTE_SHEET_KIND)->last(); + $filesByKind = $onboarding->files->groupBy('kind'); + $quoteMode = $value('quote_mode', 'waybill_rate'); + $entityChosen = $requote !== null || filled(old('entity_type')) || filled($onboarding->company_full_name) || $onboarding->status !== 'invited' || $onboarding->files->isNotEmpty(); + $entityType = $entityChosen ? $value('entity_type', 'company') : null; + $statusTone = match ($onboarding->status) { + 'returned' => 'warning', + 'activated' => 'success', + 'rejected' => 'danger', + default => 'info', + }; + $num = static fn ($v) => $v === null || $v === '' ? '' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'); +@endphp + + + + + + + + {{ $requote ? '快递变价' : '快递合作入驻' }} + + + +@if ($requote) +
+
+

← 返回报价管理 · 快递报价

+

{{ $requote['courier'] }} · 变价

+

以当前报价为底稿修改,可以只改价格,也可以改计费方式和重量分档。提交后需另一人审核,审核通过自动生成新报价版本,并推送到快递的 e签宝签署。

+
+ 底稿版本 {{ $requote['version']?->version_code ?? '入驻首版' }} + @if ($requote['change'])修改已退回的变价单 {{ $requote['change']->number }}@endif + 签署人 {{ $requote['signer'] }} +
+
+
+@else +
+
+

{{ company()?->name ? company()->name . ' · ' : '' }}快递合作

+

快递合作入驻资料

+

请如实填写贵司主体、结算账户和报价,提交后我方会在 1–3 个工作日内审核。

+
+ 申请编号 {{ $onboarding->number }} + @if ($onboarding->invite_label){{ $onboarding->invite_label }}@endif + @if ($editable && $onboarding->expires_at)链接有效期至 {{ $onboarding->expires_at->format('Y-m-d') }}@endif +
+
+
+@endif + +
+ @php + $stepIndex = match ($onboarding->status) { + 'invited', 'returned' => 0, + 'submitted', 'first_approved' => 1, + 'approved' => 2, + 'activated' => 3, + default => 1, + }; + @endphp + @unless ($requote) +
    + @foreach (['填写资料', '我方审核', '签署协议', '开通合作'] as $i => $step) +
  1. {{ $step }}
  2. + @endforeach +
+ @endunless + + @if (session('onboarding_success')) +
{{ session('onboarding_success') }}
+ @endif + + @if (request()->boolean('too_large')) + + @endif + + @if ($errors->any()) + + @endif + + @if ($requote) + @if ($requote['change'] && $requote['change']->status === 'returned') +
变价单 {{ $requote['change']->number }} 已被退回按审核意见修改后重新提交即可。
+ @endif + @elseif ($onboarding->status === 'returned' && $onboarding->return_reason) +
需要补充或修改{{ $onboarding->return_reason }}
+ @elseif (! $editable) +
+
+ 当前状态:{{ $onboarding->linkExpired() && in_array($onboarding->status, ['invited', 'returned'], true) ? '链接已过期' : $onboarding->statusLabel() }} + @switch ($onboarding->status) + @case('submitted') + @case('first_approved') + 资料已提交,审核中。如需修改,请联系我方对接人退回后再改。 + @break + @case('approved') + 审核已通过,报价确认单已推送到你填写的 e签宝账号,请留意短信或邮件,登录 e签宝完成签署。 + @break + @case('activated') + 合作已开通,感谢配合。 + @break + @case('rejected') + 很抱歉,本次暂未达成合作。 + @break + @default + 链接已过期,请联系我方对接人重新获取二维码。 + @endswitch +
+
+ @endif + +
+ @csrf + @if ($editable && ! $requote) +
+

请选择入驻方式

+ + +
+ @endif +
+ + @unless ($requote) +
+
1

入驻主体

与营业执照一致与身份证一致
+
+
+ 入驻类型* +
+ @foreach (\App\Models\Business\CourierOnboarding::ENTITY_TYPES as $type => $label) + + @endforeach +
+ 有营业执照的公司或个体工商户选这个,以公司名义签署。 + 没有营业执照、以个人身份合作选这个,用本人身份证实名签署。 +
+
+ + + @error('company_full_name'){{ $message }}@enderror +
+
+ + + @error('id_number'){{ $message }}@enderror +
+
+
+ + + @error('unified_social_credit_code'){{ $message }}@enderror +
+
+ + +
+
+
+ + +
+
+
+ + + @foreach (['中通', '圆通', '申通', '韵达', '极兔', '德邦', '顺丰', '京东', '邮政', '百世快运', '安能', '中通快运', '壹米滴答', '跨越'] as $brand) + @error('carrier_brand'){{ $message }}@enderror +
+
+ + + @error('network_name'){{ $message }}@enderror +
+
+
+ 业务类型* +
+ @foreach (\App\Models\Business\CourierOnboarding::SERVICE_TYPES as $type) + + @endforeach +
+ @error('service_type'){{ $message }}@enderror +
+
+ + +
+
+
+ +
+
2

联系人

+
+
+
+ + + @error('contact_name'){{ $message }}@enderror +
+
+ + + 用于接收合作协议的电子签署短信 + @error('contact_phone'){{ $message }}@enderror +
+
+
+
+ + +
+
+ + + @error('finance_contact_phone'){{ $message }}@enderror +
+
+
+
+ +
+
3

e签宝电子签署

报价确认单、合作协议都发到这里
+
+

审核通过后,系统会按你填写的报价自动生成报价确认单,推送到下面这个 e签宝账号,签署人用该账号登录 e签宝(或点短信链接)即可盖章签署,无需再纸质盖章寄回。

+
+
+ + + @error('esign_operator_name'){{ $message }}@enderror +
+
+ + + 手机号会收到签署短信,邮箱会收到签署邮件 + @error('esign_account'){{ $message }}@enderror +
+
+

签署主体为上面填写的公司全称,签署人需有该公司在 e签宝的签章权限。

+

个人入驻由本人签署,e签宝账号需用本人身份证完成实名认证。

+
+
+ +
+
4

结算与开票

运费打款账户(对公 / 对私)
+
+ + +
+ 开票类型* +
+ @foreach (\App\Models\Business\CourierOnboarding::INVOICE_TYPES as $type) + + @endforeach +
+ @error('invoice_type'){{ $message }}@enderror +
+
+
+ + + @error('tax_rate'){{ $message }}@enderror +
+
+ + + @error('settlement_cycle'){{ $message }}@enderror +
+
+
+
+ + @endunless +
+
{{ $requote ? 1 : 5 }}

报价

单位:元
+
+
+ ① 计费方式* +
+ @foreach (\App\Support\CourierQuoteStructure::MODES as $mode => $label) + @continue($requote && $mode === 'other') + + @endforeach +
+ @error('quote_mode'){{ $message }}@enderror +
+ +
+ ② 计费结构 +
+ 一口价重量档* + +
+ + 每档填上限重量,含上限。例如依次填 1、2、3,就是 0-1kg(含1)、1-2kg(含2)、2-3kg(含3) 三档。 + 超过最后一档还要按「面单+费率×重量」或「首重+续重」计费的,请改选上面的「小重量一口价 + 超出另算」。 + @error('quote_flat_bands'){{ $message }}@enderror +
+
+ 超出一口价部分怎么算* +
+ + +
+
+
+ + + @error('quote_first_weight'){{ $message }}@enderror +
+
+ + + 「35,50」表示 0-35kg、35-50kg、50kg以上三档,按总重量所在区间取价 + @error('quote_rate_tiers'){{ $message }}@enderror +
+
+ +
+ ③ 各省价格 +
+

全国统一价先在上面填好,点「填充到全部省份」(会覆盖已填的价格),个别省份再单独改;不发的省份整行留空。{{ $requote ? '审核通过后会生成新的报价确认单推送给快递签署,请仔细核对。' : '这份报价会直接生成报价确认单发给你签署,请仔细核对。' }}

+ @error('quote_rows'){{ $message }}@enderror +

列较多时左右滑动表格查看,省份一列固定不动

+
+
+
+ +
+ +
+
报价表*
+ + @error('files.quote_sheet'){{ $message }}@enderror +
+ + ④ 附加规则 +
+
+ 是否计泡* +
+ @foreach (\App\Models\Business\CourierOnboarding::VOLUMETRIC_RULES as $rule) + + @endforeach +
+ @error('volumetric_rule'){{ $message }}@enderror +
+
+ + + @error('volumetric_divisor'){{ $message }}@enderror +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + + @error('quote_effective_date'){{ $message }}@enderror +
+
+ @if (! empty($requote)) +
+
+ + + 留空 = 长期执行。填了就是临时价:只在执行日期~结束日期期间用,结束后第二天自动恢复原来的长期报价,快递不用再签。 + @error('quote_end_date'){{ $message }}@enderror +
+
+ @endif +
+ + +
+
+
+ + @if ($requote) +
+
2

变价说明

+
+
+ + + @error('summary'){{ $message }}@enderror +
+
+
+ @else +
+
6

上传证件照片

可直接拍照,系统会自动压缩
+
+
+ @foreach ($fileKinds as $kind => $label) + @php $existing = $filesByKind->get($kind, collect())->last(); @endphp +
+
+ + {{ $label }}@if (in_array($kind, $requiredKinds, true))*@endif + {{ $personKinds[$kind] ?? $label }}@if (in_array($kind, $personRequiredKinds, true))*@endif + +
+ + @if ($existing && $editable && ! in_array($kind, $requiredKinds, true)) + + @endif + @error('files.' . $kind){{ $message }}@enderror +
+ @endforeach +
+

+
+ + +
+
+
+ @endif +
+ + @if ($requote) +
+
+ 返回 + +
+
+ @elseif ($editable) +
+
+ + +
+
+ @endif +
+ + @unless ($requote) + + @endunless +
+ + + + diff --git a/resources/views/onboarding/courier-unavailable.blade.php b/resources/views/onboarding/courier-unavailable.blade.php new file mode 100644 index 00000000..aaf2c39b --- /dev/null +++ b/resources/views/onboarding/courier-unavailable.blade.php @@ -0,0 +1,23 @@ + + + + + + + 入驻链接不可用 + + + + + + diff --git a/routes/admin.php b/routes/admin.php index 1df5eb1e..9240187b 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,9 +1,11 @@ name('submit'); Route::post('{quoteChange}/approve', [CourierQuoteChanges::class, 'approve'])->name('approve'); Route::post('{quoteChange}/return', [CourierQuoteChanges::class, 'returnForChanges'])->name('return'); + Route::post('{quoteChange}/push-courier', [CourierQuoteChanges::class, 'pushToCourier'])->name('push-courier'); + Route::post('{quoteChange}/void', [CourierQuoteChanges::class, 'void'])->name('void'); }); Route::prefix('workspace/courier-payables/quote-management/signatures') ->name('business.courier-quote-signatures.') ->middleware('business.page:courier-payables,quote-management') ->group(function () { + Route::get('export', [CourierQuoteSignatures::class, 'export'])->name('export'); + Route::get('versions/{quoteVersion}/sheet', [CourierQuoteSignatures::class, 'sheet'])->name('sheet'); Route::post('versions/{quoteVersion}/prepare', [CourierQuoteSignatures::class, 'prepare'])->name('prepare'); Route::post('{quoteSignature}/send', [CourierQuoteSignatures::class, 'send'])->name('send'); Route::post('{quoteSignature}/synchronize', [CourierQuoteSignatures::class, 'synchronize'])->name('synchronize'); Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download'); }); +Route::prefix('workspace/courier-payables/courier-onboarding/applications') + ->name('business.courier-onboardings.') + ->middleware('business.page:courier-payables,courier-onboarding') + ->group(function (): void { + Route::post('/', [CourierOnboardings::class, 'store'])->name('store'); + Route::get('{onboarding}', [CourierOnboardings::class, 'show'])->name('show'); + Route::get('{onboarding}/qr', [CourierOnboardings::class, 'qr'])->name('qr'); + Route::get('{onboarding}/files/{file}', [CourierOnboardings::class, 'file'])->name('file'); + Route::post('{onboarding}/first-approve', [CourierOnboardings::class, 'firstApprove'])->name('first-approve'); + Route::post('{onboarding}/return', [CourierOnboardings::class, 'returnForChanges'])->name('return'); + Route::post('{onboarding}/reject', [CourierOnboardings::class, 'reject'])->name('reject'); + Route::post('{onboarding}/cancel', [CourierOnboardings::class, 'cancel'])->name('cancel'); + Route::post('{onboarding}/extend', [CourierOnboardings::class, 'extend'])->name('extend'); + Route::post('{onboarding}/internal', [CourierOnboardings::class, 'updateInternal'])->name('internal'); + Route::post('{onboarding}/erp-opening', [CourierOnboardings::class, 'erpOpening'])->name('erp-opening'); + Route::post('{onboarding}/final-approve', [CourierOnboardings::class, 'finalApprove'])->name('final-approve'); + Route::post('{onboarding}/activate', [CourierOnboardings::class, 'activate'])->name('activate'); + Route::post('{onboarding}/push-quote', [CourierOnboardings::class, 'pushQuote'])->name('push-quote'); + Route::post('{onboarding}/sync-quote', [CourierOnboardings::class, 'syncQuote'])->name('sync-quote'); + Route::post('{onboarding}/sign-url', [CourierOnboardings::class, 'signUrl'])->name('sign-url'); + Route::get('{onboarding}/requote', [CourierOnboardings::class, 'requote'])->name('requote'); + Route::post('{onboarding}/requote', [CourierOnboardings::class, 'storeRequote'])->name('requote.store'); + }); + Route::prefix('workspace/courier-payables/agreement-signatures') ->name('business.courier-agreement-signatures.') ->middleware('business.page:courier-payables,agreement-signing') @@ -338,6 +368,15 @@ Route::get('workspace/refunds/small-payment-work-orders/export', 'Business\Pages ->middleware('business.page:financial-operations,small-payment-work-orders') ->name('business.refunds.small-payment-work-orders.export'); +Route::prefix('workspace/courier-payables/courier-information/profiles') + ->name('business.courier-profiles.') + ->middleware('business.page:courier-payables,courier-information') + ->group(function (): void { + Route::post('payment-accounts', [CourierProfiles::class, 'storePaymentAccountForCode'])->name('payment-accounts.store-any'); + Route::post('{expcode}', [CourierProfiles::class, 'update'])->name('update')->where('expcode', '[0-9A-Za-z_\\-]+'); + Route::post('{expcode}/payment-accounts', [CourierProfiles::class, 'storePaymentAccount'])->name('payment-accounts.store')->where('expcode', '[0-9A-Za-z_\\-]+'); + }); + Route::get('workspace/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation') ->middleware('business.page:courier-payables,courier-information') ->name('business.courier-payables.courier-information.export'); diff --git a/routes/web.php b/routes/web.php index acc2b22f..14e78d5b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ middleware('throttle:120,1') ->name('integrations.esign.callback'); +// 快递入驻:快递方扫码打开的免登录填报页,凭链接里的随机 token 访问。 +Route::get('onboarding/courier/{token}', [CourierOnboardingForms::class, 'show']) + ->middleware('throttle:60,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('courier-onboarding.form'); +Route::post('onboarding/courier/{token}', [CourierOnboardingForms::class, 'update']) + ->middleware('throttle:20,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('courier-onboarding.update'); + Livewire::setScriptRoute(function ($handle) { $base = request()->getBasePath(); diff --git a/tests/Feature/Business/CourierInformationPageTest.php b/tests/Feature/Business/CourierInformationPageTest.php index c89afbbe..37278e94 100644 --- a/tests/Feature/Business/CourierInformationPageTest.php +++ b/tests/Feature/Business/CourierInformationPageTest.php @@ -31,6 +31,7 @@ class CourierInformationPageTest extends FeatureTestCase ->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'courier-information', + 'courier_tab' => 'express', ])) ->assertOk() ->assertSeeText('快递资料') @@ -78,6 +79,7 @@ class CourierInformationPageTest extends FeatureTestCase ->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'courier-information', + 'courier_tab' => 'express', 'expcode' => 'SF', 'status' => '1', ])) diff --git a/tests/Feature/Business/CourierOnboardingWorkflowTest.php b/tests/Feature/Business/CourierOnboardingWorkflowTest.php new file mode 100644 index 00000000..261d7346 --- /dev/null +++ b/tests/Feature/Business/CourierOnboardingWorkflowTest.php @@ -0,0 +1,1265 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + } + + public function test_list_page_renders_and_invite_generates_qr_code(): void + { + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding'])) + ->assertOk() + ->assertSee('aria-label="快递入驻"', false) + ->assertSeeText('生成入驻二维码') + ->assertSeeText('入驻流程'); + + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-onboardings.store'), ['invite_label' => '张经理']) + ->assertSessionHasErrors('courier_name'); + + $response = $this->loginAs()->post(route('business.courier-onboardings.store'), [ + 'courier_name' => '新北中通', + 'workplatform_expcode' => 'xbzt', + 'invite_label' => '新北中通 · 张经理', + 'valid_days' => 15, + ]); + + $onboarding = CourierOnboarding::query()->firstOrFail(); + $response->assertRedirect(route('business.courier-onboardings.show', $onboarding)); + $this->assertSame('新北中通', $onboarding->courier_name); + + // 系统快递名称不能和其他进行中的入驻重复。 + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-onboardings.store'), ['courier_name' => '新北中通', 'workplatform_expcode' => 'NEW1']) + ->assertSessionHasErrors('courier_name'); + + // 快递公司代码必填、自动转大写、不能和其他入驻重复。 + $this->assertSame('XBZT', $onboarding->workplatform_expcode); + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-onboardings.store'), ['courier_name' => '另一家']) + ->assertSessionHasErrors('workplatform_expcode'); + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-onboardings.store'), ['courier_name' => '另一家', 'workplatform_expcode' => 'XBZT']) + ->assertSessionHasErrors('workplatform_expcode'); + $this->assertSame(CourierOnboarding::STATUS_INVITED, $onboarding->status); + $this->assertSame(40, strlen($onboarding->token)); + $this->assertMatchesRegularExpression('/^RZ-\d{8}-001$/', $onboarding->number); + $this->assertTrue($onboarding->expires_at->between(now()->addDays(14), now()->addDays(16))); + + $this->loginAs()->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSeeText('入驻二维码') + ->assertSee(route('courier-onboarding.form', ['token' => $onboarding->token]), false); + + config(['courier_onboarding.public_url' => 'https://caiwu.example.cn:8442/']); + $this->loginAs()->get(route('business.courier-onboardings.show', $onboarding)) + ->assertSee('https://caiwu.example.cn:8442/onboarding/courier/' . $onboarding->token, false); + + $qr = $this->loginAs()->get(route('business.courier-onboardings.qr', $onboarding)); + $qr->assertOk(); + $this->assertStringStartsWith('image/svg+xml', (string) $qr->headers->get('Content-Type')); + $this->assertStringContainsString('getContent()); + } + + public function test_qr_encoder_builds_valid_structure_for_long_links(): void + { + $matrix = QrCode::matrix('https://caiwu.example.cn:8442/onboarding/courier/' . str_repeat('Ab3x', 10)); + $size = count($matrix); + + $this->assertSame(0, ($size - 17) % 4); + foreach ([[0, 0], [0, $size - 7], [$size - 7, 0]] as [$row, $column]) { + $this->assertTrue($matrix[$row][$column], '定位图形外框应为黑色'); + $this->assertTrue($matrix[$row + 3][$column + 3], '定位图形中心应为黑色'); + $this->assertFalse($matrix[$row + 1][$column + 1], '定位图形内圈应为白色'); + } + } + + public function test_courier_fills_form_without_login_and_submits_for_review(): void + { + $onboarding = $this->invite(); + $this->asGuest(); + + $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) + ->assertOk() + ->assertSeeText('快递合作入驻资料') + ->assertSeeText('公司全称') + ->assertSeeText('黑龙江') + ->assertSee('name="files[business_license][]"', false); + + $this->withExceptionHandling() + ->get(route('courier-onboarding.form', ['token' => str_repeat('x', 40)])) + ->assertNotFound(); + + // 暂存允许资料不完整。 + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), [ + 'action' => 'save', + 'company_full_name' => '常州新北快递服务有限公司', + ])->assertRedirect(route('courier-onboarding.form', ['token' => $onboarding->token])); + $this->assertSame(CourierOnboarding::STATUS_INVITED, $onboarding->fresh()->status); + + // 提交时缺字段、缺附件会被拦下。 + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), ['action' => 'submit']) + ->assertSessionHasErrors(['unified_social_credit_code', 'contact_phone', 'esign_account', 'quote_rows', 'files.business_license', 'files.legal_id_card_front', 'files.bank_card_back']); + $this->assertSame(CourierOnboarding::STATUS_INVITED, $onboarding->fresh()->status); + + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $this->completePayload()) + ->assertSessionHasNoErrors() + ->assertSessionHas('onboarding_success'); + + $onboarding->refresh(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); + $this->assertSame('91320411MA1XXXXX0Y', $onboarding->unified_social_credit_code); + $this->assertSame('6222020000123456789', $onboarding->bank_account_number); + $this->assertCount(2, $onboarding->quoteRowList()); + $this->assertEquals(['region' => '江苏', 'flat' => [], 'base' => 3.2, 'rates' => [0.8]], $onboarding->quoteRowList()[0]); + $this->assertSame(5, $onboarding->files()->count()); + $this->assertSame('13800001111', $onboarding->esign_account); + Storage::disk('uploads')->assertExists($onboarding->files()->first()->path); + + // 银行账号加密落库。 + $raw = \DB::table('business_courier_onboardings')->where('id', $onboarding->id)->value('bank_account_number'); + $this->assertNotSame('6222020000123456789', $raw); + + // 提交后不能再改。 + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $this->completePayload()) + ->assertSessionHasErrors('form'); + $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) + ->assertOk() + ->assertSeeText('资料已提交,审核中'); + } + + public function test_return_lets_courier_fix_and_resubmit(): void + { + $onboarding = $this->submittedOnboarding(); + + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.return', $onboarding), ['reason' => '开户行请写到支行']) + ->assertRedirect(route('business.courier-onboardings.show', $onboarding)); + $this->assertSame(CourierOnboarding::STATUS_RETURNED, $onboarding->fresh()->status); + + $this->asGuest(); + $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) + ->assertOk() + ->assertSeeText('开户行请写到支行'); + + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), ['bank_name' => '中国工商银行常州新北支行'] + $this->completePayload(withFiles: false)) + ->assertSessionHasNoErrors(); + $onboarding->refresh(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); + $this->assertSame('中国工商银行常州新北支行', $onboarding->bank_name); + $this->assertNull($onboarding->return_reason); + } + + public function test_two_level_review_creates_quote_version_and_activation_requires_signature(): void + { + $onboarding = $this->submittedOnboarding(); + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => '资料齐全']) + ->assertRedirect(); + $this->assertSame(CourierOnboarding::STATUS_FIRST_APPROVED, $onboarding->fresh()->status); + + // 初审人不能自己复审。 + $this->withExceptionHandling()->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '新北中通']) + ->assertSessionHasErrors('approval'); + + $reviewer = $this->createReviewer(); + $this->fakeEsign(); + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSeeText('复审通过,生成首版报价'); + + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.final-approve', $onboarding), [ + 'courier_name' => '新北中通-入驻', + 'reconciler' => '李萌', + ]) + ->assertSessionHasNoErrors(); + + $onboarding->refresh(); + $this->assertSame(CourierOnboarding::STATUS_APPROVED, $onboarding->status); + $this->assertSame('新北中通-入驻', $onboarding->courier_name); + $version = CourierQuoteVersion::query()->findOrFail($onboarding->quote_version_id); + $this->assertSame('新北中通-入驻', $version->carrier_name); + $this->assertSame('待签署', $version->signature_status); + $this->assertMatchesRegularExpression('/^QB-\d{3}$/', $version->version_code); + $values = json_decode($version->values_json, true); + $headerIndex = collect($values)->search(static fn ($row) => ($row[0] ?? null) === '省份'); + $this->assertSame(['省份', '面单', '费率(元/kg)'], $values[$headerIndex]); + $this->assertSame(['江苏', 3.2, 0.8], $values[$headerIndex + 1]); + $this->assertContains('计费方式:面单+重量*费率', array_column($values, 0)); + $this->assertContains('计泡:长*宽*高/6000,取实重与体积重较大值', array_column($values, 0)); + $change = CourierQuoteChange::query()->findOrFail($onboarding->quote_change_id); + $this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status); + $this->assertSame('新快递入驻', $change->change_type); + + // 复审通过即用快递填的报价生成确认单 PDF,推到快递填写的 e签宝账号。 + $quoteSignature = $version->fresh()->signature; + $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $quoteSignature->status); + $this->assertSame('flow-onboard', $quoteSignature->esign_sign_flow_id); + Storage::disk('uploads')->assertExists($quoteSignature->source_pdf_path); + Http::assertSent(static fn (ClientRequest $request): bool => $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' + && data_get(json_decode($request->body(), true), 'signers.0.orgSignerInfo.orgName') === '常州新北快递服务有限公司' + && str_contains($request->body(), '13800001111')); + + // 可以取签署链接直接发给快递,企业签署时按企业身份打开。 + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.sign-url', $onboarding)) + ->assertRedirect() + ->assertSessionHas('quote_sign_url', 'https://t.esign.cn/abc'); + Http::assertSent(static fn (ClientRequest $request): bool => $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/flow-onboard/sign-url' + && data_get(json_decode($request->body(), true), 'organization.orgName') === '常州新北快递服务有限公司' + && data_get(json_decode($request->body(), true), 'operator.psnAccount') === '13800001111'); + $this->loginAs($reviewer, $this->company)->withSession(['quote_sign_url' => 'https://t.esign.cn/abc']) + ->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSee('value="https://t.esign.cn/abc"', false) + ->assertSeeText('获取签署链接'); + + // 报价确认单还没签完,也没写线下说明,不能开通。 + $this->withExceptionHandling()->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.activate', $onboarding), []) + ->assertSessionHasErrors('offline_note'); + + Storage::disk('uploads')->put('quote-signatures/test/signed.pdf', '%PDF-signed'); + Storage::disk('uploads')->put('quote-signatures/test/certificate.pdf', '%PDF-cert'); + $quoteSignature->update([ + 'status' => CourierQuoteSignature::STATUS_ARCHIVED, + 'signed_pdf_path' => 'quote-signatures/test/signed.pdf', + 'certificate_path' => 'quote-signatures/test/certificate.pdf', + 'archived_at' => now(), + ]); + $version->update(['signature_status' => '已签署并归档']); + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSeeText('报价确认单已签署归档') + ->assertSeeText('回签单 PDF') + ->assertSeeText('导出电子档'); + + // 回签单显示在报价管理 · 快递报价,可单个下载或打包导出电子档。 + $quotes = app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes']; + $listed = collect($quotes)->firstWhere('id', $version->version_code); + $this->assertNotNull($listed); + $this->assertSame(route('business.courier-quote-signatures.download', [$quoteSignature, 'signed']), $listed['signed_files']['signed']); + $this->assertArrayHasKey('certificate', $listed['signed_files']); + $this->loginAs($reviewer, $this->company) + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])) + ->assertOk() + ->assertSeeText('导出回签单'); + $this->loginAs($reviewer, $this->company) + ->get($listed['signed_files']['signed']) + ->assertOk(); + $zipResponse = $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-quote-signatures.export', ['codes' => $version->version_code])); + $zipResponse->assertOk(); + $zipFile = $zipResponse->baseResponse->getFile()->getPathname(); + $zip = new \ZipArchive(); + $this->assertTrue($zip->open($zipFile) === true); + $names = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $names[] = $zip->getNameIndex($i); + } + $zip->close(); + $this->assertCount(2, $names); + $this->assertTrue(collect($names)->contains(fn ($name) => str_ends_with($name, '-回签单.pdf'))); + $this->assertTrue(collect($names)->contains(fn ($name) => str_ends_with($name, '-签署完成证书.pdf'))); + + // 导出电子档:xlsx 只保留省份表头和价格行,不带标题、执行时间、计费说明。 + $this->assertSame(route('business.courier-quote-signatures.sheet', $version), $listed['signed_files']['export']); + $sheetResponse = $this->loginAs($reviewer, $this->company)->get($listed['signed_files']['export']); + $sheetResponse->assertOk(); + $this->assertStringContainsString('.xlsx', (string) $sheetResponse->headers->get('content-disposition')); + $xlsxPath = tempnam(sys_get_temp_dir(), 'quote-sheet-') . '.xlsx'; + file_put_contents($xlsxPath, $sheetResponse->streamedContent()); + $rows = \PhpOffice\PhpSpreadsheet\IOFactory::load($xlsxPath)->getActiveSheet()->toArray(null, false, false, false); + @unlink($xlsxPath); + $this->assertSame(['省份', '面单', '费率(元/kg)'], $rows[0]); + $this->assertSame(['江苏', 3.2, 0.8], $rows[1]); + $flat = collect($rows)->flatten()->filter()->implode(' '); + $this->assertStringNotContainsString('执行时间', $flat); + $this->assertStringNotContainsString('计费方式', $flat); + $this->assertStringNotContainsString('计泡', $flat); + + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.activate', $onboarding), []) + ->assertSessionHasNoErrors(); + $this->assertSame(CourierOnboarding::STATUS_ACTIVATED, $onboarding->fresh()->status); + + $this->loginAs($reviewer, $this->company) + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'onboarded'])) + ->assertOk() + ->assertSeeText('入驻快递') + ->assertSeeText('新北中通-入驻') + ->assertSeeText('常州新北快递服务有限公司'); + } + + public function test_person_onboarding_uses_id_card_and_signs_as_individual(): void + { + $onboarding = $this->invite(); + $this->asGuest(); + $token = $onboarding->token; + + $this->get(route('courier-onboarding.form', ['token' => $token])) + ->assertOk() + ->assertSeeText('入驻类型') + ->assertSeeText('身份证号'); + + $person = [ + 'entity_type' => 'person', + 'company_full_name' => '王小二', + 'unified_social_credit_code' => '91320411MA1XXXXX0Y', + 'bank_account_name' => '王小二', + 'private_account_name' => '王小二', + 'esign_operator_name' => '别人', + ] + $this->completePayload(withFiles: false); + + // 个人缺身份证号、身份证照片时不能提交,但不要求营业执照。 + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $token]), $person) + ->assertSessionHasErrors(['id_number', 'files.legal_id_card_front', 'files.legal_id_card_back', 'files.bank_card_front', 'files.bank_card_back']) + ->assertSessionDoesntHaveErrors(['files.business_license', 'unified_social_credit_code']); + + $this->post(route('courier-onboarding.update', ['token' => $token]), ['id_number' => '32041119900101123x', 'files' => [ + 'legal_id_card_front' => [UploadedFile::fake()->image('id-front.jpg', 600, 400)], + 'legal_id_card_back' => [UploadedFile::fake()->image('id-back.jpg', 600, 400)], + 'bank_card_front' => [UploadedFile::fake()->image('card-front.jpg', 600, 400)], + 'bank_card_back' => [UploadedFile::fake()->image('card-back.jpg', 600, 400)], + ]] + $person)->assertSessionHasNoErrors(); + + $onboarding->refresh(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); + $this->assertTrue($onboarding->isPerson()); + $this->assertSame('32041119900101123X', $onboarding->id_number); + $this->assertNull($onboarding->unified_social_credit_code); + $this->assertSame('王小二', $onboarding->esign_operator_name, '个人入驻由本人签署'); + $this->assertNotSame('32041119900101123X', \DB::table('business_courier_onboardings')->where('id', $onboarding->id)->value('id_number')); + + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding)); + $this->loginAs($this->user, $this->company)->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSeeText('个人入驻') + ->assertSeeText('320***********123X') + ->assertSeeText('身份证 · 人像面'); + + $reviewer = $this->createReviewer(); + $this->fakeEsign(); + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '王小二快递']) + ->assertSessionHasNoErrors(); + + Http::assertSent(static function (ClientRequest $request): bool { + if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') { + return false; + } + $payload = json_decode($request->body(), true); + + return data_get($payload, 'signers.0.signerType') === 0 + && data_get($payload, 'signers.0.psnSignerInfo.psnInfo.psnName') === '王小二' + && data_get($payload, 'signers.0.psnSignerInfo.psnAccount') === '13800001111' + && data_get($payload, 'signers.0.orgSignerInfo') === null; + }); + } + + public function test_new_link_starts_with_entity_chooser_and_reupload_replaces_photo(): void + { + $onboarding = $this->invite(); + $this->asGuest(); + $url = route('courier-onboarding.form', ['token' => $onboarding->token]); + + $this->get($url)->assertOk() + ->assertSeeText('请选择入驻方式') + ->assertSeeText('个人入驻') + ->assertSee('data-entity="none"', false); + + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), [ + 'action' => 'save', 'entity_type' => 'person', 'company_full_name' => '王小二', + 'files' => ['legal_id_card_front' => [UploadedFile::fake()->image('a.jpg')]], + ])->assertSessionHasNoErrors(); + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), [ + 'action' => 'save', 'entity_type' => 'person', 'company_full_name' => '王小二', + 'files' => ['legal_id_card_front' => [UploadedFile::fake()->image('b.jpg')]], + ])->assertSessionHasNoErrors(); + + $files = $onboarding->fresh()->files; + $this->assertCount(1, $files, '同一项资料重新上传应替换旧照片'); + $this->assertSame('b.jpg', $files->first()->original_name); + + $this->get($url)->assertOk()->assertSee('data-entity="person"', false); + $this->get($url . '?too_large=1')->assertOk()->assertSeeText('图片太大,这次没有保存成功'); + } + + public function test_same_reviewer_switch_allows_one_person_in_test_mode(): void + { + $onboarding = $this->submittedOnboarding(); + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding)); + + config(['courier_onboarding.allow_same_reviewer' => true]); + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-onboardings.show', $onboarding)) + ->assertSeeText('复审通过,生成首版报价'); + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.final-approve', $onboarding), []) + ->assertSessionHasNoErrors(); + $this->assertSame(CourierOnboarding::STATUS_APPROVED, $onboarding->fresh()->status); + $this->assertSame($onboarding->courier_name, $onboarding->fresh()->courier_name, '复审沿用生成二维码时定好的系统快递名称'); + } + + public function test_quote_structures_cover_the_four_billing_modes(): void + { + $columns = static fn (array $input): array => array_column(CourierQuoteStructure::columns(CourierQuoteStructure::normalize($input)), 'label'); + + $this->assertSame(['面单', '费率 0-35kg', '费率 35-50kg', '费率 50kg以上'], + $columns(['quote_mode' => 'waybill_rate', 'quote_rate_tiers' => '50,35'])); + $this->assertSame(['首重5kg(元)', '续重(元/kg)'], + $columns(['quote_mode' => 'first_additional', 'quote_first_weight' => '5'])); + $this->assertSame(['0-3kg(含3) 一口价', '3-4kg(含4) 一口价', '4-5kg(含5) 一口价'], + $columns(['quote_mode' => 'flat_bands', 'quote_flat_bands' => '3、4、5kg', 'quote_rate_tiers' => '30'])); + $this->assertSame(['0-1kg(含1) 一口价', '1-2kg(含2) 一口价', '2-3kg(含3) 一口价', '3kg 以上 面单', '费率 3-30kg', '费率 30kg以上'], + $columns(['quote_mode' => 'flat_then', 'quote_flat_bands' => '1,2,3', 'quote_then_mode' => 'waybill_rate', 'quote_rate_tiers' => '30'])); + $this->assertSame(['0-3kg(含3) 一口价', '3kg 以上 首重3kg(元)', '续重(元/kg)'], + $columns(['quote_mode' => 'flat_then', 'quote_flat_bands' => '3', 'quote_then_mode' => 'first_additional', 'quote_first_weight' => '3'])); + $this->assertSame([], $columns(['quote_mode' => 'other'])); + + $structure = CourierQuoteStructure::normalize(['quote_mode' => 'flat_then', 'quote_flat_bands' => '3']); + $rows = CourierQuoteStructure::normalizeRows([ + ['region' => '江苏', 'flat' => ['3.8'], 'base' => '3.5', 'rates' => []], + ['region' => '西藏', 'flat' => [''], 'base' => '', 'rates' => ['']], + ], $structure); + $this->assertCount(1, $rows, '整行留空的省份不保存'); + $this->assertSame(['quote_rows' => '江苏还有价格没填:费率(元/kg)。不发的省份整行留空即可。'], CourierQuoteStructure::validate($structure, $rows)); + $this->assertArrayHasKey('quote_flat_bands', CourierQuoteStructure::validate(CourierQuoteStructure::normalize(['quote_mode' => 'flat_bands']), [])); + $this->assertArrayHasKey('quote_first_weight', CourierQuoteStructure::validate(CourierQuoteStructure::normalize(['quote_mode' => 'first_additional']), [])); + } + + public function test_flat_band_quote_and_other_mode_with_quote_sheet(): void + { + $onboarding = $this->invite(); + $this->asGuest(); + $flat = [ + 'quote_mode' => 'flat_bands', + 'quote_flat_bands' => '3,6', + 'quote_rows' => [['region' => '山东', 'flat' => ['6', '9']], ['region' => '河北', 'flat' => ['6.5', '']]], + 'min_weight' => '1', + 'weight_rounding' => 'ceil', + 'per_ticket_surcharge' => '1', + ] + $this->completePayload(); + + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $flat) + ->assertSessionHasErrors(['quote_rows' => '河北还有价格没填:3-6kg(含6) 一口价。不发的省份整行留空即可。']); + + $flat['quote_rows'][1]['flat'][1] = '10'; + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $flat)->assertSessionHasNoErrors(); + $onboarding->refresh(); + $this->assertSame('flat_bands', $onboarding->quote_mode); + $this->assertEquals([3, 6], $onboarding->quoteStructure()['flat_bands']); + $this->assertContains('重量取整:向上取整(如 2.1kg 按 3kg)', $onboarding->quoteRuleLines()); + $this->assertContains('每票加收:1元', $onboarding->quoteRuleLines()); + + $other = $this->invite(); + $this->asGuest(); + $payload = ['quote_mode' => 'other', 'quote_rows' => []] + $this->completePayload(); + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $other->token]), $payload) + ->assertSessionHasErrors('files.quote_sheet'); + $payload['files']['quote_sheet'] = [UploadedFile::fake()->create('报价表.xlsx', 30, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')]; + $this->post(route('courier-onboarding.update', ['token' => $other->token]), $payload)->assertSessionHasNoErrors(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $other->fresh()->status); + } + + public function test_non_admin_cannot_final_review_or_activate(): void + { + $onboarding = $this->submittedOnboarding(); + $onboarding->update(['status' => CourierOnboarding::STATUS_FIRST_APPROVED, 'first_reviewed_by' => 999]); + + $member = $this->createReviewer('manager'); + $this->withExceptionHandling()->loginAs($member, $this->company) + ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '新北中通']) + ->assertForbidden(); + $this->assertSame(CourierOnboarding::STATUS_FIRST_APPROVED, $onboarding->fresh()->status); + } + + public function test_cancelled_invitation_link_stops_working(): void + { + $onboarding = $this->invite(); + $this->loginAs()->post(route('business.courier-onboardings.cancel', $onboarding))->assertRedirect(); + + $this->asGuest(); + $this->withExceptionHandling() + ->get(route('courier-onboarding.form', ['token' => $onboarding->token])) + ->assertNotFound(); + } + + public function test_expired_link_cannot_be_saved_until_extended(): void + { + $onboarding = $this->invite(); + $onboarding->update(['expires_at' => now()->subDay()]); + + $this->asGuest(); + $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) + ->assertOk() + ->assertSeeText('链接已过期'); + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), ['action' => 'save']) + ->assertSessionHasErrors('form'); + + $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.extend', $onboarding))->assertRedirect(); + $this->assertTrue($onboarding->fresh()->expires_at->isFuture()); + } + + public function test_backend_requote_reviews_then_pushes_new_version_to_courier_esign(): void + { + $onboarding = $this->submittedOnboarding(); + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + $reviewer = $this->createReviewer(); + $this->fakeEsign(); + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => '资料齐全']); + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '宿迁圆通']) + ->assertSessionHasNoErrors(); + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-onboardings.activate', $onboarding), ['offline_note' => '测试开通']) + ->assertSessionHasNoErrors(); + $onboarding->refresh(); + $firstVersion = $onboarding->quoteVersion; + $this->assertSame(CourierOnboarding::STATUS_ACTIVATED, $onboarding->status); + $this->assertSame('waybill_rate', CourierQuoteChange::query()->findOrFail($firstVersion->source_change_id)->quote_payload['quote_mode']); + + // 报价管理里点「变价」:已入驻快递直接链到后台变价页(旧的 create 链接也会转过去),底稿是当前报价。 + $listed = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->firstWhere('id', $firstVersion->version_code); + $this->assertSame(route('business.courier-onboardings.requote', $onboarding), $listed['requote_url']); + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-quote-changes.create', ['source_quote_id' => $firstVersion->version_code])) + ->assertRedirect(route('business.courier-onboardings.requote', $onboarding)); + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-onboardings.requote', $onboarding)) + ->assertOk() + ->assertSeeText('宿迁圆通 · 变价') + ->assertSeeText('底稿版本 ' . $firstVersion->version_code) + ->assertDontSeeText('其他(上传报价表)') + ->assertDontSee('name="company_full_name"', false) + ->assertSee('"base":3.2', false); + + $quote = static fn (array $rows, array $extra = []): array => [ + 'quote_mode' => 'waybill_rate', + 'volumetric_rule' => '计泡', + 'volumetric_divisor' => '6000', + 'quote_effective_date' => now()->addDays(3)->toDateString(), + 'quote_rows' => $rows, + 'summary' => '11 月起江苏面单上调', + ...$extra, + ]; + + // 报价没变不能提交。 + $this->withExceptionHandling()->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ + ['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']], + ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']], + ])) + ->assertSessionHasErrors('quote_rows'); + + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ + ['region' => '江苏', 'base' => '3.5', 'rates' => ['0.8']], + ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']], + ])) + ->assertSessionHasNoErrors(); + $change = CourierQuoteChange::query()->latest('id')->firstOrFail(); + $this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status); + $this->assertSame('变价', $change->change_type); + $this->assertSame($firstVersion->version_code, $change->source_quote_id); + $this->assertSame('宿迁圆通', $change->carrier_name); + + // 同时只能有一张待审核的变价单。 + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-onboardings.requote', $onboarding)) + ->assertRedirect(route('business.courier-quote-changes.show', $change)); + + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-quote-changes.show', $change)) + ->assertOk() + ->assertSeeText('新旧报价对比 · 变价') + ->assertSeeText('共 1 处价格变动') + ->assertSeeText('+0.3') + ->assertSeeText('新报价('); + + // 发起人不能自己审核;另一人审核通过后生成新版本并按入驻资料推送到快递 e签宝。 + $this->withExceptionHandling()->loginAs($this->user, $this->company) + ->post(route('business.courier-quote-changes.approve', $change)) + ->assertSessionHasErrors('approval'); + config(['courier_onboarding.allow_same_reviewer' => true]); + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-quote-changes.show', $change)) + ->assertSeeText('审核通过'); + config(['courier_onboarding.allow_same_reviewer' => false]); + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-quote-changes.approve', $change)) + ->assertSessionHasNoErrors() + ->assertSessionHas('success', fn (string $message): bool => str_contains($message, '已推送到快递 e签宝')); + $change->refresh(); + $newVersion = $change->version; + $this->assertNotNull($newVersion); + $this->assertNotSame($firstVersion->version_code, $newVersion->version_code); + $this->assertSame('宿迁圆通', $newVersion->company_name); + $values = json_decode($newVersion->values_json, true); + $header = collect($values)->search(static fn ($row) => ($row[0] ?? null) === '省份'); + $this->assertSame(['江苏', 3.5, 0.8], $values[$header + 1]); + $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $newVersion->signature->status); + $this->assertSame('常州新北快递服务有限公司', $newVersion->signature->organization_name); + $this->assertSame('新快递入驻报价', $newVersion->quote_type); + $this->assertSame('快递变价报价', collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->firstWhere('id', $newVersion->version_code)['quote_type']); + $this->assertSame('新快递入驻报价', collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->firstWhere('id', $firstVersion->version_code)['quote_type']); + + // 没有回调、没开轮询时,打开报价管理会顺手查询签署中的确认单(距上次查询超过 1 分钟)。 + $this->travel(2)->minutes(); + $before = $newVersion->signature->fresh()->last_synced_at; + $this->loginAs($reviewer, $this->company) + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])) + ->assertOk(); + $this->assertTrue($newVersion->signature->fresh()->last_synced_at->gt($before)); + Http::assertSent(static fn (ClientRequest $request): bool => str_contains($request->url(), '/v3/sign-flow/' . $newVersion->signature->esign_sign_flow_id . '/detail')); + + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSeeText('报价管理 · 快递报价(变价)') + ->assertDontSeeText('发起变价'); + + // 两版都签完后:执行日期已到且最新的一版「生效中」,旧版「已失效」。版本文字状态滞后时以签署记录为准。 + $firstVersion->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); + $firstVersion->update(['signature_status' => '已签署并归档', 'effective_date' => today()->subDays(10)]); + $newVersion->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); + $newVersion->update(['signature_status' => '签署完成,待归档', 'effective_date' => today()]); + $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); + $this->assertSame('已失效', $quotes[$firstVersion->version_code]['status']); + $this->assertSame($newVersion->version_code, $quotes[$firstVersion->version_code]['superseded_by']); + $this->assertSame('生效中', $quotes[$newVersion->version_code]['status']); + + // 改计费方式:小重量一口价 + 超出另算,底稿是刚生成的新版本。 + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ + ['region' => '江苏', 'flat' => ['3', '4', '5'], 'base' => '3.5', 'rates' => ['1']], + ], ['quote_mode' => 'flat_then', 'quote_flat_bands' => '1,2,3', 'quote_then_mode' => 'waybill_rate', 'summary' => '改为三档一口价'])) + ->assertSessionHasNoErrors(); + $structureChange = CourierQuoteChange::query()->latest('id')->firstOrFail(); + $this->assertSame('重新报价', $structureChange->change_type); + $this->assertSame($newVersion->version_code, $structureChange->source_quote_id); + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-quote-changes.show', $structureChange)) + ->assertOk() + ->assertSeeText('计费方式调整') + ->assertSeeText('不再报价:浙江') + ->assertSeeText('0-1kg(含1) 一口价'); + + // 退回后在报价管理点「变价」继续改,更新同一张变价单。 + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-quote-changes.return', $structureChange), ['review_note' => '浙江也要报价']); + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-onboardings.requote', $onboarding)) + ->assertOk() + ->assertSeeText('已被退回'); + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ + ['region' => '江苏', 'flat' => ['3', '4', '5'], 'base' => '3.5', 'rates' => ['1']], + ['region' => '浙江', 'flat' => ['3', '4', '5'], 'base' => '3.6', 'rates' => ['1']], + ], ['quote_mode' => 'flat_then', 'quote_flat_bands' => '1,2,3', 'quote_then_mode' => 'waybill_rate', 'summary' => '改为三档一口价'])) + ->assertSessionHasNoErrors(); + $this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $structureChange->fresh()->status); + $this->assertSame($structureChange->id, CourierQuoteChange::query()->latest('id')->value('id')); + } + + public function test_wrong_requote_is_flagged_in_review_and_can_be_voided(): void + { + $onboarding = $this->submittedOnboarding(); + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + $reviewer = $this->createReviewer(); + $this->fakeEsign(); + $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => 'ok']); + $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '宿迁圆通']); + $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.activate', $onboarding), ['offline_note' => '测试']); + $onboarding->refresh(); + $firstVersion = $onboarding->quoteVersion; + $firstVersion->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); + $firstVersion->update(['signature_status' => '已签署并归档', 'effective_date' => today()->subDays(5)]); + + // 发错:面单多打一位、费率填 0、执行日期早于当前生效版本 → 审核页标红提醒。 + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), [ + 'quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000', + 'quote_effective_date' => today()->subDays(10)->toDateString(), + 'quote_rows' => [ + ['region' => '江苏', 'base' => '32', 'rates' => ['0.8']], + ['region' => '浙江', 'base' => '3.5', 'rates' => ['0']], + ], + 'summary' => '调价', + ]) + ->assertSessionHasNoErrors(); + $change = CourierQuoteChange::query()->latest('id')->firstOrFail(); + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-quote-changes.show', $change)) + ->assertOk() + ->assertSeeText('审核提醒(请重点核对)') + ->assertSeeText('早于当前生效版本 ' . $firstVersion->version_code) + ->assertSeeText('江苏 面单 3.2→32') + ->assertSeeText('价格为 0:浙江 费率(元/kg)'); + + // 审核人没注意通过了,快递还没签:作废会撤回 e签宝 待签任务,版本不再参与生效。 + $this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasNoErrors(); + $wrong = $change->fresh()->version; + $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $wrong->signature->status); + $this->withExceptionHandling()->loginAs($reviewer, $this->company) + ->post(route('business.courier-quote-changes.void', $change), []) + ->assertSessionHasErrors('void_reason'); + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-quote-changes.void', $change), ['void_reason' => '江苏面单多打了一位']) + ->assertSessionHasNoErrors() + ->assertSessionHas('success', fn (string $message): bool => str_contains($message, '已撤回快递 e签宝')); + Http::assertSent(static fn (ClientRequest $request): bool => str_ends_with($request->url(), '/v3/sign-flow/' . $wrong->signature->esign_sign_flow_id . '/revoke')); + $wrong->refresh(); + $this->assertSame(CourierQuoteVersion::STATUS_VOIDED, $wrong->status); + $this->assertSame(CourierQuoteSignature::STATUS_REVOKED, $wrong->signature->status); + $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); + $this->assertSame('已作废', $quotes[$wrong->version_code]['status']); + $this->assertSame('江苏面单多打了一位', $quotes[$wrong->version_code]['void_reason']); + $this->assertSame('生效中', $quotes[$firstVersion->version_code]['status']); + $this->loginAs($reviewer, $this->company) + ->get(route('business.courier-quote-changes.show', $change)) + ->assertOk() + ->assertSeeText($wrong->version_code . ' 已作废') + ->assertDontSeeText('作废此版本'); + + // 作废后再变价,底稿回到仍有效的版本。 + $this->loginAs($this->user, $this->company) + ->get(route('business.courier-onboardings.requote', $onboarding)) + ->assertOk() + ->assertSeeText('底稿版本 ' . $firstVersion->version_code); + + // 已签完的版本也能作废(只停用,回签单保留)。 + $this->loginAs($reviewer, $this->company) + ->post(route('business.courier-quote-changes.void', $firstVersion->source_change_id), ['void_reason' => '测试已签作废']) + ->assertSessionHas('success', fn (string $message): bool => str_contains($message, '回签单保留')); + $this->assertSame(CourierQuoteVersion::STATUS_VOIDED, $firstVersion->fresh()->status); + } + + public function test_company_needs_both_accounts_matching_license_and_person_corporate_is_optional(): void + { + $onboarding = $this->invite(); + $this->asGuest(); + $token = $onboarding->token; + $this->get(route('courier-onboarding.form', ['token' => $token])) + ->assertOk() + ->assertSeeText('对公账户') + ->assertSeeText('对私账户') + ->assertSeeText('对私银行卡 · 正面'); + + // 公司入驻:对公、对私都必填。 + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $token]), [ + 'bank_account_name' => '', 'bank_name' => '', 'bank_account_number' => '', + 'private_account_name' => '', 'private_bank_name' => '', 'private_account_number' => '', + ] + $this->completePayload(withFiles: false)) + ->assertSessionHasErrors(['bank_account_name', 'bank_account_number', 'private_account_name', 'private_account_number']); + + // 对公户名须等于公司全称(全角括号、空格不影响),对私收款人须等于法定代表人。 + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $token]), [ + 'company_full_name' => '常州新北快递服务有限公司(新北分部)', + 'bank_account_name' => '常州新北物流有限公司', + 'private_account_name' => '李四', + ] + $this->completePayload(withFiles: false)) + ->assertSessionHasErrors([ + 'bank_account_name' => '对公账户户名「常州新北物流有限公司」与营业执照公司全称「常州新北快递服务有限公司(新北分部)」不一致,对公账户须为公司本身的账户。', + 'private_account_name' => '对私收款人「李四」与营业执照法定代表人「王某」不一致,对私账户须为营业执照法定代表人本人的银行卡。', + ]); + $this->post(route('courier-onboarding.update', ['token' => $token]), [ + 'company_full_name' => '常州新北快递服务有限公司(新北分部)', + 'bank_account_name' => '常州新北快递服务有限公司 (新北分部)', + ] + $this->completePayload()) + ->assertSessionHasNoErrors(); + $onboarding->refresh(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); + $this->assertSame('6228480000001111', $onboarding->private_account_number); + $this->assertNotSame('6228480000001111', \DB::table('business_courier_onboardings')->where('id', $onboarding->id)->value('private_account_number')); + $this->assertSame(['corporate' => true, 'private' => true], $onboarding->accountChecks()); + + $this->loginAs() + ->get(route('business.courier-onboardings.show', $onboarding)) + ->assertOk() + ->assertSeeText('✓ 与营业执照公司全称一致') + ->assertSeeText('✓ 与法定代表人一致') + ->assertSeeText('6228'); + + // 个人入驻:对公账户选填,对私收款人须为本人。 + $person = $this->invite(); + $this->asGuest(); + $payload = [ + 'entity_type' => 'person', + 'company_full_name' => '王小二', + 'id_number' => '32041119900101123X', + 'bank_account_name' => '', 'bank_name' => '', 'bank_account_number' => '', + 'private_account_name' => '王某', + ] + $this->completePayload(); + $this->withExceptionHandling() + ->post(route('courier-onboarding.update', ['token' => $person->token]), $payload) + ->assertSessionHasErrors(['private_account_name']) + ->assertSessionDoesntHaveErrors(['bank_account_name', 'bank_account_number']); + $this->post(route('courier-onboarding.update', ['token' => $person->token]), ['private_account_name' => '王小二'] + $payload) + ->assertSessionHasNoErrors(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $person->fresh()->status); + } + + public function test_courier_overview_merges_workplatform_and_onboarding_rows(): void + { + $make = function (string $name, array $extra = []): CourierOnboarding { + return CourierOnboarding::query()->forceCreate([ + 'company_id' => $this->company->id, + 'token' => \Illuminate\Support\Str::random(40), + 'number' => 'RZ-TEST-' . uniqid(), + 'status' => CourierOnboarding::STATUS_ACTIVATED, + 'entity_type' => 'company', + 'courier_name' => $name, + 'company_full_name' => $name . '有限公司', + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ...$extra, + ]); + }; + $matched = $make('宿迁圆通', ['bank_account_name' => '宿迁圆通有限公司', 'bank_account_number' => '6222020000123456789', 'private_account_name' => '王某', 'private_account_number' => '6228480000001111']); + $byCode = $make('韵达-新', ['workplatform_expcode' => 'YD02', 'private_account_name' => '李四', 'private_account_number' => '6200000000009999']); + $make('新快递'); + + $mirror = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class)->shouldIgnoreMissing(); + $mirror->shouldReceive('relatedAvailability')->andReturn([]); + $mirror->shouldReceive('overviewSource')->andReturn([ + 'express' => [ + (object) ['Id' => 1, 'expcode' => 'ZT01', 'expname' => '宿迁圆通', 'status' => '1', 'is_delete' => '0'], + (object) ['Id' => 2, 'expcode' => 'YD02', 'expname' => '常州韵达', 'status' => '1', 'is_delete' => '0'], + (object) ['Id' => 3, 'expcode' => 'JT03', 'expname' => '老极兔', 'status' => '0', 'is_delete' => '0'], + ], + 'accounts' => [ + 'ZT01' => [ + (object) ['account' => '6222 0200 0012 3456 789', 'accountName' => '宿迁圆通有限公司', 'erpAccountId' => 'E1'], + (object) ['account' => '6228480000001111', 'accountName' => '王某', 'erpAccountId' => 'E2'], + ], + 'YD02' => [(object) ['account' => '6200000000001234', 'accountName' => '张三', 'erpAccountId' => 'E3']], + ], + ]); + app()->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $mirror); + + $rows = collect(app(\App\Business\CourierOverviewData::class)->rows()['rows'])->keyBy('name'); + $this->assertSame('both', $rows['宿迁圆通']['source']); + $this->assertSame('match', $rows['宿迁圆通']['account_status']); + $this->assertSame('name', $rows['宿迁圆通']['linked_by']); + $this->assertSame($matched->id, $rows['宿迁圆通']['onboarding']->id); + $this->assertSame('mismatch', $rows['常州韵达']['account_status']); + $this->assertSame('code', $rows['常州韵达']['linked_by']); + $this->assertSame($byCode->id, $rows['常州韵达']['onboarding']->id); + $this->assertSame('platform', $rows['老极兔']['source']); + $this->assertSame('onboarding', $rows['新快递']['source']); + $this->assertSame('pending', $rows['新快递']['erp_status']); + $this->assertSame('opened', $rows['宿迁圆通']['erp_status']); + $this->assertSame('E1 / E2', $rows['宿迁圆通']['erp_account_id']); + + // 钉钉流程同步不到时,在入驻详情手动补录 ERP 开通信息。 + $this->loginAs() + ->post(route('business.courier-onboardings.erp-opening', CourierOnboarding::query()->where('courier_name', '新快递')->firstOrFail()), ['workplatform_expcode' => 'XKD', 'erp_account_id' => 'E9']) + ->assertSessionHasNoErrors(); + $fresh = CourierOnboarding::query()->where('courier_name', '新快递')->firstOrFail(); + $this->assertFalse($fresh->erpPending()); + $this->assertSame('manual', $fresh->erp_opened_source); + $this->loginAs()->get(route('business.courier-onboardings.show', $fresh))->assertOk()->assertSeeText('ERP 开通')->assertSeeText('手动补录'); + $fresh->update(['workplatform_expcode' => null, 'erp_opened_source' => null, 'erp_account_id' => null, 'erp_opened_at' => null]); + $this->assertNotEmpty($rows['新快递']['missing_files']); + + // 快递资料只分四类;对公/对私收款信息不在主表,挪到「打款信息」里完整显示。 + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'overview'])) + ->assertOk() + ->assertDontSeeText('快递总览') + ->assertDontSee('>对私账户', false) + ->assertSeeText('网点') + ->assertSeeText('打款信息') + ->assertSeeText('操作日志'); + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'accounts'])) + ->assertOk() + ->assertSeeText('6228480000001111') + ->assertSeeText('6222020000123456789') + ->assertSeeText('宿迁圆通有限公司') + ->assertSeeText('王某'); + + // 「快递资料」默认页:工作平台列在左,入驻信息接在右;还没进工作平台的入驻快递排在最前面。 + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information'])) + ->assertOk() + ->assertSeeText('入驻单号') + ->assertSeeText('ERP 开通') + ->assertSeeText('新快递') + ->assertSeeText('待 ERP 开通'); + // 合同起止取协议签署里已签署的合作合同。 + CourierAgreementSignature::query()->forceCreate([ + 'company_id' => $this->company->id, 'business_no' => 'XY-HT-1', 'idempotency_key' => uniqid('idem', true), + 'document_type' => 'cooperation-contract', 'template_code' => 'test', 'source_file_name' => 'contract.pdf', + 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'courier_name' => '新快递', 'company_full_name' => '新快递有限公司', + 'organization_name' => '新快递有限公司', 'payload' => ['cooperation_start_date' => '2026-11-01', 'cooperation_end_date' => '2027-10-31'], + 'environment' => 'sandbox', 'source_pdf_path' => 'x.pdf', 'source_pdf_sha256' => str_repeat('a', 64), + 'created_by' => $this->user->id, 'updated_by' => $this->user->id, + ]); + $append = app(\App\Business\CourierOverviewData::class)->expressAppend([ + (object) ['Id' => 1, 'expcode' => 'ZT01', 'expname' => '宿迁圆通'], + (object) ['Id' => 2, 'expcode' => 'YD02', 'expname' => '常州韵达'], + ], 1); + $this->assertSame($matched->id, $append['matches']['1']['onboarding']->id); + $this->assertSame($byCode->id, $append['matches']['2']['onboarding']->id); + $this->assertSame(['新快递'], collect($append['extra'])->map(fn ($info) => $info['onboarding']->courier_name)->all()); + $this->assertSame('2026-11-01', $append['extra'][0]['contract_start']); + $this->assertSame('2027-10-31', $append['extra'][0]['contract_end']); + + // 只在工作平台的老快递:发码补资料,生成入驻时带上快递代码。 + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding', 'courier_name' => '老极兔', 'expcode' => 'JT03'])) + ->assertOk() + ->assertSee('value="老极兔"', false) + ->assertSee('value="JT03"', false); + $this->loginAs()->post(route('business.courier-onboardings.store'), ['courier_name' => '老极兔', 'workplatform_expcode' => 'JT03']); + $this->assertSame('JT03', CourierOnboarding::query()->latest('id')->value('workplatform_expcode')); + } + + public function test_courier_profile_edit_and_payment_account_change_jump_to_agreement_signing(): void + { + $onboarding = CourierOnboarding::query()->forceCreate([ + 'company_id' => $this->company->id, 'token' => \Illuminate\Support\Str::random(40), 'number' => 'RZ-TEST-PA', + 'status' => CourierOnboarding::STATUS_ACTIVATED, 'entity_type' => 'company', 'courier_name' => '新快递九', + 'company_full_name' => '新快递九有限公司', 'workplatform_expcode' => 'XKD9', 'contact_name' => '张三', + 'esign_account' => '13800000009', 'private_account_name' => '李四', 'private_bank_name' => '农业银行', + 'private_account_number' => '6228480000009999', 'created_by' => $this->user->id, 'updated_by' => $this->user->id, + ]); + $mirror = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class)->shouldIgnoreMissing(); + $mirror->shouldReceive('relatedAvailability')->andReturn([]); + app()->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $mirror); + $page = fn (array $query = []) => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', ...$query]); + + // 点开主信息:下方看打款信息(完整账号)、网点、操作日志。 + $this->loginAs()->get($page(['selected' => 'XKD9'])) + ->assertOk() + ->assertSeeText('打款信息新增') + ->assertSeeText('打款信息变更') + ->assertSeeText('6228480000009999') + ->assertSee('is-selected', false); + $this->loginAs()->get($page(['selected' => 'XKD9', 'edit' => 1]))->assertOk()->assertSeeText('编辑快递资料'); + + // 编辑:存财务系统覆盖值,主表显示新值,并记操作日志。 + $this->loginAs()->post(route('business.courier-profiles.update', 'XKD9'), ['expname' => '新快递九', 'linker' => '赵六', 'status' => '1', 'kuaiyunsgn' => '0']) + ->assertSessionHasNoErrors() + ->assertRedirect(); + $this->assertSame(['linker' => '赵六'], \App\Models\Business\CourierProfile::query()->where('expcode', 'XKD9')->value('overrides')); + $this->loginAs()->get($page(['selected' => 'XKD9', 'detail_tab' => 'logs'])) + ->assertOk() + ->assertSeeText('赵六') + ->assertSee('is-overridden', false) + ->assertSeeText('联系人:张三 → 赵六'); + + // 打款信息新增:保存后跳到协议签署,预填收款账户证明。 + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-profiles.payment-accounts.store', 'XKD9'), ['form' => 'change', 'action' => 'change', 'account_type' => '对私', 'account_name' => '王五', 'bank_name' => '工行', 'account_number' => '6222000011112222', 'effective_date' => '2026-10-20']) + ->assertSessionHasErrors(['old_account_name', 'old_account_number']); + $response = $this->loginAs()->post(route('business.courier-profiles.payment-accounts.store', 'XKD9'), [ + 'form' => 'add', 'action' => 'add', 'account_type' => '对公', 'account_name' => '新快递九有限公司', + 'bank_name' => '工商银行宿迁分行', 'account_number' => '6222 0000 1111 2222', 'effective_date' => '2026-10-20', + ]); + $record = \App\Models\Business\CourierPaymentAccount::query()->latest('id')->firstOrFail(); + $response->assertRedirect(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $record->id])); + $this->assertSame('6222000011112222', $record->account_number); + $this->assertNotSame('6222000011112222', \Illuminate\Support\Facades\DB::table('business_courier_payment_accounts')->value('account_number')); + $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $record->id])) + ->assertOk() + ->assertSeeText('来自快递资料 · 打款信息新增:新快递九(XKD9)') + ->assertSee('"account_proof_bank_account":"6222000011112222"', false) + ->assertSee('"document_type":"account-proof"', false) + ->assertSee('value="新快递九"', false); + $this->loginAs()->get($page(['selected' => 'XKD9']))->assertOk()->assertSeeText('待推送协议')->assertSeeText('去推送'); + + // 协议签署完成后,这条打款信息显示「已生效」。 + CourierAgreementSignature::query()->forceCreate([ + 'company_id' => $this->company->id, 'business_no' => 'XY-ZM-1', 'idempotency_key' => uniqid('idem', true), + 'document_type' => 'account-proof', 'template_code' => 'test', 'source_file_name' => 'proof.pdf', + 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'courier_name' => '新快递九', 'company_full_name' => '新快递九有限公司', + 'organization_name' => '新快递九有限公司', 'payload' => ['account_proof_bank_account' => '6222000011112222'], + 'environment' => 'sandbox', 'source_pdf_path' => 'x.pdf', 'source_pdf_sha256' => str_repeat('a', 64), + 'created_by' => $this->user->id, 'updated_by' => $this->user->id, + ]); + $this->loginAs()->get($page(['selected' => 'XKD9']))->assertOk()->assertSeeText('已生效')->assertDontSeeText('待推送协议'); + $this->loginAs()->get($page(['courier_tab' => 'logs']))->assertOk()->assertSeeText('打款信息新增')->assertSeeText('编辑快递资料'); + + // 「打款信息」页签里也能直接新增 / 变更:快递公司代码在表单里填,表格每个账户有「变更」。 + $this->loginAs()->get($page(['courier_tab' => 'accounts'])) + ->assertOk() + ->assertSeeText('打款信息新增') + ->assertSeeText('打款信息变更') + ->assertSee('data-account-change-from data-code="XKD9"', false) + ->assertSee('name="expcode"', false); + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-profiles.payment-accounts.store-any'), ['form' => 'add', 'action' => 'add', 'expcode' => 'NOPE', 'account_type' => '对公', 'account_name' => 'x', 'bank_name' => 'y', 'account_number' => '1', 'effective_date' => '2026-10-20']) + ->assertSessionHasErrors('expcode'); + $this->loginAs()->post(route('business.courier-profiles.payment-accounts.store-any'), [ + 'form' => 'change', 'action' => 'change', 'expcode' => 'XKD9', 'account_type' => '对私', + 'old_account_name' => '李四', 'old_account_number' => '6228480000009999', 'old_stop_date' => '2026-10-19', 'change_date' => '2026-10-19', + 'account_name' => '王五', 'bank_name' => '建设银行', 'account_number' => '6217000000001234', 'effective_date' => '2026-10-20', + ])->assertRedirect(); + $changeRecord = \App\Models\Business\CourierPaymentAccount::query()->latest('id')->firstOrFail(); + $this->assertSame(['XKD9', 'change', '6228480000009999'], [$changeRecord->expcode, $changeRecord->action, $changeRecord->old_account_number]); + $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $changeRecord->id])) + ->assertOk() + ->assertSee('"document_type":"account-change"', false) + ->assertSee('"old_account_number":"6228480000009999"', false); + $this->assertSame($onboarding->id, app(\App\Business\CourierOverviewData::class)->infoFor('XKD9', null)['onboarding']->id); + } + + public function test_short_term_requote_runs_for_its_period_then_falls_back_to_long_term_quote(): void + { + $onboarding = $this->submittedOnboarding(); + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + $reviewer = $this->createReviewer(); + $this->fakeEsign(); + $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => 'ok']); + $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '宿迁圆通']); + $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.activate', $onboarding), ['offline_note' => '测试']); + $onboarding->refresh(); + $base = $onboarding->quoteVersion; + $base->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); + $base->update(['signature_status' => '已签署并归档', 'effective_date' => today()->subDays(11)]); + + $start = today()->addDay(); + $end = today()->addDays(4); + $quote = static fn (array $extra = []): array => [ + 'quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000', + 'quote_effective_date' => $start->toDateString(), 'quote_end_date' => $end->toDateString(), + 'quote_rows' => [['region' => '江苏', 'base' => '4', 'rates' => ['0.8']], ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']]], + 'summary' => '旺季临时涨价', + ...$extra, + ]; + $this->loginAs($this->user, $this->company)->get(route('business.courier-onboardings.requote', $onboarding)) + ->assertOk()->assertSeeText('结束日期(选填 · 短期变价)'); + $this->withExceptionHandling()->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote(['quote_end_date' => today()->toDateString()])) + ->assertSessionHasErrors('quote_end_date'); + $this->loginAs($this->user, $this->company) + ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote()) + ->assertSessionHasNoErrors(); + $change = CourierQuoteChange::query()->latest('id')->firstOrFail(); + $this->assertSame($end->toDateString(), $change->end_date->toDateString()); + $this->loginAs($reviewer, $this->company)->get(route('business.courier-quote-changes.show', $change)) + ->assertOk() + ->assertSeeText($start->toDateString() . ' 至 ' . $end->toDateString() . '(临时)') + ->assertSeeText('这是短期变价') + ->assertSeeText($end->copy()->addDay()->toDateString() . ' 起自动恢复 ' . $base->version_code); + + $this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasNoErrors(); + $temporary = $change->fresh()->version; + $this->assertSame($end->toDateString(), $temporary->end_date->toDateString()); + $confirmation = app(\App\Support\CourierQuoteConfirmation::class)->data($temporary); + $this->assertSame($end->toDateString(), $confirmation['expiry_date']); + $this->assertStringContainsString('自动恢复原报价 ' . $base->version_code, $confirmation['temporary_note']); + $this->assertSame('长期', app(\App\Support\CourierQuoteConfirmation::class)->data($base)['expiry_date']); + $temporary->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); + $temporary->update(['signature_status' => '已签署并归档']); + + // 按日期取价:临时期间用临时价,结束后第二天自动回到原报价。 + $requotes = app(\App\Support\CourierRequoteService::class); + $this->assertSame($base->id, $requotes->effectiveVersion('宿迁圆通')->id); + $this->assertSame($temporary->id, $requotes->effectiveVersion('宿迁圆通', $start)->id); + $this->assertSame($temporary->id, $requotes->effectiveVersion('宿迁圆通', $end)->id); + $this->assertSame($base->id, $requotes->effectiveVersion('宿迁圆通', $end->copy()->addDay())->id); + + // 下一次变价的底稿是长期报价,不是临时价。 + $this->loginAs($this->user, $this->company)->get(route('business.courier-onboardings.requote', $onboarding)) + ->assertOk()->assertSee('"base":3.2', false); + + $this->travelTo($start->copy()->addDay()->setTime(10, 0)); + $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); + $this->assertSame('生效中(临时)', $quotes[$temporary->version_code]['status']); + $this->assertSame($start->toDateString() . ' 至 ' . $end->toDateString() . '(临时)', $quotes[$temporary->version_code]['period_label']); + $this->assertSame('暂停中', $quotes[$base->version_code]['status']); + $this->assertStringContainsString($end->copy()->addDay()->toDateString() . ' 起恢复', $quotes[$base->version_code]['status_note']); + + $this->travelTo($end->copy()->addDay()->setTime(10, 0)); + $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); + $this->assertSame('已到期', $quotes[$temporary->version_code]['status']); + $this->assertSame('生效中', $quotes[$base->version_code]['status']); + $this->travelBack(); + } + + private function invite(): CourierOnboarding + { + $this->loginAs()->post(route('business.courier-onboardings.store'), [ + 'courier_name' => '新北中通-' . uniqid(), 'workplatform_expcode' => 'XB' . uniqid(), 'invite_label' => '张经理', 'valid_days' => 30, + ]); + + return CourierOnboarding::query()->latest('id')->firstOrFail(); + } + + private function submittedOnboarding(): CourierOnboarding + { + $onboarding = $this->invite(); + $this->asGuest(); + $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $this->completePayload()) + ->assertSessionHasNoErrors(); + + return $onboarding->fresh(); + } + + private function asGuest(): void + { + auth()->logout(); + Company::forgetCurrent(); + } + + /** + * @return array + */ + private function completePayload(bool $withFiles = true): array + { + $payload = [ + 'action' => 'submit', + 'company_full_name' => '常州新北快递服务有限公司', + 'unified_social_credit_code' => '91320411ma1xxxxx0y', + 'legal_representative' => '王某', + 'carrier_brand' => '中通', + 'network_name' => '常州新北网点', + 'service_type' => '快递', + 'contact_name' => '张经理', + 'contact_phone' => '13800001111', + 'esign_operator_name' => '张经理', + 'esign_account' => '13800001111', + 'bank_account_name' => '常州新北快递服务有限公司', + 'bank_name' => '中国工商银行', + 'bank_account_number' => '6222 0200 0012 3456 789', + 'private_account_name' => '王某', + 'private_bank_name' => '中国农业银行', + 'private_account_number' => '6228 4800 0000 1111', + 'invoice_type' => '增值税专用发票', + 'tax_rate' => '6', + 'settlement_cycle' => '月结', + 'quote_mode' => 'waybill_rate', + 'volumetric_rule' => '计泡', + 'volumetric_divisor' => '6000', + 'quote_effective_date' => now()->addDay()->toDateString(), + 'quote_rows' => [ + ['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']], + ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']], + ['region' => '上海', 'base' => '', 'rates' => ['']], + ], + ]; + + if ($withFiles) { + $payload['files'] = [ + 'business_license' => [UploadedFile::fake()->create('license.pdf', 120, 'application/pdf')], + 'legal_id_card_front' => [UploadedFile::fake()->image('id-front.jpg', 600, 400)], + 'legal_id_card_back' => [UploadedFile::fake()->image('id-back.jpg', 600, 400)], + 'bank_card_front' => [UploadedFile::fake()->image('card-front.jpg', 600, 400)], + 'bank_card_back' => [UploadedFile::fake()->image('card-back.jpg', 600, 400)], + ]; + } + + return $payload; + } + + private function createReviewer(string $role = 'admin') + { + $reviewer = user_model_class()::create([ + 'name' => 'Onboarding Reviewer', + 'email' => 'onboarding-reviewer-' . uniqid('', true) . '@example.com', + 'password' => 'password', + 'locale' => 'zh-CN', + 'enabled' => 1, + 'landing_page' => 'dashboard', + 'created_from' => 'core::test', + ]); + $reviewer->companies()->attach($this->company->id); + $reviewer->roles()->attach(Role::where('name', $role)->firstOrFail()->id); + $reviewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + return $reviewer; + } + + private function fakeEsign(): void + { + config([ + 'services.esign.environment' => 'sandbox', + 'services.esign.base_url' => 'https://smlopenapi.esign.test', + 'services.esign.app_id' => 'sandbox-app-id', + 'services.esign.app_secret' => 'sandbox-app-secret', + 'services.esign.notify_url' => null, + ]); + $flows = 0; + Http::fake(static function (ClientRequest $request) use (&$flows) { + return match (true) { + $request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([ + 'code' => 0, + 'data' => ['fileId' => 'file-onboard', 'fileUploadUrl' => 'https://upload.esign.test/file-onboard'], + ]), + $request->url() === 'https://upload.esign.test/file-onboard' => Http::response(['errCode' => 0]), + $request->url() === 'https://smlopenapi.esign.test/v3/files/file-onboard' => Http::response(['code' => 0, 'data' => ['status' => 2]]), + $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response(['code' => 0, 'data' => ['signFlowId' => ++$flows === 1 ? 'flow-onboard' : 'flow-onboard-' . $flows]]), + str_ends_with($request->url(), '/detail') => Http::response(['code' => 0, 'data' => ['signFlowStatus' => '1']]), + str_ends_with($request->url(), '/revoke') => Http::response(['code' => 0, 'data' => []]), + $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/flow-onboard/sign-url' => Http::response(['code' => 0, 'data' => ['url' => 'https://esign.cn/long', 'shortUrl' => 'https://t.esign.cn/abc']]), + default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404), + }; + }); + } + + private function createSignedAgreement(string $courierName): void + { + CourierAgreementSignature::query()->forceCreate([ + 'company_id' => $this->company->id, + 'business_no' => 'XY-TEST-' . uniqid(), + 'idempotency_key' => uniqid('idem', true), + 'document_type' => 'cooperation-contract', + 'template_code' => 'test', + 'source_file_name' => 'contract.pdf', + 'status' => CourierAgreementSignature::STATUS_ARCHIVED, + 'courier_name' => $courierName, + 'company_full_name' => '常州新北快递服务有限公司', + 'organization_name' => '常州新北快递服务有限公司', + 'payload' => [], + 'source_pdf_path' => 'test/contract.pdf', + 'source_pdf_sha256' => str_repeat('0', 64), + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ]); + } +} diff --git a/tests/Feature/Business/QuoteManagementPageTest.php b/tests/Feature/Business/QuoteManagementPageTest.php index 4e1e1239..41f1b304 100644 --- a/tests/Feature/Business/QuoteManagementPageTest.php +++ b/tests/Feature/Business/QuoteManagementPageTest.php @@ -168,6 +168,7 @@ class QuoteManagementPageTest extends FeatureTestCase $quoteDirectory = collect($courierPayables['pages'])->firstWhere('slug', 'courier-quote'); $this->assertSame([ 'quote-management', + 'courier-onboarding', 'courier-information', 'agreement-signing', 'courier-surcharge',