diff --git a/.dockerignore b/.dockerignore index 776b9a12..b0370ad0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,3 +29,6 @@ docs scripts npm-debug.log yarn-error.log +public/net-layer-demo.html +public/perf-diff-plan.html +public/plan-a-changes-202608.csv diff --git a/.gitignore b/.gitignore index 77f148a7..35ebdef7 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,6 @@ modules/* .workbuddy/ runtime/.env /.private/kaifa-progress-sync/ +public/net-layer-demo.html +public/perf-diff-plan.html +public/plan-a-changes-202608.csv 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/Business/StoreViolationAlertCalculator.php b/app/Business/StoreViolationAlertCalculator.php new file mode 100644 index 00000000..f875be7e --- /dev/null +++ b/app/Business/StoreViolationAlertCalculator.php @@ -0,0 +1,273 @@ +>|null */ + private ?array $rules = null; + + /** @var array|null */ + private ?array $ownerMap = null; + + /** @var array */ + private array $ownerFallback = []; + + public function __construct( + private readonly StoreViolationRuleRepository $ruleRepository, + private readonly StoreAccountMirrorRepository $storeAccounts, + ) { + } + + public function flush(): void + { + $this->rules = null; + $this->ownerMap = null; + $this->ownerFallback = []; + } + + /** @return array 预警相关字段(用于同步/导入写入) */ + public function calculate( + string $platform, + ?string $shopName, + ?string $violationType, + ?string $violationDate, + ?string $appealDeadline, + ): array { + $rule = self::matchRule($this->rules(), $platform, $violationType); + $needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true); + $level = null; + if ($needsAlert) { + $level = trim((string) ($rule['alert_level'] ?? '')); + if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) { + $level = 'P2'; + } + } + + $hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null + ? (int) $rule['handling_hours'] + : null; + + return [ + 'needs_alert' => $needsAlert, + 'alert_level' => $level, + 'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null, + 'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null, + 'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null, + 'handling_hours' => $needsAlert ? $hours : null, + 'owner_name' => $this->ownerFor($shopName), + 'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level), + ]; + } + + /** @return list> */ + private function rules(): array + { + if ($this->rules === null) { + try { + $this->rules = $this->ruleRepository->activeRules(); + } catch (Throwable) { + $this->rules = []; + } + } + + return $this->rules; + } + + /** + * 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。 + */ + public static function cleanViolationType(?string $text): string + { + $text = trim((string) $text); + if ($text === '') { + return ''; + } + + $previous = null; + while ($previous !== $text) { + $previous = $text; + $text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text; + } + + return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text; + } + + private static function loose(string $text): string + { + return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text; + } + + /** + * 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。 + * + * @param list> $rules 已按优先级排序 + * @return array|null + */ + public static function matchRule(array $rules, string $platform, ?string $violationType): ?array + { + $record = self::cleanViolationType($violationType); + if ($record === '') { + return null; + } + + $looseRecord = self::loose($record); + + foreach ($rules as $rule) { + if (trim((string) ($rule['platform'] ?? '')) !== $platform) { + continue; + } + + $ruleType = self::cleanViolationType($rule['violation_type'] ?? null); + if ($ruleType !== '') { + if ($record === $ruleType || str_starts_with($record, $ruleType)) { + return $rule; + } + foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) { + $part = trim((string) $part); + if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) { + return $rule; + } + } + $looseRule = self::loose($ruleType); + if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) { + return $rule; + } + } + + foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) { + $keyword = self::loose(self::cleanViolationType($keyword)); + if ($keyword !== '' && str_contains($looseRecord, $keyword)) { + return $rule; + } + } + } + + return null; + } + + /** + * 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。 + */ + public static function deadlineFor( + ?string $violationDate, + ?string $appealDeadline, + ?int $handlingHours, + ?string $alertLevel, + ): ?string { + $appeal = trim((string) $appealDeadline); + if ($appeal !== '') { + try { + return Carbon::parse($appeal)->endOfDay()->toDateTimeString(); + } catch (Throwable) { + // 解析失败时退回按级别计算 + } + } + + $date = trim((string) $violationDate); + if ($date === '') { + return null; + } + + try { + $base = Carbon::parse($date)->startOfDay(); + } catch (Throwable) { + return null; + } + + if ($handlingHours !== null && $handlingHours > 0) { + return $base->addHours($handlingHours)->toDateTimeString(); + } + + $level = trim((string) $alertLevel); + if ($level === '') { + return null; + } + + $days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0); + + return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null; + } + + /** 是否超时:处理状态不在终态且当前时间已过处理截止。 */ + public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool + { + $status = trim((string) $handlingStatus); + if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) { + return false; + } + + $deadline = trim((string) $handlingDeadline); + if ($deadline === '') { + return false; + } + + try { + return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline)); + } catch (Throwable) { + return false; + } + } + + private function ownerFor(?string $shopName): ?string + { + $name = trim((string) $shopName); + if ($name === '') { + return null; + } + + try { + if ($this->ownerMap === null) { + $this->ownerMap = $this->storeAccounts->ownershipByStoreName(); + } + $owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? '')); + if ($owner !== '') { + return mb_substr($owner, 0, 64); + } + } catch (Throwable) { + $this->ownerMap ??= []; + } + + // 兜底:店铺档案(经营分析副本库)的店铺运营负责人。 + if (array_key_exists($name, $this->ownerFallback)) { + return $this->ownerFallback[$name]; + } + + $head = null; + try { + $row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('ops_fys_shop_info') + ->where('shopName', $name) + ->orderByDesc('id') + ->first(['shopHead']); + $head = self::nullableText($row->shopHead ?? null, 64); + } catch (Throwable) { + $head = null; + } + + return $this->ownerFallback[$name] = $head; + } + + private static function nullableText(mixed $value, int $limit): ?string + { + $text = trim((string) $value); + + return $text === '' ? null : mb_substr($text, 0, $limit); + } +} diff --git a/app/Business/StoreViolationMapper.php b/app/Business/StoreViolationMapper.php index bbd41379..8f56da6e 100644 --- a/app/Business/StoreViolationMapper.php +++ b/app/Business/StoreViolationMapper.php @@ -72,6 +72,9 @@ class StoreViolationMapper 'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191), 'violation_type' => $content === '' ? null : mb_substr($content, 0, 500), 'violation_category' => $this->categoryForContent($content), + // 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。 + 'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500), + 'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500), 'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100), 'violation_date' => $this->normalizeDate($row['violation_date'] ?? null), 'status' => $this->normalizeLabel($row['status'] ?? null, 64), diff --git a/app/Business/StoreViolationNotifier.php b/app/Business/StoreViolationNotifier.php new file mode 100644 index 00000000..add81683 --- /dev/null +++ b/app/Business/StoreViolationNotifier.php @@ -0,0 +1,237 @@ +pushes->instantCandidates($levels, $retryFailed, $limit); + $summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0]; + $ownerContacts = (array) config('store_violations.push.owner_contacts', []); + + foreach ($candidates as $record) { + $owner = trim((string) ($record->owner_name ?? '')); + $atMobiles = []; + if ($owner !== '' && ! empty($ownerContacts[$owner])) { + $atMobiles[] = (string) $ownerContacts[$owner]; + } + + [$title, $text] = $this->instantMessage($record); + $pushNo = $this->pushNo(); + $now = Carbon::now(); + + try { + $this->send($title, $text, $atMobiles); + $this->pushes->markPushed([(int) $record->id], $now); + $summary['succeeded']++; + $this->pushes->log([ + 'push_no' => $pushNo, + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'instant', + 'platform' => (string) ($record->platform ?? ''), + 'violation_no' => (string) ($record->violation_no ?? ''), + 'message_digest' => mb_substr($title, 0, 500), + 'mentioned' => $owner, + 'result' => 'success', + 'error' => null, + ]); + } catch (Throwable $exception) { + $summary['failed']++; + $this->pushes->markPushFailed((int) $record->id, $now); + $this->pushes->log([ + 'push_no' => $pushNo, + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'instant', + 'platform' => (string) ($record->platform ?? ''), + 'violation_no' => (string) ($record->violation_no ?? ''), + 'message_digest' => mb_substr($title, 0, 500), + 'mentioned' => $owner, + 'result' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 500), + ]); + } + } + + return $summary; + } + + /** + * @return array{pending:array,overdue:int,p2:int,sent:bool} + */ + public function pushDigest(): array + { + $data = $this->pushes->digestData(); + $pending = $data['pending']; + $overdue = $data['overdue']; + $p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + + $lines = [ + '### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')', + '- 待处理:P0 ' . (int) ($pending['P0'] ?? 0) + . ' 条 / P1 ' . (int) ($pending['P1'] ?? 0) + . ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条', + '- 已超时未认领:' . $overdue->count() . ' 条', + ]; + foreach ($overdue as $index => $record) { + $lines[] = sprintf( + ' %d. [%s] %s(责任人:%s) · 截止 %s', + $index + 1, + (string) ($record->alert_level ?? ''), + (string) ($record->shop_name ?? ''), + trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配', + (string) ($record->handling_deadline ?? ''), + ); + } + + $now = Carbon::now(); + try { + $this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []); + } catch (Throwable $exception) { + $this->pushes->log([ + 'push_no' => $this->pushNo(), + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'daily', + 'platform' => null, + 'violation_no' => null, + 'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500), + 'mentioned' => null, + 'result' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 500), + ]); + + return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false]; + } + + // 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。 + $this->pushes->markPushed($p2Ids, $now); + $this->pushes->log([ + 'push_no' => $this->pushNo(), + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'daily', + 'platform' => null, + 'violation_no' => null, + 'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500), + 'mentioned' => null, + 'result' => 'success', + 'error' => null, + ]); + + return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true]; + } + + /** @return array{0:string,1:string} */ + private function instantMessage(object $record): array + { + $level = (string) ($record->alert_level ?? ''); + $levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level); + $platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? '')); + $title = '店铺违规预警(' . $levelLabel . ')'; + $lines = [ + '### 【' . $levelLabel . '】店铺违规预警', + '- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''), + '- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200), + '- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'), + '- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'), + '- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'), + '- 处理截止:' . (string) ($record->handling_deadline ?? '—'), + ]; + + $claimUrl = $this->claimUrl((int) $record->id); + if ($claimUrl !== null) { + $lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)'; + } + + return [$title, implode("\n", $lines)]; + } + + private function claimUrl(int $recordId): ?string + { + try { + $companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1); + + return URL::signedRoute('business.store-violation-alert.claim', [ + 'record' => $recordId, + 'company_id' => $companyId, + ]); + } catch (Throwable) { + return null; + } + } + + /** @param list $atMobiles */ + private function send(string $title, string $text, array $atMobiles): void + { + $config = (array) config('store_violations.push', []); + $webhook = trim((string) ($config['webhook'] ?? '')); + if ($webhook === '') { + throw new RuntimeException('推送 Webhook 未配置。'); + } + + $url = $webhook; + $secret = trim((string) ($config['secret'] ?? '')); + if ($secret !== '') { + $timestamp = (string) (int) (microtime(true) * 1000); + $sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true))); + $url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign; + } + + $response = Http::timeout(10)->post($url, [ + 'msgtype' => 'markdown', + 'markdown' => ['title' => $title, 'text' => $text], + 'at' => [ + 'atMobiles' => array_values($atMobiles), + 'isAtAll' => (bool) ($config['at_all'] ?? false), + ], + ]); + + if (! $response->successful()) { + throw new RuntimeException('推送接口返回 HTTP ' . $response->status()); + } + + $body = (array) $response->json(); + if ((int) ($body['errcode'] ?? 0) !== 0) { + throw new RuntimeException('推送接口错误:' . (string) ($body['errcode'] ?? '?') . ' ' . (string) ($body['errmsg'] ?? '')); + } + } + + private function pushNo(): string + { + return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6)); + } +} diff --git a/app/Business/StoreViolationWorkbookImporter.php b/app/Business/StoreViolationWorkbookImporter.php index 43750e12..00391749 100644 --- a/app/Business/StoreViolationWorkbookImporter.php +++ b/app/Business/StoreViolationWorkbookImporter.php @@ -19,6 +19,7 @@ class StoreViolationWorkbookImporter public function __construct( private readonly StoreViolationMapper $mapper, private readonly StoreViolationRecordRepository $repository, + private readonly StoreViolationAlertCalculator $alerts, ) { } @@ -139,6 +140,11 @@ class StoreViolationWorkbookImporter } $updateColumns[] = 'source'; $updateColumns[] = 'updated_at'; + // 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。 + $updateColumns = array_values(array_unique(array_merge( + $updateColumns, + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ))); $payload = []; $created = 0; @@ -155,6 +161,17 @@ class StoreViolationWorkbookImporter continue; } + $matchType = trim((string) ($data['violation_type'] ?? '')) !== '' + ? (string) $data['violation_type'] + : ($data['violation_reason'] ?? null); + $data = array_merge($data, $this->alerts->calculate( + $platform, + (string) ($data['shop_name'] ?? ''), + $matchType, + $data['violation_date'] ?? null, + $data['appeal_deadline'] ?? null, + )); + foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) { if (! array_key_exists($column, $data)) { $data[$column] = null; @@ -305,13 +322,25 @@ class StoreViolationWorkbookImporter $data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500); } + $reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null; + if ($has('violation_reason')) { + $data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500); + } + + if ($has('disposition')) { + $disposition = trim((string) ($raw['disposition'] ?? '')); + $data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500); + } + if ($has('violation_category')) { + $categorySource = ($type !== null && $type !== '') ? $type : (string) $reason; $category = trim((string) ($raw['violation_category'] ?? '')); $data['violation_category'] = $category === '' - ? $this->mapper->categoryForContent($type) + ? $this->mapper->categoryForContent($categorySource) : mb_substr($category, 0, 64); - } elseif ($has('violation_type')) { - $data['violation_category'] = $this->mapper->categoryForContent($type); + } elseif ($has('violation_type') || $has('violation_reason')) { + $categorySource = ($type !== null && $type !== '') ? $type : (string) $reason; + $data['violation_category'] = $this->mapper->categoryForContent($categorySource); } if ($has('violation_date')) { @@ -357,6 +386,8 @@ class StoreViolationWorkbookImporter if ($has('handling_status')) { $handling = trim((string) ($raw['handling_status'] ?? '')); + $legacyMap = (array) config('store_violations.handling_status_legacy', []); + $handling = (string) ($legacyMap[$handling] ?? $handling); $options = (array) config('store_violations.handling_statuses', []); if ($handling === '') { $data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理'); @@ -368,6 +399,15 @@ class StoreViolationWorkbookImporter } } + if ($has('ignore_reason')) { + $ignoreReason = trim((string) ($raw['ignore_reason'] ?? '')); + $data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191); + } + + if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') { + $errors[] = '处理状态为“已忽略”时必须填写忽略原因'; + } + return [ 'row_number' => $rowNumber, 'status' => $errors === [] ? 'valid' : 'invalid', diff --git a/app/Console/Commands/ImportStoreViolationRules.php b/app/Console/Commands/ImportStoreViolationRules.php new file mode 100644 index 00000000..bd5d234d --- /dev/null +++ b/app/Console/Commands/ImportStoreViolationRules.php @@ -0,0 +1,187 @@ + ['平台', 'platform'], + 'violation_category' => ['违规分类', '分类', 'violation_category'], + 'violation_type' => ['违规类型', '类型', 'violation_type'], + 'match_keywords' => ['匹配关键词', '关键词', 'match_keywords'], + 'alert_level' => ['预警级别', '级别', '预警等级', 'alert_level'], + 'needs_alert' => ['是否需要提醒', '需提醒', '需要提醒', 'needs_alert'], + 'suggested_action' => ['建议处理动作', '处理建议', '建议动作', 'suggested_action'], + 'handling_hours' => ['处理时效', '时效', 'handling_hours'], + 'remind_channel' => ['提醒方式', 'remind_channel'], + 'sort' => ['排序', 'sort'], + 'enabled' => ['启用', 'enabled'], + 'remark' => ['备注', 'remark'], + ]; + + private const PLATFORM_MAP = [ + '淘系' => 'taobao', '淘宝' => 'taobao', '天猫' => 'taobao', + '京东' => 'jd', '京喜' => 'jingxi', '抖音' => 'douyin', + '拼多多' => 'pinduoduo', '阿里巴巴' => 'alibaba', + ]; + + public function handle(): int + { + $file = (string) $this->argument('file'); + if (! is_file($file)) { + $this->error('文件不存在:' . $file); + + return self::FAILURE; + } + + try { + $reader = IOFactory::createReaderForFile($file); + if (method_exists($reader, 'setReadDataOnly')) { + $reader->setReadDataOnly(true); + } + if (method_exists($reader, 'setReadFilter')) { + // 字典文件通常只有几十行;部分导出文件带巨大空白维度,限制读取范围。 + $reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter { + public function readCell($columnAddress, $row, $worksheetName = ''): bool + { + return $row <= 5000; + } + }); + } + $matrix = $reader->load($file)->getActiveSheet()->toArray(null, true, true, false); + } catch (Throwable $exception) { + $this->error('无法读取文件:' . $exception->getMessage()); + + return self::FAILURE; + } + + if ($matrix === []) { + $this->error('文件为空。'); + + return self::FAILURE; + } + + $map = []; + foreach ($matrix[0] as $index => $cell) { + $label = mb_strtolower(trim((string) $cell)); + foreach (self::HEADERS as $key => $aliases) { + foreach ($aliases as $alias) { + if ($label === mb_strtolower($alias)) { + $map[$key] = $index; + break 2; + } + } + } + } + + if (! isset($map['platform'], $map['violation_type'])) { + $this->error('缺少必需表头:平台、违规类型。'); + + return self::FAILURE; + } + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + if (! app(StoreViolationRuleRepository::class)->available()) { + $this->error('违规字典表尚未建立,请先执行数据库迁移。'); + + return self::FAILURE; + } + + $created = 0; + $updated = 0; + $skipped = 0; + $sort = 0; + + foreach (array_slice($matrix, 1) as $row) { + $value = static fn (string $key): string => isset($map[$key]) ? trim((string) ($row[$map[$key]] ?? '')) : ''; + + $platform = $value('platform'); + $platform = self::PLATFORM_MAP[$platform] ?? mb_strtolower($platform); + $type = $value('violation_type'); + if ($platform === '' || $type === '') { + $skipped++; + continue; + } + + $sort += 10; + + $levelRaw = strtoupper($value('alert_level')); + $level = in_array($levelRaw, ['P0', 'P1', 'P2'], true) ? $levelRaw : null; + + $needsAlertRaw = mb_strtolower($value('needs_alert')); + $needsAlert = ! in_array($needsAlertRaw, ['否', 'no', '0', 'false'], true); + + $hoursRaw = $value('handling_hours'); + $hours = is_numeric($hoursRaw) ? max((int) $hoursRaw, 0) : null; + + $payload = [ + 'platform' => $platform, + 'violation_category' => $value('violation_category') !== '' ? mb_substr($value('violation_category'), 0, 64) : null, + 'violation_type' => mb_substr($type, 0, 500), + 'match_keywords' => $value('match_keywords') !== '' ? mb_substr($value('match_keywords'), 0, 500) : null, + 'alert_level' => $needsAlert ? $level : null, + 'needs_alert' => $needsAlert ? 1 : 0, + 'suggested_action' => $value('suggested_action') !== '' ? mb_substr($value('suggested_action'), 0, 500) : null, + 'handling_hours' => $hours, + 'remind_channel' => $value('remind_channel') !== '' ? mb_substr($value('remind_channel'), 0, 191) : null, + 'sort' => is_numeric($value('sort')) ? (int) $value('sort') : $sort, + 'enabled' => ! in_array(mb_strtolower($value('enabled')), ['否', 'no', '0', 'false'], true) ? 1 : 0, + 'remark' => $value('remark') !== '' ? mb_substr($value('remark'), 0, 191) : null, + 'updated_at' => now(), + ]; + + $exists = $connection->table(StoreViolationRuleRepository::TABLE) + ->where('platform', $platform) + ->where('violation_type', $payload['violation_type']) + ->exists(); + + if ($this->option('dry-run')) { + $exists ? $updated++ : $created++; + continue; + } + + if ($exists) { + $connection->table(StoreViolationRuleRepository::TABLE) + ->where('platform', $platform) + ->where('violation_type', $payload['violation_type']) + ->update($payload); + $updated++; + } else { + $connection->table(StoreViolationRuleRepository::TABLE)->insert($payload + [ + 'created_at' => now(), + ]); + $created++; + } + } + + $this->info(sprintf( + '字典导入%s:新增 %d 条,更新 %d 条,跳过 %d 条。', + $this->option('dry-run') ? '(试运行,未写库)' : '完成', + $created, + $updated, + $skipped, + )); + $this->line('提示:需要让存量记录套用新字典时,执行 store-violations:recalc-alerts。'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PushStoreViolationAlerts.php b/app/Console/Commands/PushStoreViolationAlerts.php new file mode 100644 index 00000000..b0036b9c --- /dev/null +++ b/app/Console/Commands/PushStoreViolationAlerts.php @@ -0,0 +1,41 @@ +enabled()) { + $this->line('推送未配置,跳过。'); + + return self::SUCCESS; + } + + $summary = $notifier->pushInstant( + max((int) $this->option('limit'), 1), + (bool) $this->option('retry-failed'), + ); + + $this->info(sprintf( + '即时推送:待推 %d 条,成功 %d 条,失败 %d 条。', + $summary['attempted'], + $summary['succeeded'], + $summary['failed'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PushStoreViolationDigest.php b/app/Console/Commands/PushStoreViolationDigest.php new file mode 100644 index 00000000..aee87d08 --- /dev/null +++ b/app/Console/Commands/PushStoreViolationDigest.php @@ -0,0 +1,38 @@ +enabled()) { + $this->line('推送未配置,跳过。'); + + return self::SUCCESS; + } + + $summary = $notifier->pushDigest(); + + $this->info(sprintf( + '日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。', + $summary['sent'] ? '已发送' : '发送失败', + (int) ($summary['pending']['P0'] ?? 0), + (int) ($summary['pending']['P1'] ?? 0), + (int) ($summary['pending']['P2'] ?? 0), + (int) $summary['overdue'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalcStoreViolationAlerts.php b/app/Console/Commands/RecalcStoreViolationAlerts.php new file mode 100644 index 00000000..2e381924 --- /dev/null +++ b/app/Console/Commands/RecalcStoreViolationAlerts.php @@ -0,0 +1,79 @@ +available()) { + $this->error('统一表 store_violation_records 尚未建立。'); + + return self::FAILURE; + } + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $chunk = max(min((int) $this->option('chunk'), 2000), 100); + $platform = trim((string) $this->option('platform')); + $total = 0; + + $query = $connection->table(StoreViolationRecordRepository::TABLE) + ->select(['id', 'platform', 'violation_no', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline']) + ->orderBy('id'); + if ($platform !== '') { + $query->where('platform', $platform); + } + + $query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void { + $updates = []; + foreach ($rows as $row) { + $matchType = trim((string) ($row->violation_type ?? '')) !== '' + ? (string) $row->violation_type + : ($row->violation_reason ?? null); + + // upsert 的插入侧会校验非空列,带上 platform / violation_no 以通过严格模式; + // 命中已有记录时只会更新派生字段(update 列表)。 + $updates[] = [ + 'id' => $row->id, + 'platform' => (string) $row->platform, + 'violation_no' => (string) ($row->violation_no ?? ''), + ] + $calculator->calculate( + (string) $row->platform, + $row->shop_name, + $matchType, + $row->violation_date ? (string) $row->violation_date : null, + $row->appeal_deadline ? (string) $row->appeal_deadline : null, + ); + } + + if ($updates !== []) { + $connection->table(StoreViolationRecordRepository::TABLE)->upsert( + $updates, + ['id'], + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ); + $total += count($updates); + } + }); + + $this->info(sprintf('预警重算完成:%d 行。', $total)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 004f775f..0439bdb0 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -162,6 +162,17 @@ class SyncStoreReceiptOrderMatches extends Command /** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */ private const REMARK_PROMO_ONLY = '无收款账单'; + /** + * 1688 采销专用账户(按账单店铺名识别): + * 这个账户收的是采销/供货结算款,不是店铺销售单收款,订单侧本来就没有对应的销售单, + * 所以未匹配原因单独成档,避免和真实丢单混在「订单表无此单」里。 + */ + private const CAIXIAO_STORE_KEYWORD = '采销专用'; + + private const REMARK_EMPTY_NO_CAIXIAO = '账单无单号(采销收款)'; + + private const REMARK_NO_ORDER_CAIXIAO = '1688采销收款(非销售单)'; + public function handle(): int { // 全量重建时账单与订单侧映射都在内存里:拼多多 / 淘系支付宝这类大平台需要 1G 以上, @@ -536,6 +547,8 @@ class SyncStoreReceiptOrderMatches extends Command // 子单的平台子订单号与主单不同时,按「子订单号 + 条码」的去重键拦不住,会把同一批 // 商品重复累加。整单没有任何主单行时退化为取全部行,避免把真实金额抹掉——与实付 // 「主单优先,无主单退化」保持同一套规则。 + // 合并发货行(merge_so_id 非空)会把组内其它订单的金额一起挂在本单号上: + // 当单号下存在「非合并行」时,商品与实付都只认非合并行,避免把合并组合计当成单笔金额。 $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_main_nos'); $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_main_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB'); $mirror->statement( @@ -545,6 +558,15 @@ class SyncStoreReceiptOrderMatches extends Command STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no WHERE o.link_o_id IS NULL OR o.link_o_id = 0" ); + $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_plain_nos'); + $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_plain_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB'); + $mirror->statement( + "INSERT IGNORE INTO tmp_receipt_plain_nos (no) + SELECT DISTINCT o.so_id as no + FROM tmp_receipt_order_nos t + STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no + WHERE COALESCE(o.merge_so_id, '') = ''" + ); // 同一线上订单号在聚水潭可能对应多个 o_id(拆单/子单),且子单会重复包含 // 同一批子订单商品行;先按「子订单号 + 条码」去重再求和,避免重复计算。 @@ -561,22 +583,26 @@ class SyncStoreReceiptOrderMatches extends Command STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no STRAIGHT_JOIN {$itemsTable} i ON i.o_id = o.o_id LEFT JOIN tmp_receipt_main_nos m ON m.no = o.so_id + LEFT JOIN tmp_receipt_plain_nos p ON p.no = o.so_id WHERE (o.link_o_id IS NULL OR o.link_o_id = 0) - OR m.no IS NULL + OR (m.no IS NULL AND (COALESCE(o.merge_so_id, '') = '' OR p.no IS NULL)) GROUP BY o.so_id, o.shop_name, dedup_key ) d GROUP BY so_id, shop_name" ); // 订单实付:取订单级字段(明细行上的实付会重复);拆单取主单(link_o_id 为空), - // 没有主单时退化为取该单号下的最大值。 + // 其次取「非合并行」(merge_so_id 为空——合并发货行带的是组内合计金额,不能算单笔), + // 都没有时退化为取该单号下的最大值。 $paid = $mirror->select( "SELECT so_id, shop_name, COALESCE( MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid END), + MAX(CASE WHEN COALESCE(merge_so_id, '') = '' THEN paid END), MAX(paid) ) as paid FROM ( - SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, o.{$paidColumn} as paid + SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, + COALESCE(o.merge_so_id, '') as merge_so_id, o.{$paidColumn} as paid FROM tmp_receipt_order_nos t STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no ) x @@ -691,6 +717,8 @@ class SyncStoreReceiptOrderMatches extends Command $status = $basis === 'product' ? $productStatus : $paidStatus; $remark = $basis === 'product' ? $productRemark : $paidRemark; + // 1688 采销专用账户的未匹配原因单独成档(采销收款不是店铺销售单)。 + $remark = self::caixiaoRemark((string) $group['store_name'], $remark); $basisAmount = $basis === 'product' ? $productAmount : $paidAmount; if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') { @@ -1081,7 +1109,9 @@ class SyncStoreReceiptOrderMatches extends Command 'paid_amount' => null, 'difference' => null, 'match_status' => self::STATUS_UNMATCHED, - 'remark' => $core === '' ? self::REMARK_EMPTY_NO : self::REMARK_PROMO_ONLY, + 'remark' => $core === '' + ? self::caixiaoRemark((string) $group['store_name'], self::REMARK_EMPTY_NO) + : self::REMARK_PROMO_ONLY, 'imported_at' => $now, ]; } @@ -1197,6 +1227,30 @@ class SyncStoreReceiptOrderMatches extends Command return strtoupper($value); } + /** + * 是否 1688 采销专用账户(按账单店铺名判断)。 + */ + private static function isCaixiaoStore(string $storeName): bool + { + return str_contains(trim($storeName), self::CAIXIAO_STORE_KEYWORD); + } + + /** + * 1688 采销专用账户的未匹配原因单独成档;其余店铺的标注原样返回。 + */ + private static function caixiaoRemark(string $storeName, string $remark): string + { + if (! self::isCaixiaoStore($storeName)) { + return $remark; + } + + return match ($remark) { + self::REMARK_EMPTY_NO => self::REMARK_EMPTY_NO_CAIXIAO, + self::REMARK_NO_ORDER => self::REMARK_NO_ORDER_CAIXIAO, + default => $remark, + }; + } + /** * 账单行上的业务备注:科目映射表的「业务备注关键词」用它匹配。 * diff --git a/app/Console/Commands/SyncStoreViolations.php b/app/Console/Commands/SyncStoreViolations.php index 6884eb64..a05743ac 100644 --- a/app/Console/Commands/SyncStoreViolations.php +++ b/app/Console/Commands/SyncStoreViolations.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Business\StoreViolationAlertCalculator; use App\Business\StoreViolationMapper; use Illuminate\Console\Command; use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository; @@ -23,7 +24,11 @@ class SyncStoreViolations extends Command protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)'; - public function handle(StoreViolationMapper $mapper, StoreViolationRecordRepository $repository): int + public function handle( + StoreViolationMapper $mapper, + StoreViolationRecordRepository $repository, + StoreViolationAlertCalculator $calculator, + ): int { if (! $repository->available()) { $this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。'); @@ -68,11 +73,32 @@ class SyncStoreViolations extends Command $skipped = 0; $read = 0; + // 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列, + // 避免每日同步把导入补充的数据清空。 + $extraColumns = $this->availableExtraColumns($pdo); + $updateColumns = $extraColumns === [] + ? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS + : array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns)); + // 预警派生字段(级别/责任人/处理截止等)每次都随同步重算写入。 + $updateColumns = array_values(array_unique(array_merge( + $updateColumns, + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ))); + try { while (true) { - $sql = 'SELECT TOP ' . $batchSize - . ' id, shop_name, violation_no, violation_date, deadline, violation_content,' - . ' status, window_name, platform, crawl_date' + // FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"), + // 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。 + $selectedColumns = ' id, shop_name, violation_no,' + . ' CONVERT(varchar(10), violation_date, 23) AS violation_date,' + . ' CONVERT(varchar(10), deadline, 23) AS deadline,' + . ' violation_content, status, window_name, platform,' + . ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date'; + foreach ($extraColumns as $target => $sourceColumn) { + $selectedColumns .= ', ' . $sourceColumn . ' AS ' . $target; + } + + $sql = 'SELECT TOP ' . $batchSize . $selectedColumns . ' FROM rpt.crawl_shop_violation WHERE id > ?'; $params = [$lastId]; @@ -102,6 +128,14 @@ class SyncStoreViolations extends Command continue; } + $mapped = array_merge($mapped, $calculator->calculate( + (string) $mapped['platform'], + $mapped['shop_name'], + $mapped['violation_type'], + $mapped['violation_date'], + $mapped['appeal_deadline'], + )); + $payload[] = $mapped + [ 'synced_at' => $now, 'created_at' => $now, @@ -109,7 +143,7 @@ class SyncStoreViolations extends Command ]; } - $upserted += $repository->upsertRows($payload, StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS); + $upserted += $repository->upsertRows($payload, $updateColumns); } } catch (Throwable $exception) { report($exception); @@ -132,4 +166,47 @@ class SyncStoreViolations extends Command return self::SUCCESS; } + + /** @return array 统一表字段 => 上游视图列名 */ + private function availableExtraColumns(PDO $pdo): array + { + try { + $statement = $pdo->query( + "SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('rpt.crawl_shop_violation')" + ); + $names = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (Throwable) { + return []; + } + + return self::pickExtraColumns(array_map('strval', $names)); + } + + /** + * 从上游可用列名中挑选可同步的扩展列;列名以数据平台实际提供的为准。 + * + * @param list $availableColumns + * @return array + */ + public static function pickExtraColumns(array $availableColumns): array + { + $candidates = [ + 'violation_reason' => ['violation_reason', 'reason'], + 'disposition' => ['disposition', 'handling_method', 'disposal'], + ]; + + $available = array_map(static fn (string $name): string => strtolower($name), $availableColumns); + $selected = []; + foreach ($candidates as $target => $names) { + foreach ($names as $name) { + if (in_array($name, $available, true)) { + $selected[$target] = $name; + break; + } + } + } + + return $selected; + } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 43feda7a..64d8d09b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -65,6 +65,17 @@ class Kernel extends ConsoleKernel ->withoutOverlapping(60) ->runInBackground(); + // 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总。 + $schedule->command('store-violations:push-instant') + ->cron('*/5 9-21 * * *') + ->withoutOverlapping(5) + ->runInBackground(); + + $schedule->command('store-violations:push-digest') + ->dailyAt('10:00') + ->withoutOverlapping(30) + ->runInBackground(); + $schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0') ->dailyAt('00:00') ->withoutOverlapping(180) 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 index 3a447924..d0635468 100644 --- a/app/Http/Controllers/Business/CourierOnboardings.php +++ b/app/Http/Controllers/Business/CourierOnboardings.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Business; use App\Models\Business\CourierOnboarding; +use App\Models\Business\CourierOnboardingEvent; use App\Models\Business\CourierOnboardingFile; use App\Support\CourierOnboardingService; use App\Support\QrCode; @@ -12,6 +13,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -25,15 +27,31 @@ class CourierOnboardings extends Controller { $this->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', '入驻二维码已生成,发给快递方扫码填写即可。'); @@ -42,18 +60,92 @@ class CourierOnboardings extends Controller public function show(CourierOnboarding $onboarding): View { $this->ensureReady(); - $onboarding->load(['files', 'events.actor', 'quoteVersion']); + 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), - 'canFinalReview' => $this->isAdmin() && (int) $onboarding->first_reviewed_by !== (int) user_id(), + '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(); @@ -126,17 +218,84 @@ class CourierOnboardings extends Controller 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' => ['required', 'string', 'max:100'], + '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()); - return $this->back($onboarding, '财务复审通过。下一步:到协议签署页发起合作协议,签署完成后回来确认开通。'); + 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 @@ -150,7 +309,12 @@ class CourierOnboardings extends Controller private function formUrl(CourierOnboarding $onboarding): string { - return route('courier-onboarding.form', ['token' => $onboarding->token]); + $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 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 ad5ca2ec..721ccc2e 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -13,6 +13,7 @@ use App\Business\UpstreamBusinessInformation; use App\Business\StoreReceivingAccountTemplate; use App\Business\StoreReceivingAccountWorkbookImporter; use App\Business\StoreViolationWorkbookImporter; +use App\Business\StoreViolationAlertCalculator; use App\Business\WithdrawalManagementReport; use App\Jobs\Common\InventoryReportExport; use App\Models\Banking\MarketingAccountBill; @@ -31,6 +32,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Illuminate\Support\Carbon; @@ -69,6 +71,7 @@ use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\AccountMappingStore; use Modules\EcommerceFinance\Services\AccountMappingValidator; +use Modules\EcommerceFinance\Services\DingTalkApprovalAttachmentDownloader; use Modules\EcommerceFinance\Services\StoreReportInitialPeriods; use Modules\EcommerceFinance\Services\TaxForecastLogic; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; @@ -135,7 +138,15 @@ class Pages extends Controller { $basis = self::differenceBasis(); - return ['账单无单号', '单号格式异常', '订单表无此单', $basis['no_amount_remark'], '无收款账单']; + return [ + '账单无单号', + '账单无单号(采销收款)', + '单号格式异常', + '订单表无此单', + '1688采销收款(非销售单)', + $basis['no_amount_remark'], + '无收款账单', + ]; } /** @@ -154,7 +165,7 @@ class Pages extends Controller } /** - * 「实付含已退款部分」的聚水潭证据 SQL(仅抖音负差额行计算,CASE 守卫保证其它行不触发子查询)。 + * 「订单实付含已退款部分」的聚水潭证据 SQL(仅抖音负差额行计算,CASE 守卫保证其它行不触发子查询)。 * * 退款单:经订单 o_id 关联(so_id 列没有索引,订单侧走 so_id 索引、退款单侧走 o_id 索引), * 只统计 SUCCESS 且非 Split 父单的叶子退款;分别取合计与单笔最大值。 @@ -182,6 +193,53 @@ class Pages extends Controller ]; } + /** + * 得物「联合营销费未让利」证据 SQL(仅得物负差额行计算,CASE 守卫保证其它行不触发子查询)。 + * + * 得物联合营销费 = 让利买家部分(整数)+ 商家自担尾数;订单实付只扣了让利部分, + * 未让利尾数 = 营销费支出 − 让利,即「营销费支出 + 差额」恰为整数; + * 对不上(差值非整)时不标注,避免误标。 + * + * @return array{guard:string, marketing_expense:string} + */ + private static function receiptDewuMarketingEvidenceSql(): array + { + $guard = "platform = '得物' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) < -0.005"; + + return [ + 'guard' => $guard, + 'marketing_expense' => "(SELECT COALESCE(SUM(b.expense_amount), 0) + FROM dewu_settlement_bill_rows b + WHERE b.online_order_no = store_receipt_order_match_rows.online_order_no + AND b.bill_item = '联合营销费')", + ]; + } + + /** + * 京喜「订单实付取供货价」证据 SQL(仅京喜正差额行计算,CASE 守卫保证其它行不触发子查询)。 + * + * 京喜工厂店/直营为供货模式:账单按「应收 − 直营服务费 = 供货价(净打款)」结算, + * 聚水潭「订单实付」记的是供货价(净收);差额 = 该单直营服务费规模(收/支较大一边)。 + * + * @return array{guard:string, supply_fee:string} + */ + private static function receiptJingxiSupplyEvidenceSql(): array + { + $guard = "platform = '京喜账单' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) > 0.005"; + + return [ + 'guard' => $guard, + 'supply_fee' => "(SELECT GREATEST(COALESCE(SUM(b.income), 0), COALESCE(SUM(b.expense), 0)) + FROM jd_daily_bill_rows b + WHERE b.order_no = store_receipt_order_match_rows.online_order_no + AND b.bill_item = '直营服务费')", + 'supply_net' => "(SELECT ABS(COALESCE(SUM(b.income), 0) - COALESCE(SUM(b.expense), 0)) + FROM jd_daily_bill_rows b + WHERE b.order_no = store_receipt_order_match_rows.online_order_no + AND b.bill_item = '直营服务费')", + ]; + } + /** * 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。 * 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0; @@ -196,22 +254,47 @@ class Pages extends Controller $label = $basis['label']; $rules = []; + $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; $rules[$label . '未匹配'] = "match_status = '未匹配'"; $rules[$label . '取数为0(种菜)'] = "match_status = '已匹配' AND planting_amount IS NOT NULL AND ABS(COALESCE($column, 0)) < 0.005"; $rules[$label . '取数为0'] = "match_status = '已匹配' AND planting_amount IS NULL AND ABS(COALESCE($column, 0)) < 0.005"; + // 得物「订单实付未减联合营销费」:负差额 = 联合营销费未让利尾数(营销费支出 + 差额恰为整数)才标注。 + // 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。 + $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); + $dewuNeat = '(' . $dewuEvidence['marketing_expense'] . ' + ' . $effective . ')'; + $rules['订单实付未减联合营销费'] = 'CASE WHEN (' . $dewuEvidence['guard'] + . ' AND (' . $dewuEvidence['marketing_expense'] . ') > 0.005' + . ' AND ABS(' . $dewuNeat . ' - ROUND(' . $dewuNeat . ', 0)) < 0.005) THEN 1 ELSE 0 END = 1'; + + // 京喜「订单实付取供货价」:正差额 = 该单直营服务费规模(供货模式的净额差)才标注。 + $jingxiEvidence = self::receiptJingxiSupplyEvidenceSql(); + $rules['订单实付取供货价'] = 'CASE WHEN (' . $jingxiEvidence['guard'] + . ' AND (' . $jingxiEvidence['supply_fee'] . ') > 0.005' + . ' AND (ABS(' . $effective . ' - (' . $jingxiEvidence['supply_fee'] . ')) < 0.005' + . ' OR ABS(' . $effective . ' - (' . $jingxiEvidence['supply_net'] . ')) < 0.005)) THEN 1 ELSE 0 END = 1'; $rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005"; $rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005"; $rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005"; - $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; - // 抖音「实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上(≈差额)才标注。 + // 抖音「订单实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上 + // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)才标注。 + // 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。 + // 整单全退(退款合计≈实付)也视为命中:全退后收入只剩后续零星收款(如用户打款)。 $refundEvidence = self::receiptRefundEvidenceSql(); $differenceAbs = 'ABS(' . $effective . ')'; $refundHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - ' . $differenceAbs . ') < 0.02 ELSE 0 END'; - $rules['实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' + $refundGapHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; + $refundGapNetHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS((' . $amount . ' + COALESCE(refund_amount, 0)) - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; + $refundFullHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - COALESCE(paid_amount, 0)) < 0.02 ELSE 0 END'; + $rules['订单实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' . $refundHit($refundEvidence['refund_sum']) . ' OR ' . $refundHit($refundEvidence['refund_max']) . ' OR ' . $refundHit($refundEvidence['cancelled_sum']) + . ' OR ' . $refundGapHit($refundEvidence['refund_sum']) + . ' OR ' . $refundGapHit($refundEvidence['refund_max']) + . ' OR ' . $refundGapHit($refundEvidence['cancelled_sum']) + . ' OR ' . $refundGapNetHit($refundEvidence['refund_sum']) + . ' OR ' . $refundFullHit($refundEvidence['refund_sum']) . ') ELSE 0 END = 1'; $conditions = []; @@ -252,6 +335,26 @@ class Pages extends Controller if (abs($product) < 0.005) { return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0'; } + // 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。 + // 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。 + if (($row->platform ?? '') === '得物' && $difference < -0.005) { + $marketingExpense = (float) ($row->order_dewu_marketing_expense ?? 0); + if ($marketingExpense > 0.005) { + $neat = $marketingExpense + $difference; + if (abs($neat - round($neat)) < 0.005) { + return '订单实付未减联合营销费'; + } + } + } + // 京喜正差额:差额与直营服务费规模吻合(订单实付取的是供货价/净收)→「订单实付取供货价」。 + if (($row->platform ?? '') === '京喜账单' && $difference > 0.005) { + $supplyFee = (float) ($row->order_jingxi_supply_fee ?? 0); + $supplyNet = (float) ($row->order_jingxi_supply_net ?? 0); + if ($supplyFee > 0.005 + && (abs($difference - $supplyFee) < 0.005 || abs($difference - $supplyNet) < 0.005)) { + return '订单实付取供货价'; + } + } if (abs($product - ($income + $discount)) < 0.005) { return $label . '未减冲减'; } @@ -262,13 +365,28 @@ class Pages extends Controller return $label . '未减优惠和冲减'; } - // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上(≈差额)→「实付含已退款部分」。 + // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上 + // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)→「订单实付含已退款部分」。 + // 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。 + // 整单全退(退款合计≈实付)也视为命中:全退后收入只剩后续零星收款(如用户打款)。 if (($row->platform ?? '') === '抖音' && $difference < 0) { + $refundGap = (float) ($row->paid_amount ?? 0) - $income; $refunds = [$row->order_refund_sum ?? null, $row->order_refund_max ?? null, $row->order_cancelled_sum ?? null]; foreach ($refunds as $refund) { - if ($refund !== null && (float) $refund > 0.005 && abs((float) $refund - abs($difference)) < 0.02) { - return '实付含已退款部分'; + if ($refund === null || (float) $refund <= 0.005) { + continue; } + $refundAmount = (float) $refund; + if (abs($refundAmount - abs($difference)) < 0.02 || abs($refundAmount - $refundGap) < 0.02) { + return '订单实付含已退款部分'; + } + } + $refundSum = $row->order_refund_sum ?? null; + $refundNet = (float) ($row->refund_amount ?? 0); + $refundFull = (float) ($row->paid_amount ?? 0); + if ($refundSum !== null && (float) $refundSum > 0.005 + && (abs((float) $refundSum + $refundNet - $refundGap) < 0.02 || abs((float) $refundSum - $refundFull) < 0.02)) { + return '订单实付含已退款部分'; } } @@ -381,6 +499,14 @@ class Pages extends Controller abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); + // 附加费结算已独立到快递应付页面,旧页签地址跳到新页面。 + if ($section === 'courier-payables' && $page === 'courier-surcharge' && $tab === 'settlement') { + return redirect()->route('business.pages.show', array_merge(request()->query(), [ + 'section' => $section, + 'page' => 'courier-surcharge-settlement', + ])); + } + $pages = collect($sectionConfig['pages'] ?? []); if ($pages->isEmpty()) { @@ -807,10 +933,11 @@ class Pages extends Controller $storeFilter = $this->receiptOrderMatchFilterValues(request('store_name')); [$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark')); $unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason')); + $differenceState = $this->receiptOrderMatchFilterValues(request('difference_state')); $orderNo = trim((string) request('online_order_no', '')); // 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static - $applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter): void { + $applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState): void { if ($platformFilter !== []) { $query->whereIn('platform', $platformFilter); } @@ -830,6 +957,9 @@ class Pages extends Controller if ($unmatchedReasonFilter !== []) { $this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter); } + if ($differenceState !== []) { + $this->applyReceiptDifferenceStateFilter($query, $differenceState); + } }; // 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。 @@ -837,13 +967,11 @@ class Pages extends Controller ->table('store_receipt_order_match_rows') ->where('report_month', $month); - $countQuery = $base(); - $applyFilters($countQuery); - $total = (int) $countQuery->count(); - + // 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。 $aggQuery = $base(); $applyFilters($aggQuery); $agg = $aggQuery->selectRaw(" + COUNT(*) as total, SUM(payment_count) as payments, SUM(income_amount) as income_total, SUM(discount_amount) as discount_total, @@ -858,6 +986,7 @@ class Pages extends Controller SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched, SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched ")->first(); + $total = (int) ($agg->total ?? 0); $stats = [ 'month' => $month, @@ -919,11 +1048,16 @@ class Pages extends Controller $itemsQuery = $base(); $applyFilters($itemsQuery); $refundEvidence = self::receiptRefundEvidenceSql(); + $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); + $jingxiEvidence = self::receiptJingxiSupplyEvidenceSql(); $items = $itemsQuery ->selectRaw('*') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum') + ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net') ->orderBy('occurred_date') ->orderBy('store_name') ->orderBy('online_order_no') @@ -997,6 +1131,71 @@ class Pages extends Controller $query->where('match_status', '未匹配')->whereIn('remark', $labels); } + /** + * 「差额(有/无)」筛选:按页面差额口径判断(未匹配行按订单实付=0 计入差额)。 + * 多选内部是「或」:全选或全不选都等于不过滤。 + * + * @param \Illuminate\Database\Query\Builder $query + * @param string|list $state + */ + private function applyReceiptDifferenceStateFilter($query, $state): void + { + $states = is_array($state) ? $state : [$state]; + $has = in_array('有差额', $states, true); + $none = in_array('无差额', $states, true); + if ($has === $none) { + return; + } + $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; + $query->whereRaw($has ? 'ABS(' . $effective . ') > 0.005' : 'ABS(' . $effective . ') <= 0.005'); + } + + /** + * 「差额(有/无)」联动计数:应用除差额以外的筛选条件后, + * 分别统计有差额 / 无差额的行数(行数为 0 的取值不返回,前端不展示)。 + * + * @param array $filters + * @return array{values:list,counts:array} + */ + private function receiptDifferenceStateFacet(string $month, array $filters, string $orderNo): array + { + $query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('store_receipt_order_match_rows') + ->where('report_month', $month); + + foreach ($filters as $key => $values) { + if ($key === 'difference_state' || $values === [] || $values === '') { + continue; + } + if ($key === 'remark') { + $this->applyReceiptRemarkFilter($query, (array) $values); + continue; + } + if ($key === 'unmatched_reason') { + $this->applyReceiptUnmatchedReasonFilter($query, (array) $values); + continue; + } + $query->whereIn($key, $values); + } + if ($orderNo !== '') { + $query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%'); + } + + $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; + $row = $query->selectRaw('SUM(CASE WHEN ABS(' . $effective . ') > 0.005 THEN 1 ELSE 0 END) as has_diff, ' + . 'SUM(CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 1 ELSE 0 END) as none_diff')->first(); + + $counts = []; + if ((int) ($row->has_diff ?? 0) > 0) { + $counts['有差额'] = (int) $row->has_diff; + } + if ((int) ($row->none_diff ?? 0) > 0) { + $counts['无差额'] = (int) $row->none_diff; + } + + return ['values' => ['有差额', '无差额'], 'counts' => $counts]; + } + /** * 业绩明细的多选筛选值:多选控件提交 name[](数组),同时兼容历史单值链接。 * @@ -1084,13 +1283,15 @@ class Pages extends Controller 'match_status' => $statusValues, 'remark' => $remarkValues, 'unmatched_reason' => $this->receiptOrderMatchFilterValues(request('unmatched_reason')), + 'difference_state' => $this->receiptOrderMatchFilterValues(request('difference_state')), ]; $orderNo = trim((string) request('online_order_no', '')); // 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存 $cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo])); - return response()->json(cache()->remember($cacheKey, now()->addSeconds(60), function () use ($month, $filters, $orderNo) { + // 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。 + return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) { return [ 'month' => substr($month, 0, 7), 'facets' => [ @@ -1099,6 +1300,7 @@ class Pages extends Controller 'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS), 'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())), 'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()), + 'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo), ], ]; })); @@ -1118,7 +1320,7 @@ class Pages extends Controller ->where('report_month', $month); foreach ($filters as $key => $values) { - if ($key === $column || $values === []) { + if ($key === $column || $values === [] || $values === '') { continue; } // 差额异常备注按公式条件判定 @@ -1131,6 +1333,11 @@ class Pages extends Controller $this->applyReceiptUnmatchedReasonFilter($query, (array) $values); continue; } + // 差额(有/无):按页面差额口径过滤 + if ($key === 'difference_state') { + $this->applyReceiptDifferenceStateFilter($query, (array) $values); + continue; + } $query->whereIn($key, $values); } if ($orderNo !== '') { @@ -1294,14 +1501,27 @@ class Pages extends Controller if ($remarkValues !== []) { $this->applyReceiptRemarkFilter($query, $remarkValues); } + $unmatchedReasonValues = $this->receiptOrderMatchFilterValues($request->query('unmatched_reason')); + if ($unmatchedReasonValues !== []) { + $this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonValues); + } + $differenceState = $this->receiptOrderMatchFilterValues($request->query('difference_state')); + if ($differenceState !== []) { + $this->applyReceiptDifferenceStateFilter($query, $differenceState); + } - // 「实付含已退款部分」判定所需证据列(与页面同一口径;仅抖音负差额行会真正计算子查询)。 + // 「订单实付含已退款部分」「订单实付未减联合营销费」判定所需证据列(与页面同一口径;仅对应平台负差额行会真正计算子查询)。 $refundEvidence = self::receiptRefundEvidenceSql(); + $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); + $jingxiEvidence = self::receiptJingxiSupplyEvidenceSql(); $query ->selectRaw('*') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max') - ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum'); + ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum') + ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net'); $selected = array_values(array_intersect( array_filter(explode(',', (string) $request->query('columns', ''))), @@ -2529,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 @@ -2556,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 @@ -2790,6 +3035,7 @@ class Pages extends Controller 'account-mapping' => $this->contentForAccountMapping(), 'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(), 'store-violation-alert' => $this->contentForStoreViolationAlerts(), + 'preview-statistics' => $this->contentForPreviewStatistics(), 'planting-details' => $this->contentForPlantingDetails(), 'jst-order-labels' => $this->contentForJstOrderLabels(), 'store-report-data-logic' => $this->prepareStoreReportLogic(), @@ -3105,7 +3351,7 @@ class Pages extends Controller 'storeViolationFilters' => $filters, 'storeViolationFilterOptions' => $error === null ? $repository->filterOptions($activePlatform) - : ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []], + : ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []], 'storeViolationPlatformCounts' => $error === null ? $repository->platformCounts() : [], 'storeViolationSync' => $repository->syncStatus(), 'storeViolationError' => $error, @@ -3116,14 +3362,185 @@ class Pages extends Controller /** @return array */ private function storeViolationFilters(Request $request): array { - return [ + $owner = mb_substr(trim((string) $request->query('owner')), 0, 64); + $ownerIsMe = in_array(mb_strtolower($owner), ['me', '我的'], true); + if ($ownerIsMe) { + $owner = mb_substr(trim((string) (auth()->user()->name ?? '')), 0, 64); + } + + $handlingStatus = mb_substr(trim((string) $request->query('handling_status')), 0, 32); + $view = trim((string) $request->query('view')) === 'all' ? 'all' : 'default'; + $explicit = trim((string) $request->query('keyword')) !== '' + || trim((string) $request->query('status')) !== '' + || $handlingStatus !== '' + || trim((string) $request->query('category')) !== '' + || $this->dateFilter($request->query('date_from')) !== '' + || $this->dateFilter($request->query('date_to')) !== '' + || trim((string) $request->query('level')) !== '' + || $owner !== '' + || trim((string) $request->query('overdue')) === '1'; + + $filters = [ 'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100), 'status' => mb_substr(trim((string) $request->query('status')), 0, 64), - 'handling_status' => mb_substr(trim((string) $request->query('handling_status')), 0, 32), + 'handling_status' => $handlingStatus, 'category' => mb_substr(trim((string) $request->query('category')), 0, 64), 'date_from' => $this->dateFilter($request->query('date_from')), 'date_to' => $this->dateFilter($request->query('date_to')), + 'level' => mb_substr(trim((string) $request->query('level')), 0, 8), + 'owner' => $owner, + 'owner_is_me' => $ownerIsMe ? '1' : '', + 'overdue' => trim((string) $request->query('overdue')) === '1' ? '1' : '', + 'view' => $view, + 'has_explicit' => $explicit ? '1' : '', ]; + + // 默认视图:只展示「需提醒 + 未处理」,避免历史数据淹没预警; + // 其余数据仍可通过「全部数据」查看。 + if ($view === 'default') { + $filters['needs_alert'] = '1'; + if ($filters['handling_status'] === '') { + $filters['handling_status'] = (string) config('store_violations.default_handling_status', '未处理'); + } + } + + return $filters; + } + + /** 财务经营风险管控 › 预览统计:顶部 4 张 KPI 卡 + 需提醒且未处理的明细预览。 */ + protected function contentForPreviewStatistics(): array + { + $request = request(); + $platforms = (array) config('store_violations.platforms', []); + $requested = (string) $request->query('platform', (string) array_key_first($platforms)); + $activePlatform = array_key_exists($requested, $platforms) ? $requested : (string) array_key_first($platforms); + + $repository = app(StoreViolationRecordRepository::class); + $limit = min(max((int) setting('default.list_limit', 25), 10), 100); + $error = null; + $stats = []; + $counts = []; + $panels = []; + + if (! $repository->available()) { + $error = '店铺违规统一表尚未建立,暂时无法读取数据。'; + } else { + try { + $stats = $repository->statsByPlatform(); + $counts = $repository->platformCounts(); + } catch (Throwable $exception) { + report($exception); + $error = '店铺违规数据暂时无法读取,请稍后重试。'; + } + } + + foreach (array_keys($platforms) as $slug) { + $page = $slug === $activePlatform ? max((int) $request->query('page', 1), 1) : 1; + $records = Collection::make(); + $total = 0; + + if ($error === null) { + try { + $result = $repository->paginate((string) $slug, [ + 'needs_alert' => true, + 'handling_status' => (string) config('store_violations.default_handling_status', '未处理'), + ], $page, $limit); + $records = Collection::make($result['data']); + $total = (int) $result['total']; + } catch (Throwable $exception) { + report($exception); + $error = '店铺违规数据暂时无法读取,请稍后重试。'; + } + } + + $panels[$slug] = [ + 'rows' => new LengthAwarePaginator($records, $total, $limit, $page, [ + 'path' => $request->url(), + 'query' => array_merge($request->query(), ['platform' => (string) $slug]), + ]), + 'total' => $total, + 'stats' => $stats[$slug] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0], + ]; + } + + return [ + 'view' => 'business.pages.financial-risk-control.preview-statistics', + 'storeViolationPlatforms' => $platforms, + 'storeViolationActivePlatform' => $activePlatform, + 'storeViolationPlatformCounts' => $counts, + 'storeViolationPanels' => $panels, + 'storeViolationError' => $error, + ]; + } + + /** 店铺违规信息预警:行内更新处理状态(已忽略必填原因)。 */ + public function updateStoreViolation(Request $request) + { + $data = $request->validate([ + 'id' => ['required', 'integer'], + 'handling_status' => ['required', 'string', \Illuminate\Validation\Rule::in((array) config('store_violations.handling_statuses', []))], + 'ignore_reason' => ['nullable', 'string', 'max:191'], + 'points_deducted' => ['nullable', 'numeric', 'min:0', 'max:9999'], + 'penalty_date' => ['nullable', 'date_format:Y-m-d'], + ]); + + $ignoreReason = trim((string) ($data['ignore_reason'] ?? '')); + if ($data['handling_status'] === '已忽略' && $ignoreReason === '') { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'ignore_reason' => '已忽略必须填写忽略原因。', + ]); + } + + $repository = app(StoreViolationRecordRepository::class); + abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。'); + abort_if($repository->findById((int) $data['id']) === null, 404); + + $updates = [ + 'handling_status' => $data['handling_status'], + 'ignore_reason' => $data['handling_status'] === '已忽略' ? $ignoreReason : null, + 'updated_at' => now(), + ]; + if ($request->has('points_deducted')) { + $updates['points_deducted'] = $data['points_deducted'] ?? null; + } + if ($request->has('penalty_date')) { + $updates['penalty_date'] = $data['penalty_date'] ?? null; + } + + $repository->updateFields((int) $data['id'], $updates); + + return back(302, [], route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + ]))->with('success', '处理状态已更新。'); + } + + /** 店铺违规信息预警:群消息「去认领」签名链接,幂等置为处理中。 */ + public function claimStoreViolation(Request $request, int $record) + { + abort_unless($request->hasValidSignature(), 403, '链接已过期,请从最新的提醒消息进入。'); + + $repository = app(StoreViolationRecordRepository::class); + abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。'); + $row = $repository->findById($record); + abort_if($row === null, 404); + + $defaultStatus = (string) config('store_violations.default_handling_status', '未处理'); + if (trim((string) ($row->handling_status ?? '')) === $defaultStatus) { + $repository->updateFields($record, [ + 'handling_status' => '处理中', + 'updated_at' => now(), + ]); + } + + return redirect() + ->route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => (string) ($row->platform ?? ''), + 'keyword' => (string) ($row->violation_no ?? ''), + ]) + ->with('success', '已认领该违规记录,处理状态已更新为“处理中”。'); } /** 店铺违规信息预警:按当前筛选导出统一表头(Excel)。 */ @@ -3165,13 +3582,22 @@ class Pages extends Controller if ($key === 'points_deducted' && $value !== null) { $value = (float) $value; } + if ($key === 'alert_level' && $value !== null) { + $value = (string) (config('store_violations.alert_levels.' . $value . '.label') ?? $value); + } + if ($key === 'is_overdue') { + $value = StoreViolationAlertCalculator::isOverdue( + $row->handling_status ?? null, + $row->handling_deadline ?? null, + ) ? '已超时' : '未超时'; + } $values[] = $value === null ? '' : $value; } $sheet->fromArray($values, null, 'A' . $rowNumber); $rowNumber++; } - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3197,7 +3623,7 @@ class Pages extends Controller $headers = array_map(static fn (array $column): string => (string) $column['label'], $columns); $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray( - ['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'], + ['示例·某某旗舰店', 'P1-较重', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '示例·张三', '待处理', '', '2026-10-11', '2026-10-13 23:59:59', '未超时', '未推送', '未处理', ''], null, 'A2', ); @@ -3208,7 +3634,7 @@ class Pages extends Controller 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(24); - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3221,10 +3647,18 @@ class Pages extends Controller ['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'], ['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'], ['违规分类', '选填。留空时按违规类型关键词自动归类。'], + ['预警级别', '自动计算。按「平台+违规类型」匹配违规字典;可留空,导入时自动生成。'], + ['责任人', '自动计算。按店铺名称关联店铺运营负责人;可留空,导入时自动生成。'], + ['违规原因', '选填。平台展示的违规原因/详细说明。'], + ['处置方式', '选填。平台对违规的处置/处理方式。'], ['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'], ['扣分', '选填。数字,例如 2 或 0.5。'], ['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'], - ['处理状态', '选填。可选值:' . $handling . ';留空按“未处理”写入。'], + ['处理截止', '自动计算。申诉截止日期非空时取其当天 23:59:59;否则按级别(P0+1 / P1+2 / P2+3 天)计算。'], + ['是否超时', '自动计算。处理状态非“处理完成/已忽略”且已过处理截止时显示“已超时”。'], + ['推送状态', '系统回写。导入时忽略该列。'], + ['处理状态', '选填。可选值:' . $handling . ';选“已忽略”时必须填写忽略原因;留空按“未处理”写入。'], + ['忽略原因', '选填。处理状态为“已忽略”时必填。'], ['平台', '无需填写。导入时按当前页签决定平台。'], ['提示', '请保留第一行表头;示例行可直接替换为实际数据。'], ], null, 'A1'); @@ -3234,7 +3668,7 @@ class Pages extends Controller ]); $notes->getColumnDimension('A')->setWidth(34); $notes->getColumnDimension('B')->setWidth(72); - $notes->getStyle('B1:B11')->getAlignment()->setWrapText(true); + $notes->getStyle('B1:B19')->getAlignment()->setWrapText(true); (new Xlsx($spreadsheet))->save('php://output'); }, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [ @@ -3783,7 +4217,7 @@ class Pages extends Controller protected function contentForCourierSurcharge(?string $tab = null): array { $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); - $quoteInput = $tab === 'settlement' ? [] : request()->query(); + $quoteInput = request()->query(); $filters = [ 'keyword' => $clean($quoteInput['keyword'] ?? '', 40), 'courier' => $clean($quoteInput['courier'] ?? '', 40), @@ -3811,10 +4245,6 @@ class Pages extends Controller 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; - if ($tab === 'settlement') { - $content += $this->contentForCourierSurchargeSettlement(); - } - return $content; } @@ -4630,6 +5060,37 @@ class Pages extends Controller } } + /** + * 下载「应付云仓车费」某条审批实例的钉钉附件: + * 先校验附件确实属于该实例(镜像表单数据),再换取钉盘临时地址并透传下载。 + */ + public function downloadDingPayableAttachment(Request $request): StreamedResponse + { + $processInstanceId = mb_substr(trim((string) $request->query('process_instance_id', '')), 0, 100); + $fileId = mb_substr(trim((string) $request->query('file_id', '')), 0, 64); + abort_if($processInstanceId === '' || $fileId === '', 404); + + $attachment = $this->dingPayableWorkflows->attachment($processInstanceId, $fileId); + abort_if($attachment === null, 404, '附件不存在或不属于该审批实例。'); + + $uri = app(DingTalkApprovalAttachmentDownloader::class)->downloadUri($processInstanceId, $fileId); + abort_if($uri === null || $uri === '', 502, '暂时无法获取钉钉附件下载地址,请稍后重试。'); + + // 钉盘地址是带签名的 OSS 链接:请求时不要附带额外请求头,避免破坏签名。 + $upstream = Http::withOptions(['stream' => true])->timeout(60)->get($uri); + abort_if(! $upstream->successful(), 502, '附件下载失败,请稍后重试。'); + + $fileName = $attachment['fileName'] !== '' ? $attachment['fileName'] : $fileId; + $contentType = (string) ($upstream->header('Content-Type') ?: 'application/octet-stream'); + + return response()->streamDownload(function () use ($upstream): void { + $body = $upstream->toPsrResponse()->getBody(); + while (! $body->eof()) { + echo $body->read(8192); + } + }, $fileName, ['Content-Type' => $contentType]); + } + protected function contentForOnlineDaifaPrice(): array { $filters = $this->onlineDaifaPriceFilters(request()); @@ -6028,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('无法创建快递资料导出流。'); @@ -6044,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 ?? []))), ]); } @@ -6247,8 +6728,9 @@ 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', 'onboarded'], true) ? $tab : 'express'; + $tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express'; $page = max((int) request()->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, $page, [ @@ -6260,12 +6742,16 @@ 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 === 'onboarded') { - // 扫码入驻并开通的新快递,由财务系统自己维护,不依赖工作平台镜像。 - $rows = app(\App\Business\CourierOnboardingPageData::class)->activated($page, $limit); - } elseif ($tab === 'express') { + // 网点 / 打款信息 / 操作日志页签也显示快递资料主表的同步状态,避免误显示「镜像未建立」。 + if ($tab !== 'express' && $this->courierInformation->available()) { + $state = $this->courierInformation->state(); + $syncStatus = $this->courierInformation->syncStatus($state); + } + if ($tab === 'express') { if (! $this->courierInformation->available()) { $error = '共享业务镜像尚未建立,快递资料暂时无法读取。'; } else { @@ -6291,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); @@ -6315,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, @@ -6328,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 index a4afc9c0..51b62427 100644 --- a/app/Http/Controllers/Onboarding/CourierOnboardingForms.php +++ b/app/Http/Controllers/Onboarding/CourierOnboardingForms.php @@ -36,6 +36,8 @@ class CourierOnboardingForms extends Controller 'provinces' => CourierOnboarding::PROVINCES, 'fileKinds' => CourierOnboardingFile::KINDS, 'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS, + 'personKinds' => CourierOnboardingFile::PERSON_KINDS, + 'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS, ]); } @@ -90,7 +92,10 @@ class CourierOnboardingForms extends Controller $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'], @@ -103,28 +108,55 @@ class CourierOnboardingForms extends Controller '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'], - 'remark' => ['nullable', 'string', 'max:2000'], 'quote_rows' => ['nullable', 'array', 'max:80'], 'quote_rows.*.region' => ['nullable', 'string', 'max:50'], - 'quote_rows.*.first_weight' => $money, - 'quote_rows.*.first_price' => $money, - 'quote_rows.*.rate' => $money, - '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,pdf,xls,xlsx,doc,docx'], + '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'], ]; } @@ -136,11 +168,13 @@ class CourierOnboardingForms extends Controller 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、Word 文件。', + 'files.*.*.mimes' => '请上传照片、PDF 或 Excel 文件。', ]; } } diff --git a/app/Models/Business/CourierOnboarding.php b/app/Models/Business/CourierOnboarding.php index 67266dd9..311ac7b0 100644 --- a/app/Models/Business/CourierOnboarding.php +++ b/app/Models/Business/CourierOnboarding.php @@ -34,14 +34,17 @@ class CourierOnboarding extends Model /** 快递方可以继续编辑资料的状态。 */ 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 = [ - 'waybill_rate' => '面单 + 费率 × 重量', - 'first_additional' => '首重 + 续重', - 'flat' => '一口价 / 分段价', - ]; + public const QUOTE_MODES = \App\Support\CourierQuoteStructure::MODES; public const VOLUMETRIC_RULES = ['不计泡', '计泡']; public const PROVINCES = [ @@ -54,11 +57,14 @@ class CourierOnboarding extends Model protected $fillable = [ 'company_id', 'number', 'token', 'status', 'invite_label', 'expires_at', - 'company_full_name', 'unified_social_credit_code', 'legal_representative', 'business_address', + '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', + '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', - 'quote_mode', 'volumetric_rule', 'volumetric_divisor', 'quote_effective_date', 'quote_rows', 'quote_note', 'remark', + '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', @@ -73,7 +79,13 @@ class CourierOnboarding extends Model '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', @@ -83,7 +95,7 @@ class CourierOnboarding extends Model 'deleted_at' => 'datetime', ]; - protected $hidden = ['token', 'bank_account_number']; + protected $hidden = ['token', 'bank_account_number', 'private_account_number', 'id_number']; public function files(): HasMany { @@ -100,6 +112,29 @@ class CourierOnboarding extends Model 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; @@ -126,9 +161,103 @@ class CourierOnboarding extends Model 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 self::QUOTE_MODES[$this->quote_mode] ?? ''; + 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 @@ -142,7 +271,7 @@ class CourierOnboarding extends Model } /** - * @return list + * @return list,base:?float,rates:list}> */ public function quoteRowList(): array { diff --git a/app/Models/Business/CourierOnboardingEvent.php b/app/Models/Business/CourierOnboardingEvent.php index 2d55e34e..7d8f9862 100644 --- a/app/Models/Business/CourierOnboardingEvent.php +++ b/app/Models/Business/CourierOnboardingEvent.php @@ -37,6 +37,8 @@ class CourierOnboardingEvent extends Model 'cancelled' => '作废邀请', 'extended' => '延长链接有效期', 'internal_updated' => '更新内部信息', + 'quote_pushed' => '推送报价确认单到 e签宝', + 'quote_push_failed' => '报价确认单推送失败', default => $this->event_type, }; } diff --git a/app/Models/Business/CourierOnboardingFile.php b/app/Models/Business/CourierOnboardingFile.php index 142a9f4f..9d864878 100644 --- a/app/Models/Business/CourierOnboardingFile.php +++ b/app/Models/Business/CourierOnboardingFile.php @@ -8,15 +8,47 @@ class CourierOnboardingFile extends Model { public const KINDS = [ 'business_license' => '营业执照', - 'legal_id_card' => '法人身份证(正反面)', - 'bank_account_proof' => '开户许可证 / 银行账户证明', - 'operating_permit' => '快递经营许可证', - 'quote_sheet' => '盖章报价单', - 'other' => '其他资料', + 'legal_id_card_front' => '法人身份证 · 人像面', + 'legal_id_card_back' => '法人身份证 · 国徽面', + 'bank_card_front' => '对私银行卡 · 正面', + 'bank_card_back' => '对私银行卡 · 反面', ]; - /** 提交审核前必须上传的资料。 */ - public const REQUIRED_KINDS = ['business_license', 'bank_account_proof', 'quote_sheet']; + /** 计费方式选「其他」时上传的报价表,单独显示在报价区块里。 */ + 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'; 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 index f9aaa84f..6fb1b1ae 100644 --- a/app/Support/CourierOnboardingService.php +++ b/app/Support/CourierOnboardingService.php @@ -8,7 +8,10 @@ use App\Models\Business\CourierOnboardingEvent; use App\Models\Business\CourierOnboardingFile; use App\Models\Business\CourierQuoteChange; use App\Models\Business\CourierQuoteChangeEvent; +use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; +use App\Services\Esign\EsignClient; +use App\Support\CourierQuoteStructure; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -21,20 +24,34 @@ class CourierOnboardingService { public const DEFAULT_VALID_DAYS = 30; - public const MAX_FILES_PER_KIND = 6; + public const MAX_FILES_PER_KIND = 1; /** 提交审核前快递方必须填写的字段。 */ - private const REQUIRED_FIELDS = [ + private const COMPANY_REQUIRED_FIELDS = [ 'company_full_name' => '公司全称', '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' => '对接人手机', - 'bank_account_name' => '收款户名', - 'bank_name' => '开户行', - 'bank_account_number' => '银行账号', + 'esign_operator_name' => 'e签宝签署人姓名', + 'esign_account' => 'e签宝账号', + 'private_account_name' => '对私收款人', + 'private_bank_name' => '对私账户开户行', + 'private_account_number' => '对私银行卡号', 'invoice_type' => '开票类型', 'settlement_cycle' => '结算周期', 'quote_mode' => '计费方式', @@ -42,6 +59,11 @@ class CourierOnboardingService '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') @@ -49,10 +71,16 @@ class CourierOnboardingService && Schema::hasTable('business_courier_onboarding_events'); } - public function invite(?string $label, int $validDays, int $userId): CourierOnboarding + public function invite(?string $label, int $validDays, int $userId, ?string $courierName = null): CourierOnboarding { - return DB::transaction(function () use ($label, $validDays, $userId): 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(), @@ -62,7 +90,8 @@ class CourierOnboardingService 'created_by' => $userId, 'updated_by' => $userId, ]); - $this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED, $onboarding->invite_label, 'user', $userId); + $this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED, + trim('系统快递名称:' . ($onboarding->courier_name ?? '—') . ($onboarding->invite_label ? ';备注:' . $onboarding->invite_label : '')), 'user', $userId); return $onboarding; }); @@ -216,11 +245,12 @@ class CourierOnboardingService public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。'); - if ((int) $onboarding->first_reviewed_by === $userId) { + if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) { throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']); } - $courierName = trim((string) $data['courier_name']); + // 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。 + $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 { @@ -236,9 +266,9 @@ class CourierOnboardingService if ($version !== null) { $extra['quote_change_id'] = $version->source_change_id; $extra['quote_version_id'] = $version->id; - $note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code},可在协议签署页发起报价确认单。"); + $note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code}。"); } else { - $note = trim(($note ?? '') . "\n快递未填写报价明细,仅有报价附件,请在报价管理中补录。"); + $note = trim(($note ?? '') . "\n快递未填写报价明细,请在报价管理中补录后再发起报价确认。"); } return $this->transition($onboarding, CourierOnboarding::STATUS_APPROVED, 'final_approved', $note, $userId, $extra); @@ -249,14 +279,18 @@ class CourierOnboardingService { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。'); + $quoteSignature = $onboarding->quoteSignature(); + $quoteSigned = $quoteSignature?->status === CourierQuoteSignature::STATUS_ARCHIVED; $signature = $this->signedAgreement($onboarding); - if ($signature === null && blank($offlineNote)) { - throw ValidationException::withMessages(['offline_note' => '未找到已签署的电子协议;如已线下签署纸质协议,请填写说明后再开通。']); + if (! $quoteSigned && $signature === null && blank($offlineNote)) { + throw ValidationException::withMessages(['offline_note' => '报价确认单还没在 e签宝签署完成;如已线下签署,请填写说明后再开通。']); } - $note = $signature !== null - ? '电子协议已签署:' . $signature->business_no - : '线下签署:' . trim((string) $offlineNote); + $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, @@ -264,6 +298,104 @@ class CourierOnboardingService ]); } + 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; + } + /** * 与该快递相关的协议签署任务(按系统快递名称或公司全称匹配),最近的在前。 * @@ -327,8 +459,9 @@ class CourierOnboardingService 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', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle', - 'quote_mode', 'volumetric_rule', 'quote_note', 'remark', + '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); } @@ -337,52 +470,85 @@ class CourierOnboardingService $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['volumetric_divisor'] = $attributes['volumetric_rule'] === '计泡' && is_numeric($data['volumetric_divisor'] ?? null) - ? (int) $data['volumetric_divisor'] - : null; - $attributes['quote_effective_date'] = $text('quote_effective_date'); - $attributes['quote_rows'] = $this->normalizeQuoteRows((array) ($data['quote_rows'] ?? [])); + $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 $rows - * @return list + * 报价相关字段(计费结构、各省价格、附加规则、执行日期),入驻表单和后台变价共用。 + * + * @param array $data + * @return array */ - private function normalizeQuoteRows(array $rows): array + public function quoteAttributes(array $data): array { - $number = static fn ($value): ?float => is_numeric($value) ? round((float) $value, 4) : null; - $result = []; + $text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null; + $structure = CourierQuoteStructure::normalize($data); + $volumetricRule = $text('volumetric_rule'); - foreach ($rows as $row) { - if (! is_array($row)) { - continue; - } - $region = trim((string) ($row['region'] ?? '')); - $values = [ - 'first_weight' => $number($row['first_weight'] ?? null), - 'first_price' => $number($row['first_price'] ?? null), - 'rate' => $number($row['rate'] ?? null), - ]; - if ($region === '' || array_filter($values, static fn ($value): bool => $value !== null) === []) { - continue; - } - $result[] = ['region' => mb_substr($region, 0, 50)] + $values; + 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 $result; + return $payload; } private function storeFile(CourierOnboarding $onboarding, string $kind, UploadedFile $upload): void { - if (! array_key_exists($kind, CourierOnboardingFile::KINDS)) { + if (! array_key_exists($kind, CourierOnboardingFile::allKinds())) { return; } - if ($onboarding->files()->where('kind', $kind)->count() >= self::MAX_FILES_PER_KIND) { - throw ValidationException::withMessages(["files.{$kind}" => CourierOnboardingFile::KINDS[$kind] . '最多上传 ' . self::MAX_FILES_PER_KIND . ' 个文件。']); - } + // 每项资料只保留一张,重新上传即替换。 + $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); @@ -405,7 +571,8 @@ class CourierOnboardingService private function assertCompleteForSubmit(CourierOnboarding $onboarding): void { $errors = []; - foreach (self::REQUIRED_FIELDS as $field => $label) { + $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}。"; } @@ -414,10 +581,30 @@ class CourierOnboardingService $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(); - foreach (CourierOnboardingFile::REQUIRED_KINDS as $kind) { + $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::KINDS[$kind] . '。'; + $errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::labelFor($kind, $onboarding->entity_type) . '。'; } } @@ -426,7 +613,7 @@ class CourierOnboardingService } } - private function assertCourierNameAvailable(CourierOnboarding $onboarding, string $courierName): void + private function assertCourierNameAvailable(?CourierOnboarding $onboarding, string $courierName): void { if ($courierName === '') { throw ValidationException::withMessages(['courier_name' => '请填写系统快递名称。']); @@ -434,7 +621,7 @@ class CourierOnboardingService $taken = CourierOnboarding::query() ->where('courier_name', $courierName) - ->whereKeyNot($onboarding->id) + ->when($onboarding !== null, static fn ($query) => $query->whereKeyNot($onboarding->id)) ->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]) ->exists(); @@ -468,6 +655,7 @@ class CourierOnboardingService '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(), @@ -518,34 +706,32 @@ class CourierOnboardingService * @param list $rows * @return list> */ - private function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array + public function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array { - $volumetric = $onboarding->volumetric_rule === '计泡' - ? '计泡:长*宽*高/' . ($onboarding->volumetric_divisor ?: 6000) - : '不计泡'; + $columns = $onboarding->quoteColumns(); + $width = count($columns) + 1; + $line = static fn (string $text): array => array_pad([$text], $width, null); + $matrix = [ - [$onboarding->courier_name . ' 报价', null, null, null], - ['执行时间:' . $effectiveDate->format('Y.m.d'), '计费方式:' . $onboarding->quoteModeLabel(), $volumetric, null], + $line($onboarding->courier_name . ' 报价'), + $line('执行时间:' . $effectiveDate->format('Y.m.d')), ]; - $matrix[] = match ($onboarding->quote_mode) { - 'first_additional' => ['省份', '首重(kg)', '首重价(元)', '续重(元/kg)'], - 'flat' => ['省份/重量段', '重量(kg)', '价格(元)', '续重(元/kg)'], - default => ['省份', '首重(kg)', '面单(元)', '费率(元/kg)'], - }; - + $matrix[] = ['省份', ...array_column($columns, 'label')]; foreach ($rows as $row) { - $matrix[] = [$row['region'], $row['first_weight'], $row['first_price'], $row['rate']]; + $matrix[] = [$row['region'], ...CourierQuoteStructure::rowValues($row, $columns)]; } - $notes = array_filter([ - filled($onboarding->quote_note) ? '备注:' . $onboarding->quote_note : null, - '适用快递:' . $onboarding->courier_name, - ]); - foreach ($notes as $line) { - $matrix[] = [$line, null, null, null]; + // 规则说明放在价格表下方,避免报价确认单抬头的「面单费」误读说明文字。 + 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; } 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/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/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index e5f02725..5edcaa50 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -149,25 +149,30 @@ class SidebarPermissions } unset($section); - // 「财务经营风险管控」新增「流程统计」子页面:按页面键保存过精确权限的账号只会 - // 有 store-violation-alert,会看不到新页面;这里按「能看店铺违规预警 就能看流程统计」 - // 兼容旧配置(两页同属财务经营风险管控权限)。 + // 「财务经营风险管控」新增「预览统计 / 流程统计」子页面:按页面键保存过精确权限的 + // 账号只会有一条或两条,会看不到新页面;这里按「能看店铺违规预警 就能看新增页」 + // 兼容旧配置(同属财务经营风险管控权限)。 foreach ($navigation as &$riskSection) { if (($riskSection['slug'] ?? null) !== 'financial-risk-control') { continue; } $pageSlugs = array_column($riskSection['pages'] ?? [], 'slug'); - if (! in_array('store-violation-alert', $pageSlugs, true) || in_array('process-statistics', $pageSlugs, true)) { + if (! in_array('store-violation-alert', $pageSlugs, true)) { continue; } $configuredPages = collect(BusinessNavigation::all()) ->firstWhere('slug', 'financial-risk-control')['pages'] ?? []; - $statisticsPage = collect($configuredPages)->firstWhere('slug', 'process-statistics'); - if ($statisticsPage) { - $riskSection['pages'][] = $statisticsPage; + foreach ($configuredPages as $configuredPage) { + $configuredSlug = (string) ($configuredPage['slug'] ?? ''); + if (! in_array($configuredSlug, ['preview-statistics', 'process-statistics'], true)) { + continue; + } + if (! in_array($configuredSlug, $pageSlugs, true)) { + $riskSection['pages'][] = $configuredPage; + } } } unset($riskSection); diff --git a/config/business_navigation.php b/config/business_navigation.php index 694fc90a..0095ec02 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -714,7 +714,6 @@ $navigation = [ 'title' => '附加费报价维护', 'tabs' => [ ['slug' => 'quotes', 'title' => '附加费报价'], - ['slug' => 'settlement', 'title' => '附加费结算'], ], ], ], @@ -723,7 +722,7 @@ $navigation = [ ['slug' => 'shipping-data', 'title' => '发货数据'], ['slug' => 'return-pickup-management', 'title' => '退货取件管理'], ['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'], - ['slug' => 'warehouse-freight', 'title' => '仓储运费'], + ['slug' => 'warehouse-freight', 'title' => '仓储费'], ['slug' => 'bookkeeping-details', 'title' => '记账明细'], ['slug' => 'compensation-details', 'title' => '赔偿明细'], ['slug' => 'advance-details', 'title' => '借支明细'], @@ -808,7 +807,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], @@ -823,8 +822,8 @@ $navigation = [ ['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'], ['name' => 'paid_amount', 'label' => '订单实付金额', 'kind' => 'number'], ['name' => 'difference', 'label' => '差额', 'kind' => 'number'], - ['name' => 'match_status', 'label' => '匹配状态'], ['name' => 'remark', 'label' => '差额异常备注'], + ['name' => 'match_status', 'label' => '匹配状态'], ['name' => 'unmatched_reason', 'label' => '未匹配原因'], ], ], @@ -873,6 +872,7 @@ $navigation = [ 'icon' => 'security', 'pages' => [ ['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'], + ['slug' => 'preview-statistics', 'title' => '预览统计'], [ 'slug' => 'process-statistics', '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/config/store_violations.php b/config/store_violations.php index 1618e164..b15153a6 100644 --- a/config/store_violations.php +++ b/config/store_violations.php @@ -12,17 +12,26 @@ return [ // 统一表头(顺序即页面、导出、导入模板的列顺序) + // auto=自动计算列(导入忽略、导出可见);virtual=查询时计算;system=系统回写(导入忽略)。 'columns' => [ ['key' => 'shop_name', 'label' => '店铺名'], + ['key' => 'alert_level', 'label' => '预警级别', 'auto' => true], ['key' => 'violation_type', 'label' => '违规类型'], ['key' => 'violation_category', 'label' => '违规分类'], + ['key' => 'violation_reason', 'label' => '违规原因'], + ['key' => 'disposition', 'label' => '处置方式'], ['key' => 'violation_no', 'label' => '违规单号'], ['key' => 'violation_date', 'label' => '违规日期'], ['key' => 'points_deducted', 'label' => '扣分'], + ['key' => 'owner_name', 'label' => '责任人', 'auto' => true], ['key' => 'status', 'label' => '状态'], ['key' => 'penalty_date', 'label' => '处罚日期'], ['key' => 'appeal_deadline', 'label' => '申诉截止日期'], + ['key' => 'handling_deadline', 'label' => '处理截止', 'auto' => true], + ['key' => 'is_overdue', 'label' => '是否超时', 'virtual' => true], + ['key' => 'push_status', 'label' => '推送状态', 'system' => true], ['key' => 'handling_status', 'label' => '处理状态'], + ['key' => 'ignore_reason', 'label' => '忽略原因'], ], // 页面平台页签;source_values 为上游/导入文件中平台字段的取值 @@ -36,14 +45,51 @@ return [ ], // 处理状态(本地维护) - 'handling_statuses' => ['未处理', '处理中', '已处理', '无需处理'], + 'handling_statuses' => ['未处理', '处理中', '处理完成', '已忽略'], 'default_handling_status' => '未处理', + // 历史取值兼容:导入时归一化到新四档 + 'handling_status_legacy' => ['已处理' => '处理完成', '无需处理' => '已忽略'], + + // 预警级别(页面标签颜色)与处理时效默认(违规日期起算的天数,当天 23:59:59 到期) + 'alert_levels' => [ + 'P0' => ['label' => 'P0-严重', 'tone' => 'is-danger'], + 'P1' => ['label' => 'P1-较重', 'tone' => 'is-warning'], + 'P2' => ['label' => 'P2-提醒', 'tone' => 'is-success'], + ], + 'alert_default_days' => ['P0' => 1, 'P1' => 2, 'P2' => 3], + + // 推送状态 + 'push_statuses' => ['未推送', '已推送', '推送失败'], + + // 默认视图:只展示需提醒且未处理的记录,其余数据只入库不打扰 + 'default_view' => ['needs_alert' => true, 'handling_status' => '未处理'], + + // 群机器人推送(先留接口,配置后启用;窗口从每天 09:00 开始) + 'push' => [ + 'enabled' => false, + 'channel' => 'dingtalk_robot', + 'webhook' => '', + 'secret' => '', + 'window_start' => '09:00', + 'window_end' => '21:00', + 'instant_levels' => ['P0', 'P1'], + 'digest_time' => '10:00', + 'at_all' => false, + ], // 导入表头别名 → 统一列键(不区分大小写、忽略空格) 'header_aliases' => [ 'shop_name' => ['店铺名', '店铺名称', '店铺', '商家名称', '商家', 'shop_name', 'shopname'], - 'violation_type' => ['违规类型', '违规内容', '违规行为', '违规原因', '违规描述', 'violation_type'], + 'violation_type' => ['违规类型', '违规内容', '违规行为', '违规描述', 'violation_type'], 'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'], + 'violation_reason' => ['违规原因', '违规说明', '违规详情', 'violation_reason', 'reason'], + 'disposition' => ['处置方式', '处置措施', '处置结果', '处理方式', 'disposition', 'handling_method'], + 'alert_level' => ['预警级别', '预警等级', 'alert_level'], + 'owner_name' => ['责任人', '店铺负责人', '运营负责人', 'owner_name'], + 'handling_deadline' => ['处理截止', '处理截止时间', '处理时限', 'handling_deadline'], + 'is_overdue' => ['是否超时', '是否逾期', 'is_overdue'], + 'push_status' => ['推送状态', 'push_status'], + 'ignore_reason' => ['忽略原因', '忽略理由', 'ignore_reason'], 'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'], 'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'], 'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'], 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 index 8a559c13..a7db0868 100644 --- 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 @@ -18,7 +18,9 @@ return new class extends Migration $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(); @@ -31,6 +33,8 @@ return new class extends Migration $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(); @@ -45,7 +49,11 @@ return new class extends Migration $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(); 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_120000_add_online_order_index_to_dewu_bill_rows.php b/database/migrations/2026_10_13_120000_add_online_order_index_to_dewu_bill_rows.php new file mode 100644 index 00000000..95ee114f --- /dev/null +++ b/database/migrations/2026_10_13_120000_add_online_order_index_to_dewu_bill_rows.php @@ -0,0 +1,54 @@ +connection()); + if (! $schema->hasTable('dewu_settlement_bill_rows') || $this->hasIndex()) { + return; + } + + $schema->table('dewu_settlement_bill_rows', function (Blueprint $table): void { + $table->index(['online_order_no', 'bill_item'], self::INDEX); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('dewu_settlement_bill_rows') || ! $this->hasIndex()) { + return; + } + + $schema->table('dewu_settlement_bill_rows', function (Blueprint $table): void { + $table->dropIndex(self::INDEX); + }); + } + + private function hasIndex(): bool + { + return (bool) DB::connection($this->connection())->selectOne( + "SELECT COUNT(*) AS c FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?", + ['dewu_settlement_bill_rows', self::INDEX], + )->c; + } +}; diff --git a/database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php b/database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php new file mode 100644 index 00000000..05ed9fc9 --- /dev/null +++ b/database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php @@ -0,0 +1,59 @@ +connection()); + + if (! $schema->hasTable('store_violation_records')) { + return; + } + + $missing = []; + foreach (['violation_reason', 'disposition'] as $column) { + if (! $schema->hasColumn('store_violation_records', $column)) { + $missing[] = $column; + } + } + + if ($missing === []) { + return; + } + + $schema->table('store_violation_records', function (Blueprint $table) use ($missing): void { + if (in_array('violation_reason', $missing, true)) { + $table->string('violation_reason', 500)->nullable()->after('violation_category'); + } + if (in_array('disposition', $missing, true)) { + $table->string('disposition', 500)->nullable()->after('violation_reason'); + } + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('store_violation_records')) { + return; + } + + foreach (['disposition', 'violation_reason'] as $column) { + if ($schema->hasColumn('store_violation_records', $column)) { + $schema->table('store_violation_records', function (Blueprint $table) use ($column): void { + $table->dropColumn($column); + }); + } + } + } +}; diff --git a/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php b/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php new file mode 100644 index 00000000..d6125d21 --- /dev/null +++ b/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php @@ -0,0 +1,104 @@ +connection()); + $table = 'store_violation_records'; + + if (! $schema->hasTable($table)) { + return; + } + + $columns = [ + 'owner_name' => fn (Blueprint $t) => $t->string('owner_name', 64)->nullable()->after('shop_name'), + 'alert_level' => fn (Blueprint $t) => $t->string('alert_level', 8)->nullable()->after('violation_category'), + 'needs_alert' => fn (Blueprint $t) => $t->boolean('needs_alert')->default(false)->after('alert_level'), + 'alert_rule_id' => fn (Blueprint $t) => $t->unsignedBigInteger('alert_rule_id')->nullable()->after('needs_alert'), + 'alert_action' => fn (Blueprint $t) => $t->string('alert_action', 500)->nullable(), + 'alert_channel' => fn (Blueprint $t) => $t->string('alert_channel', 64)->nullable(), + 'handling_hours' => fn (Blueprint $t) => $t->unsignedInteger('handling_hours')->nullable(), + 'handling_deadline' => fn (Blueprint $t) => $t->dateTime('handling_deadline')->nullable()->after('appeal_deadline'), + 'ignore_reason' => fn (Blueprint $t) => $t->string('ignore_reason', 191)->nullable()->after('handling_status'), + 'push_status' => fn (Blueprint $t) => $t->string('push_status', 16)->default('未推送'), + 'last_pushed_at' => fn (Blueprint $t) => $t->dateTime('last_pushed_at')->nullable(), + ]; + + $missing = []; + foreach ($columns as $column => $definition) { + if (! $schema->hasColumn($table, $column)) { + $missing[$column] = $definition; + } + } + + if ($missing !== []) { + $schema->table($table, function (Blueprint $blueprint) use ($missing): void { + foreach ($missing as $definition) { + $definition($blueprint); + } + }); + } + + $indexes = [ + 'store_violations_platform_alert_status_index' => ['platform', 'needs_alert', 'handling_status'], + 'store_violations_alert_deadline_index' => ['needs_alert', 'handling_status', 'handling_deadline'], + 'store_violations_platform_owner_index' => ['platform', 'owner_name'], + 'store_violations_push_level_index' => ['push_status', 'alert_level'], + ]; + + foreach ($indexes as $name => $indexColumns) { + if (! $schema->hasIndex($table, $name)) { + $schema->table($table, function (Blueprint $blueprint) use ($indexColumns, $name): void { + $blueprint->index($indexColumns, $name); + }); + } + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $table = 'store_violation_records'; + + if (! $schema->hasTable($table)) { + return; + } + + foreach ([ + 'store_violations_platform_alert_status_index', + 'store_violations_alert_deadline_index', + 'store_violations_platform_owner_index', + 'store_violations_push_level_index', + ] as $name) { + if ($schema->hasIndex($table, $name)) { + $schema->table($table, function (Blueprint $blueprint) use ($name): void { + $blueprint->dropIndex($name); + }); + } + } + + foreach ([ + 'owner_name', 'alert_level', 'needs_alert', 'alert_rule_id', 'alert_action', 'alert_channel', + 'handling_hours', 'handling_deadline', 'ignore_reason', 'push_status', 'last_pushed_at', + ] as $column) { + if ($schema->hasColumn($table, $column)) { + $schema->table($table, function (Blueprint $blueprint) use ($column): void { + $blueprint->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_13_200100_create_store_violation_rules_table.php b/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php new file mode 100644 index 00000000..ab5b7fde --- /dev/null +++ b/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php @@ -0,0 +1,49 @@ +connection()); + + if ($schema->hasTable('store_violation_rules')) { + return; + } + + $schema->create('store_violation_rules', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('platform', 32)->comment('平台 slug:taobao/alibaba/jd/jingxi/pinduoduo/douyin'); + $table->string('violation_category', 64)->nullable()->comment('字典分类,命中后可用于校准记录分类'); + $table->string('violation_type', 500)->nullable()->comment('匹配用类型:清洗后相等或前缀匹配'); + $table->string('match_keywords', 500)->nullable()->comment('补充关键词,|、/、顿号分隔,包含任一即命中'); + $table->string('alert_level', 8)->nullable()->comment('P0/P1/P2;为空时命中后按 P2 处理'); + $table->boolean('needs_alert')->default(true)->comment('是否需要提醒;否=只入库不推送'); + $table->string('suggested_action', 500)->nullable()->comment('建议处理动作'); + $table->unsignedInteger('handling_hours')->nullable()->comment('处理时效(小时);为空时按级别默认'); + $table->string('remind_channel', 191)->nullable()->comment('提醒方式'); + $table->unsignedInteger('sort')->default(0)->comment('匹配顺序,越小越先匹配'); + $table->boolean('enabled')->default(true); + $table->string('remark', 191)->nullable(); + $table->timestamps(); + + $table->index(['platform', 'enabled', 'sort'], 'store_violation_rules_match_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('store_violation_rules'); + } +}; diff --git a/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php b/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php new file mode 100644 index 00000000..4f1f5b16 --- /dev/null +++ b/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php @@ -0,0 +1,48 @@ +connection()); + + if ($schema->hasTable('store_violation_push_logs')) { + return; + } + + $schema->create('store_violation_push_logs', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('push_no', 32)->unique('store_violation_push_logs_no_unique'); + $table->dateTime('pushed_at')->nullable(); + $table->string('channel', 32)->default('dingtalk_robot'); + $table->string('message_type', 16)->default('instant')->comment('instant/daily'); + $table->string('platform', 32)->nullable(); + $table->string('violation_no', 100)->nullable(); + $table->string('message_digest', 500)->nullable(); + $table->string('mentioned', 191)->nullable(); + $table->string('result', 16)->default('success')->comment('success/failed'); + $table->string('error', 500)->nullable(); + $table->timestamps(); + + $table->index(['violation_no', 'message_type'], 'store_violation_push_logs_no_type_index'); + $table->index('pushed_at', 'store_violation_push_logs_pushed_at_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('store_violation_push_logs'); + } +}; 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/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 1f26c98c..abe0128c 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -197,6 +197,11 @@ CRON_TZ=Asia/Shanghai 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 +30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 +# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。 +*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1 +0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 @@ -204,6 +209,10 @@ CRON_TZ=Asia/Shanghai # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 # 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。 30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1 +# 附加费结算:每天滚动重算最近 7 天;每周一再把最近 90 天全量重算一次, +# 补齐晚到的退货进仓与审批变化(超长费按出库日期、退回费/拦截费按查件工单创建时间)。 +30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet' >/dev/null 2>&1 +40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet' >/dev/null 2>&1 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index b6bd03a5..fc040f90 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -201,6 +201,11 @@ CRON_TZ=Asia/Shanghai 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 +30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 +# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。 +*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1 +0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 @@ -208,6 +213,10 @@ CRON_TZ=Asia/Shanghai # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 # 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。 30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1 +# 附加费结算:每天滚动重算最近 7 天;每周一再把最近 90 天全量重算一次, +# 补齐晚到的退货进仓与审批变化(超长费按出库日期、退回费/拦截费按查件工单创建时间)。 +30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet' >/dev/null 2>&1 +40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet' >/dev/null 2>&1 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots 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/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php index 3d0bbb17..0617e04f 100644 --- a/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php @@ -98,6 +98,57 @@ class DingPayableWorkflowMirrorRepository ]; } + /** + * 校验某个审批实例里是否存在该附件,并返回附件元数据(下载接口使用, + * 防止拿任意 fileId 越权取钉盘文件)。 + * + * @return array{fileName:string,fileType:string,fileSize:int}|null + */ + public function attachment(string $processInstanceId, string $fileId): ?array + { + if ($processInstanceId === '' || $fileId === '') { + return null; + } + + $record = $this->connection->table('ding_payable_workflow_mirror') + ->where('process_instance_id', $processInstanceId) + ->first(); + + if ($record === null) { + return null; + } + + $scheme = $this->decode((string) $record->scheme_payload, []); + $forms = is_array($scheme) ? $this->formFields($scheme) : []; + $files = []; + + foreach (['附件', '未签字版合同附件或其他明细附件'] as $key) { + $value = $forms[$key] ?? null; + if (! is_array($value)) { + continue; + } + foreach ($value as $file) { + if (is_array($file)) { + $files[] = $file; + } + } + } + + foreach ($files as $file) { + if ((string) ($file['fileId'] ?? '') !== $fileId) { + continue; + } + + return [ + 'fileName' => trim((string) ($file['fileName'] ?? '')) ?: $fileId, + 'fileType' => trim((string) ($file['fileType'] ?? '')), + 'fileSize' => (int) ($file['fileSize'] ?? 0), + ]; + } + + return null; + } + /** @param array $workflow @param array $filters */ private function matchesWorkflowFilters(array $workflow, array $filters): bool { diff --git a/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php new file mode 100644 index 00000000..d6f4b6b8 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php @@ -0,0 +1,128 @@ +connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + /** + * 即时推送候选:需提醒 + 未处理 + 未推送 + 指定级别。 + * + * @param list $levels + * @return Collection + */ + public function instantCandidates(array $levels, bool $retryFailed, int $limit): Collection + { + return $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('needs_alert', true) + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->whereIn('alert_level', $levels) + ->where(function ($query) use ($retryFailed): void { + $query->where('push_status', '未推送'); + if ($retryFailed) { + $query->orWhere('push_status', '推送失败'); + } + }) + ->orderByRaw("CASE alert_level WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 ELSE 2 END") + ->orderBy('id') + ->limit(max($limit, 1)) + ->get(); + } + + /** + * 每日汇总取数:各级别待处理数量与超时未认领记录。 + * + * @return array{pending:array,overdue:Collection,p2_pending:Collection} + */ + public function digestData(int $overdueLimit = 20): array + { + $defaultStatus = (string) config('store_violations.default_handling_status', '未处理'); + + $pending = []; + foreach ($this->connection->table(StoreViolationRecordRepository::TABLE) + ->selectRaw('alert_level, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('handling_status', $defaultStatus) + ->groupBy('alert_level') + ->get() as $row) { + $pending[(string) $row->alert_level] = (int) $row->aggregate; + } + + $overdue = $this->connection->table(StoreViolationRecordRepository::TABLE) + ->whereIn('alert_level', array_keys((array) config('store_violations.alert_levels', []))) + ->whereNotIn('handling_status', StoreViolationRecordRepository::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', Carbon::now()) + ->where('needs_alert', true) + ->orderBy('handling_deadline') + ->limit(max($overdueLimit, 1)) + ->get(['id', 'platform', 'shop_name', 'owner_name', 'alert_level', 'violation_type', 'handling_deadline']); + + $p2 = $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('needs_alert', true) + ->where('alert_level', 'P2') + ->where('handling_status', $defaultStatus) + ->where('push_status', '未推送') + ->orderBy('id') + ->get(['id']); + + return ['pending' => $pending, 'overdue' => $overdue, 'p2_pending' => $p2]; + } + + /** @param list $ids */ + public function markPushed(array $ids, Carbon $at): void + { + if ($ids === []) { + return; + } + + foreach (array_chunk($ids, 500) as $chunk) { + $this->connection->table(StoreViolationRecordRepository::TABLE) + ->whereIn('id', $chunk) + ->update(['push_status' => '已推送', 'last_pushed_at' => $at, 'updated_at' => $at]); + } + } + + public function markPushFailed(int $id, Carbon $at): void + { + $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('id', $id) + ->update(['push_status' => '推送失败', 'last_pushed_at' => $at, 'updated_at' => $at]); + } + + /** @param array $entry */ + public function log(array $entry): void + { + if (! $this->available()) { + return; + } + + $now = now(); + $this->connection->table(self::LOG_TABLE)->insert($entry + [ + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function available(): bool + { + return \Illuminate\Support\Facades\Schema::connection($this->connection->getName()) + ->hasTable(self::LOG_TABLE); + } +} diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php index 987285ba..c11c7dd0 100644 --- a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php @@ -36,6 +36,21 @@ class StoreViolationRecordRepository 'updated_at', ]; + /** 预警派生字段(同步/导入/重算时自动计算;本地工作流字段不受影响) */ + public const ALERT_DERIVED_COLUMNS = [ + 'needs_alert', + 'alert_level', + 'alert_rule_id', + 'alert_action', + 'alert_channel', + 'handling_hours', + 'owner_name', + 'handling_deadline', + ]; + + /** 视为已终结的处理状态(不再计入超时) */ + public const FINAL_HANDLING_STATUSES = ['处理完成', '已忽略', '已处理', '无需处理']; + private Connection $connection; public function __construct() @@ -68,6 +83,25 @@ class StoreViolationRecordRepository return $this->available() ? (int) $this->connection->table(self::TABLE)->count() : 0; } + public function findById(int $id): ?object + { + if (! $this->available() || $id <= 0) { + return null; + } + + return $this->connection->table(self::TABLE)->where('id', $id)->first(); + } + + /** @param array $fields */ + public function updateFields(int $id, array $fields): void + { + if (! $this->available() || $fields === []) { + return; + } + + $this->connection->table(self::TABLE)->where('id', $id)->update($fields); + } + /** @return array{code:string,last_succeeded_at:?string,row_count:int,last_error:?string} */ public function syncStatus(): array { @@ -152,14 +186,42 @@ class StoreViolationRecordRepository $query->whereDate('violation_date', '<=', $filters['date_to']); } + if (($filters['needs_alert'] ?? null) !== null && $filters['needs_alert'] !== '') { + $query->where('needs_alert', (bool) $filters['needs_alert']); + } + + $level = trim((string) ($filters['level'] ?? '')); + if ($level !== '') { + $query->where('alert_level', $level); + } + + $owner = trim((string) ($filters['owner'] ?? '')); + if ($owner !== '') { + $query->where('owner_name', $owner); + } + + if (! empty($filters['overdue'])) { + $query->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->where('needs_alert', true) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', now()); + } + return $query; } - /** @return array{statuses:list,handling_statuses:list,categories:list,shops:list} */ + /** @return array{statuses:list,handling_statuses:list,categories:list,shops:list,owners:list,levels:list} */ public function filterOptions(string $platform): array { if (! $this->available()) { - return ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []]; + return [ + 'statuses' => [], + 'handling_statuses' => [], + 'categories' => [], + 'shops' => [], + 'owners' => [], + 'levels' => [], + ]; } $distinct = fn (string $column): array => $this->connection->table(self::TABLE) @@ -176,6 +238,17 @@ class StoreViolationRecordRepository 'statuses' => $distinct('status'), 'handling_statuses' => $distinct('handling_status'), 'categories' => $distinct('violation_category'), + 'owners' => $this->connection->table(self::TABLE) + ->where('platform', $platform) + ->whereNotNull('owner_name') + ->where('owner_name', '<>', '') + ->orderBy('owner_name') + ->distinct() + ->limit(500) + ->pluck('owner_name') + ->map(static fn (mixed $value): string => (string) $value) + ->all(), + 'levels' => array_keys((array) config('store_violations.alert_levels', [])), 'shops' => $this->connection->table(self::TABLE) ->where('platform', $platform) ->where('shop_name', '<>', '') @@ -188,6 +261,71 @@ class StoreViolationRecordRepository ]; } + /** + * 预览统计:各平台 4 个指标(今日新增 / 待处理 / P0未处理 / 已超时)。 + * + * @return array + */ + public function statsByPlatform(): array + { + $result = []; + if (! $this->available()) { + return $result; + } + + $ensure = static function (array &$result, string $platform): void { + $result[$platform] ??= ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0]; + }; + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('created_at', '>=', now()->startOfDay()) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['new_today'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['pending'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('alert_level', 'P0') + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['p0_pending'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', now()) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['overdue'] = (int) $row->aggregate; + } + + return $result; + } + /** * 统一表头导出用的行数据(流式读取,避免一次性载入)。 * diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php new file mode 100644 index 00000000..c80d952f --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php @@ -0,0 +1,58 @@ +connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + public function available(): bool + { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } + + /** @return list> 启用中的规则,按 sort、id 升序 */ + public function activeRules(): array + { + if (! $this->available()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->where('enabled', true) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + } + + /** @return list> 全部规则(后台维护用),按 sort、id 升序 */ + public function all(): array + { + if (! $this->available()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + } +} diff --git a/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php b/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php new file mode 100644 index 00000000..073e64cc --- /dev/null +++ b/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php @@ -0,0 +1,71 @@ +accessToken(); + + if ($token === null) { + return null; + } + + $response = Http::withHeaders(['x-acs-dingtalk-access-token' => $token]) + ->timeout(20) + ->post(self::GRANT_DOWNLOAD_URL, [ + 'processInstanceId' => $processInstanceId, + 'fileId' => $fileId, + ]); + + if (! $response->successful()) { + report(new \RuntimeException( + 'dingtalk approval attachment grant failed: HTTP ' . $response->status() + )); + + return null; + } + + $uri = (string) $response->json('result.downloadUri'); + + return $uri !== '' ? $uri : null; + } + + private function accessToken(): ?string + { + $clientId = (string) config('dingtalk.client_id'); + $secret = (string) config('dingtalk.client_secret'); + + if ($clientId === '' || $secret === '') { + return null; + } + + $token = Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(100), function () use ($clientId, $secret): ?string { + $response = Http::timeout(15)->get(self::GET_TOKEN_URL, [ + 'appkey' => $clientId, + 'appsecret' => $secret, + ]); + + $token = (string) $response->json('access_token'); + + return $token !== '' ? $token : null; + }); + + return is_string($token) && $token !== '' ? $token : null; + } +} diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index b93884eb..8ae617f3 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -43,6 +43,10 @@ class ShippingReplicaSynchronizer 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no', ]; private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone']; + private const REFRESHED_PACKAGE_FIELDS = [ + 'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time', + 'receiver_state', 'receiver_city', 'receiver_district', + ]; private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier']; private const VIEW = 'rpt.Jst_SendData_View_extension'; @@ -68,6 +72,7 @@ class ShippingReplicaSynchronizer private int $packagesRetainedForMissingItems = 0; private int $packagesRetainedForUnmatchedAmount = 0; private int $staleMirrorPackages = 0; + private int $supersededRemoved = 0; /** @var array */ private array $seenSourceIds = []; @@ -135,6 +140,12 @@ class ShippingReplicaSynchronizer /** @var array 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */ private array $ioShop = []; + + /** @var array 最近一次 loadViewItems 的 io_id => 视图快递名称 expressName(工作平台显示的快递名称) */ + private array $ioExpress = []; + + /** @var array 最近一次 loadViewItems 的 io_id => 视图发货仓 wareHoursName(工作平台显示的仓库) */ + private array $ioWare = []; /** @var array 已查询订单元数据缓存 o_id=>字段 */ private array $orderMetadata = []; @@ -168,6 +179,7 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; + $this->supersededRemoved = 0; $this->seenSourceIds = []; $this->expressOrderIds = []; $this->dwsFailedDays = []; @@ -255,6 +267,7 @@ class ShippingReplicaSynchronizer $result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount; $result['dws_failed_days'] = $this->dwsFailedDays; $result['stale_mirror_packages'] = $this->staleMirrorPackages; + $result['superseded_packages_removed'] = $this->supersededRemoved; $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; } if ($skipFreight) { @@ -489,6 +502,7 @@ class ShippingReplicaSynchronizer ?? $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null); $pkg = $this->pkgFromExpress($r, $tracking, $shipTime); $pkg = $this->withOutboundCarrier($io, $r, $pkg, $pkgId); + $pkg = $this->withViewCarrier($io, $pkg, $pkgId); $pkg['platform'] = $this->platformOf($io, $pkg['platform']); $pkg = $this->withViewAddr($io, $pkg); $total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now); @@ -705,6 +719,7 @@ class ShippingReplicaSynchronizer $pkg['platform'] = $this->platformOf($io, $pkg['platform']); $pkg = $this->withViewAddr($io, $pkg); $pkgId = self::AUTOSHIP_OFFSET + (int) $oid; + $pkg = $this->withViewCarrier($io, $pkg, $pkgId); $total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true); } unset($rows, $byOid, $bySo, $dws, $skus, $freight); @@ -900,8 +915,11 @@ class ShippingReplicaSynchronizer } $pkg = $this->pkgFromOutbound($r, $tracking !== '' ? $tracking : null, $this->ts($r['io_date'] ?? null), $h, $so); $pkg = $this->withViewAddr($io, $pkg); + $pkg = $this->withViewCarrier($io, $pkg, self::OUTBOUND_OFFSET + (int) $io); if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) { - $pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE; + if (empty($this->ioWare[$io])) { + $pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE; + } $virtualEmitted++; } $total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking !== '' ? $tracking : null, $now, true); @@ -1030,7 +1048,7 @@ class ShippingReplicaSynchronizer private function loadViewHeads(array $ioIds): array { $out = []; - foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) { + foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) { if ($chunk === []) { continue; } @@ -1260,6 +1278,26 @@ class ShippingReplicaSynchronizer return $pkg; } + /** + * 工作平台的快递名称/仓库即发货数据视图的 expressName / wareHoursName(抽样 14/14 一致): + * 视图有值时以视图为准;视图无该出库单时保留出库单/发货明细推断的值。 + */ + private function withViewCarrier(string $io, array $pkg, int $pkgId): array + { + if ($io === '') { + return $pkg; + } + if (! empty($this->ioExpress[$io])) { + $pkg['express_name1'] = $this->ioExpress[$io]; + } + if (! empty($this->ioWare[$io])) { + $pkg['warehouse_name'] = $this->ioWare[$io]; + unset($this->pendingWarehouse[$pkgId]); + } + + return $pkg; + } + private function wmsName(string $wms): ?string { if (empty($this->wmsNames[$wms])) { @@ -1723,12 +1761,15 @@ class ShippingReplicaSynchronizer $this->ioSite = []; $this->ioAddr = []; $this->ioShop = []; - foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) { + $this->ioExpress = []; + $this->ioWare = []; + // 发货数据视图是计算视图,IN 列表过长时副本库容易超时:按 300 个出库单一批 + foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); - $sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM ' + $sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, expressName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM ' . self::VIEW . " WHERE io_id IN ({$ph})"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $io = (string) ($r['io_id'] ?? ''); @@ -1738,6 +1779,12 @@ class ShippingReplicaSynchronizer if ($io !== '' && ! isset($this->ioShop[$io]) && trim((string) ($r['shop_name'] ?? '')) !== '') { $this->ioShop[$io] = trim((string) $r['shop_name']); } + if ($io !== '' && ! isset($this->ioExpress[$io]) && trim((string) ($r['expressName'] ?? '')) !== '') { + $this->ioExpress[$io] = trim((string) $r['expressName']); + } + if ($io !== '' && ! isset($this->ioWare[$io]) && trim((string) ($r['wareHoursName'] ?? '')) !== '') { + $this->ioWare[$io] = trim((string) $r['wareHoursName']); + } if ($io !== '') { foreach (['st' => 'receiver_state', 'ci' => 'receiver_city', 'di' => 'receiver_district'] as $ak => $ac) { $av = trim((string) ($r[$ac] ?? '')); @@ -2137,9 +2184,11 @@ class ShippingReplicaSynchronizer $rows = []; $replaceIds = []; + $refresh = []; foreach ($packages as $id => $packageRows) { if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) { $this->packagesRetainedForMissingItems++; + $refresh[$id] = $packageRows[0] ?? []; continue; } $unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? []; @@ -2176,6 +2225,7 @@ class ShippingReplicaSynchronizer } if ($unsafeAmount) { $this->packagesRetainedForUnmatchedAmount++; + $refresh[$id] = $packageRows[0] ?? []; continue; } $replaceIds[] = $id; @@ -2205,6 +2255,7 @@ class ShippingReplicaSynchronizer } array_push($rows, ...$packageRows); } + $this->refreshRetainedPackages($refresh); if ($replaceIds === []) { return; } @@ -2307,21 +2358,55 @@ class ShippingReplicaSynchronizer return $out; } + /** + * 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录 + * (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白; + * 其余仍保留并计数,需按源键核对。 + */ private function countUnseenMirrorPackages(string $fromS, string $toS): int { $unseen = 0; - $ids = $this->db->table('jst_oms_shipping') + $superseded = []; + $rows = $this->db->table('jst_oms_shipping') ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) - ->distinct()->pluck('source_id'); - foreach ($ids as $id) { - if (! isset($this->seenSourceIds[(int) $id])) { - $unseen++; + ->selectRaw('source_id, MAX(express_no) AS express_no')->groupBy('source_id')->get(); + foreach ($rows as $row) { + $id = (int) $row->source_id; + if (isset($this->seenSourceIds[$id])) { + continue; } + $tk = ltrim(trim((string) ($row->express_no ?? '')), '@'); + if ($tk !== '' && isset($this->emittedTrack[$tk])) { + $superseded[] = $id; + continue; + } + $unseen++; } + foreach (array_chunk($superseded, 500) as $chunk) { + $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->delete(); + } + $this->supersededRemoved = count($superseded); return $unseen; } + /** 因商品/金额无法安全替换而保留的旧包裹:包裹级字段(省市区、快递、仓库、店铺等)仍按本次上游刷新,商品与金额不动。 */ + private function refreshRetainedPackages(array $refresh): void + { + foreach ($refresh as $id => $new) { + $set = []; + foreach (self::REFRESHED_PACKAGE_FIELDS as $field) { + $v = $new[$field] ?? null; + if ($v !== null && trim((string) $v) !== '') { + $set[$field] = $v; + } + } + if ($set !== []) { + $this->db->table('jst_oms_shipping')->where('source_id', (int) $id)->update($set); + } + } + } + private function withPreservedFreight(array $payload, array $previous): array { $payload = $this->withPreservedCharges($payload, $previous); 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 da8c6dc2..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 -
+
@@ -329,7 +372,6 @@ 'networks' => ['icon' => 'storefront', 'label' => '网点'], 'accounts' => ['icon' => 'account_balance', 'label' => '打款信息'], 'logs' => ['icon' => 'history', 'label' => '操作日志'], - 'onboarded' => ['icon' => 'how_to_reg', 'label' => '入驻快递'], ] as $key => $item) @@ -342,11 +384,11 @@
- +
- +
@@ -381,29 +423,23 @@ 导出 重置 + @if ($selectedRow) + 编辑 + @else + + @endif
- @elseif ($tab === 'onboarded') -
- -
- - -
-
- - 快递入驻 -
-
- @elseif (in_array($tab, ['networks', 'accounts'], true)) -
+ @else +
- +
+ 重置
@endif @@ -420,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')
@@ -448,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
- @elseif ($tab === 'onboarded') -
-
- - - - @forelse ($rows ?? [] as $row) - - - - - - - - - - - - @empty - - @endforelse - -
入驻单号系统快递名称公司全称网点品牌业务类型对接人对接电话对账人收款户名开户行结算周期开票计泡开通时间
{{ $row->number }}{{ $row->courier_name ?: '-' }}{{ $row->company_full_name ?: '-' }}{{ $row->network_name ?: '-' }}{{ $row->carrier_brand ?: '-' }}{{ $row->service_type ?: '-' }}{{ $row->contact_name ?: '-' }}{{ $row->contact_phone ?: '-' }}{{ $row->reconciler ?: '-' }}{{ $row->bank_account_name ?: '-' }}{{ $row->bank_name ?: '-' }}{{ $row->settlement_cycle ?: '-' }}{{ $row->invoice_type ?: '-' }}{{ $row->tax_rate !== null ? ' ' . rtrim(rtrim(number_format($row->tax_rate, 2), '0'), '.') . '%' : '' }}{{ $row->volumetric_rule === '计泡' ? '计泡 /' . $row->volumetric_divisor : ($row->volumetric_rule ?: '-') }}{{ $row->activated_at?->format('Y-m-d H:i') ?? '-' }}
还没有通过扫码入驻开通的快递。
-
- @if ($rows) - - @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 index 5ad07bfc..645c67b4 100644 --- a/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php +++ b/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php @@ -11,14 +11,9 @@ }; $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'), '.'); - $modeHeaders = match ($onboarding->quote_mode) { - 'first_additional' => ['首重(kg)', '首重价(元)', '续重(元/kg)'], - 'flat' => ['重量上限(kg)', '一口价(元)', '超出续重(元/kg)'], - default => ['首重(kg)', '面单(元)', '费率(元/kg)'], - }; + $quoteColumns = $onboarding->quoteColumns(); $quoteRows = $onboarding->quoteRowList(); - $suggestedName = $onboarding->courier_name ?: trim(($onboarding->network_name ?? '') !== '' ? $onboarding->network_name : ($onboarding->company_full_name ?? '')); - $agreementUrl = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']); + $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 @@ -117,17 +112,22 @@ @endif
-

公司主体与联系人

+

入驻主体与联系人

{{ $onboarding->entityLabel() }}入驻
-
公司全称
{{ $show($onboarding->company_full_name) }}
-
统一社会信用代码
{{ $show($onboarding->unified_social_credit_code) }}
-
法定代表人
{{ $show($onboarding->legal_representative) }}
+
{{ $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) }}
@@ -137,48 +137,57 @@

结算与开票

+ @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) }}
-
开户行
{{ $show($onboarding->bank_name) }}
-
银行账号
{{ $show($onboarding->bank_account_number) }}
+
对公户名
{{ $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) }}
- @if ($onboarding->bank_account_name && $onboarding->company_full_name && $onboarding->bank_account_name !== $onboarding->company_full_name) -

收款户名与公司全称不一致,请核对是否为对私账户或代收账户。

- @endif +

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

-

报价

+

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

@if ($onboarding->quoteVersion) 报价管理 {{ $onboarding->quoteVersion->version_code }} @endif
-
计费方式
{{ $show($onboarding->quoteModeLabel()) }}
-
是否计泡
{{ $onboarding->volumetric_rule === '计泡' ? '计泡(长×宽×高÷' . $onboarding->volumetric_divisor . ')' : $show($onboarding->volumetric_rule) }}
+
计费方式
{{ $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 ($quoteRows === []) -

快递方没有在线填写报价明细,请以附件中的盖章报价单为准。

+ @if ($onboarding->quote_mode === 'other') +
特殊计费,快递上传了报价表{{ $quoteSheet ? ':' . $quoteSheet->original_name : '' }}。复审通过后请在「报价管理」里按报价表录入首版报价,再发起报价确认。
+ @if ($quoteSheet)

下载报价表

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

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

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

共 {{ count($quoteRows) }} 行。复审时请与盖章报价单逐项核对;复审通过后这份报价会作为首版报价写进报价管理。

+

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

@endif
@@ -195,12 +204,12 @@ @if ($file->isImage()) - {{ $file->kindLabel() }} + {{ \App\Models\Business\CourierOnboardingFile::labelFor($file->kind, $onboarding->entity_type) }} @else @endif - {{ $file->kindLabel() }} + {{ \App\Models\Business\CourierOnboardingFile::labelFor($file->kind, $onboarding->entity_type) }} {{ $file->original_name }} {{ $file->sizeLabel() }} · {{ $file->created_at?->format('m-d H:i') }} @@ -267,17 +276,25 @@ @if ($canFinalReview)
@csrf -
- - - 必须与报价名、发货数据里的「快递名称」完全一致,复审后不可再改。 -
+ @if ($onboarding->courier_name) +
+ +
{{ $onboarding->courier_name }}
+ 生成二维码时已定好;复审通过后锁定,首版报价和 e签宝报价确认单都用这个名称。如需更正,请先在下方「内部信息」修改。 +
+ @else +
+ + + 必须与报价名、发货数据里的「快递名称」完全一致,复审后不可再改。 +
+ @endif
@elseif ($isAdmin) -

你已做过初审,需要另一位管理员完成复审。

+
这条申请的初审是你做的。为防止一人包办,复审必须换另一位管理员账号登录来做,复审按钮会出现在这里。
@else

等待管理员(财务)复审。

@endif @@ -285,30 +302,63 @@
@elseif ($status === 'approved') + @php + $quoteSigned = $quoteSignature?->status === 'archived'; + $canPush = $isAdmin && $esignReady && $onboarding->quoteVersion && (! $quoteSignature || $quoteSignature->canSend()); + @endphp
-

签署并开通

+

报价确认 · 开通

-

到「协议签署」发起合作协议(快递名称填 {{ $onboarding->courier_name }},签署手机 {{ $onboarding->contact_phone }})@if ($onboarding->quoteVersion),报价确认单选择 {{ $onboarding->quoteVersion->version_code }}@endif。签署完成后回到这里开通。

- 去协议签署 -
- @if ($signatures->isEmpty()) -

还没有找到「{{ $onboarding->courier_name }}」的签署任务。

- @else - - - @foreach ($signatures as $signature)@endforeach -
签署任务状态
{{ $signature->business_no }}
{{ $signature->documentLabel() }}
{{ $signature->statusLabel() }}
+
+
报价确认单
{{ $onboarding->quoteVersion?->version_code ?? '—' }}
+
e签宝状态
{{ $quoteSignature?->statusLabel() ?? '未推送' }}
+
签署方
{{ $show($onboarding->company_full_name) }}
{{ $show($onboarding->esign_operator_name) }} · {{ $show($onboarding->esign_account) }}
+
+ @if ($quoteSignature?->last_error) +
{{ $quoteSignature->last_error }}
@endif + @unless ($esignReady) +

E 签宝尚未配置(本地预览环境正常现象),报价确认单无法推送;线下签署后填写说明即可开通。

+ @endunless +
+ @if ($canPush) +
@csrf
+ @endif + @if ($quoteSignature?->esign_sign_flow_id && ! $quoteSigned) +
@csrf
+
@csrf
+ @endif + @if ($quoteSigned && $quoteSignature->signed_pdf_path) + 回签单 PDF + 导出电子档 + @endif + @if ($quoteSignature?->source_pdf_path) + 报价确认单 PDF + @endif +
+ @if (session('quote_sign_url')) +
+ 签署链接(签署人 {{ $show($onboarding->esign_operator_name) }} 用手机扫码或打开) +
{!! \App\Support\QrCode::svg(session('quote_sign_url'), 4, 2) !!}
+ +

可以直接把链接或二维码发给快递。打开后需先用 {{ $show($onboarding->esign_account) }} 登录 / 实名{{ $onboarding->isPerson() ? '' : ',并以「' . $onboarding->company_full_name . '」企业身份签署' }}。

+
+ @endif + @if ($quoteSignature?->esign_sign_flow_id && ! $quoteSigned && ! $onboarding->isPerson()) +

企业签署的任务在 e签宝里归属「{{ $show($onboarding->company_full_name) }}」这家企业:签署人会收到短信;在 e签宝 App 里要切换到这家企业才能看到待签任务。企业名称必须和营业执照完全一致、且已在 e签宝完成企业认证(或由签署人当场认证),否则无法签署。

+ @endif +
@if ($isAdmin)
@csrf - @unless ($signedAgreement) -
+ @unless ($quoteSigned || $signedAgreement) +
@endunless + @if ($quoteSigned)报价确认单已签署归档,可以直接开通。@endif
@else -

开通需要管理员账号。

+

推送和开通需要管理员账号。

@endif
@@ -316,6 +366,13 @@
{{ $onboarding->activated_at?->format('Y-m-d H:i') }} 已开通,可在快递资料「入驻快递」查看。
快递资料 · 入驻快递 + 报价管理 · 快递报价(变价) + @if ($quoteSignature?->status === 'archived' && $quoteSignature->signed_pdf_path) + + @endif
@endif @@ -337,6 +394,34 @@ @endif
+ @if ($status === 'activated') +
+

ERP 开通

+ @if ($onboarding->erpPending())待 ERP 开通@else已开通@endif +
+
+ @if ($onboarding->erpPending()) +

下一步:ERP 专员在钉钉发起「新建快递」流程,开通发货系统账号。流程完结后,流程里填的快递代码、ERP账户ID 会自动补到这里和快递资料。

+ @else +
+
快递代码
{{ $onboarding->workplatform_expcode }}
+
ERP账户ID
{{ $onboarding->erp_account_id ?: '—' }}
+
开通时间
{{ $onboarding->erp_opened_at?->format('Y-m-d H:i') ?? '—' }}
+
来源
{{ $onboarding->erpSourceLabel() ?: '—' }}
+
+ @endif +
+ 钉钉流程同步不到时手动补录 +
+ @csrf +
+
+ +
+
+
+
+ @endif
diff --git a/resources/views/business/pages/courier-payables/courier-onboarding.blade.php b/resources/views/business/pages/courier-payables/courier-onboarding.blade.php index caca148b..c98c0b98 100644 --- a/resources/views/business/pages/courier-payables/courier-onboarding.blade.php +++ b/resources/views/business/pages/courier-payables/courier-onboarding.blade.php @@ -41,6 +41,7 @@ .co-field { display:grid; gap:.3rem; min-width:0; } .co-field label { color:var(--co-muted); font-size:.72rem; font-weight:600; } .co-field input, .co-field select { width:100%; height:2.2rem; padding:0 .6rem; border:1px solid var(--co-border); border-radius:.45rem; background:#fff; color:var(--co-ink); font:inherit; } .co-field input:focus, .co-field select:focus { border-color:var(--co-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); } + .co-help { color:var(--co-muted); font-size:.68rem; line-height:1.5; } .co-error { color:#b80000; font-size:.72rem; } .co-invite__row { display:grid; grid-template-columns:minmax(0,1fr) 7rem; gap:.6rem; } .co-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .8rem; border:1px solid var(--co-border); border-radius:.45rem; background:#fff; color:var(--co-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; } .co-button .material-icons-outlined { font-size:1rem; } .co-button--primary { border-color:var(--co-accent); background:var(--co-accent); color:#fff; } .co-button--primary:hover { background:#6a5129; } @@ -91,12 +92,24 @@
  • 签署并开通协议签署后开通,进快递资料「入驻快递」
  • -
    + @csrf

    生成入驻二维码

    +
    + + + 与快递资料「快递公司代码」一致,入驻信息按这个代码对齐到快递资料。 + @error('workplatform_expcode'){{ $message }}@enderror +
    +
    + + + 我方规定的名称,需与报价名、发货数据里的快递名称完全一致;快递方看不到。 + @error('courier_name'){{ $message }}@enderror +
    @@ -134,7 +147,7 @@
    - + @forelse ($rows ?? [] as $row) @@ -142,6 +155,7 @@ + @@ -152,7 +166,7 @@ @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 : '' }}{{ in_array($row->status, ['submitted', 'first_approved', 'approved'], true) ? '去处理' : '查看' }}
    {{ $search !== '' ? '没有符合条件的入驻申请。' : '这里还没有入驻申请,先在上方生成一个二维码发给快递方。' }}
    {{ $search !== '' ? '没有符合条件的入驻申请。' : '这里还没有入驻申请,先在上方生成一个二维码发给快递方。' }}
    diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php index b2b6e67f..1a55c0f9 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -60,7 +60,7 @@ --settlement-soft: var(--vip-content-accent-soft, #f7f0de); --settlement-border: var(--vip-border, #d8d2c7); display: grid; - gap: 1rem; + gap: .65rem; min-width: 0; color: var(--settlement-ink); font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace; @@ -97,36 +97,37 @@ } .settlement-summary__intro, - .settlement-summary__metric { min-width: 0; padding: 1rem 1.15rem; } - .settlement-summary__intro { display: flex; align-items: center; gap: .75rem; } - .settlement-summary__icon { display: grid; flex: none; width: 2.4rem; height: 2.4rem; place-items: center; border-radius: .6rem; background: var(--settlement-soft); color: var(--settlement-accent); } - .settlement-summary__title { font-size: .95rem; font-weight: 700; } - .settlement-summary__subtitle { margin-top: .2rem; color: var(--settlement-muted); font-size: .72rem; } + .settlement-summary__metric { min-width: 0; padding: .55rem .9rem; } + .settlement-summary__intro { display: flex; align-items: center; gap: .6rem; } + .settlement-summary__icon { display: grid; flex: none; width: 1.9rem; height: 1.9rem; place-items: center; border-radius: .5rem; background: var(--settlement-soft); color: var(--settlement-accent); } + .settlement-summary__title { font-size: .875rem; font-weight: 700; } + .settlement-summary__subtitle { margin-top: .1rem; color: var(--settlement-muted); font-size: .68rem; } .settlement-summary__metric { border-left: 1px solid var(--settlement-border); } - .settlement-summary__label { color: var(--settlement-muted); font-size: .72rem; } - .settlement-summary__value { margin-top: .25rem; font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; } + .settlement-summary__label { color: var(--settlement-muted); font-size: .68rem; } + .settlement-summary__value { margin-top: .15rem; font-size: .95rem; font-weight: 700; font-variant-numeric: tabular-nums; } .settlement-filter-bar { display: grid; grid-template-columns: repeat(4, minmax(10rem, 1fr)); - gap: .7rem; + gap: .45rem .6rem; align-items: end; - padding: 1rem 1.15rem; + padding: .55rem .9rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fffdf8; } - .settlement-field { display: grid; gap: .3rem; min-width: 0; } - .settlement-field label { color: var(--settlement-muted); font-size: .72rem; font-weight: 600; } + .settlement-field { display: grid; gap: .2rem; min-width: 0; } + .settlement-field[hidden] { display: none; } + .settlement-field label { color: var(--settlement-muted); font-size: .68rem; font-weight: 600; } .settlement-field input, .settlement-field select { width: 100%; - height: 2.2rem; + height: 1.9rem; min-width: 0; - padding: 0 .6rem; + padding: 0 .5rem; border: 1px solid var(--settlement-border); - border-radius: .45rem; + border-radius: .4rem; background: #fff; color: var(--settlement-ink); font: inherit; @@ -134,23 +135,26 @@ .settlement-field input:focus, .settlement-field select:focus { border-color: var(--settlement-accent); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, .18); } - .settlement-actions { display: flex; flex-wrap: wrap; gap: .45rem; align-items: end; } - .settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-height: 2.2rem; padding: 0 .75rem; border: 1px solid var(--settlement-border); border-radius: .45rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; } + .settlement-actions { display: flex; flex-wrap: wrap; gap: .4rem; align-items: end; } + .settlement-filter-bar .settlement-actions { grid-column: 1 / -1; } + .settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .3rem; min-height: 1.9rem; padding: 0 .65rem; border: 1px solid var(--settlement-border); border-radius: .4rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; } .settlement-button--primary { border-color: var(--settlement-accent); background: var(--settlement-accent); color: #fff; } - .settlement-button .material-icons-outlined { font-size: 1rem; } - .settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem 1rem; margin-top: .75rem; padding: .85rem 1.15rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; } - .settlement-import-bar .settlement-field { flex: 0 1 22rem; } - .settlement-import-bar input[type="file"] { width: 100%; height: 2.2rem; padding: .35rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .45rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; } - .settlement-import-bar .settlement-table-hint { align-self: center; } + .settlement-button .material-icons-outlined { font-size: .95rem; } + .settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .45rem .75rem; margin-top: .45rem; padding: .5rem .9rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; } + .settlement-import-bar .settlement-field { flex: 0 1 18rem; } + .settlement-import-bar input[type="file"] { width: 100%; height: 1.9rem; padding: .25rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .4rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; } + .settlement-import-bar .settlement-table-hint { align-self: center; flex-basis: 100%; } + .settlement-import-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .15rem .9rem; margin-left: auto; align-self: center; color: var(--settlement-muted); font-size: .7rem; } + .settlement-import-meta strong { color: var(--settlement-ink); } .settlement-table-surface { overflow: visible; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; } - .settlement-table-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: .75rem 1rem; padding: .85rem 1.15rem; border-bottom: 1px solid var(--settlement-border); } + .settlement-table-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .4rem .9rem; padding: .5rem .9rem; border-bottom: 1px solid var(--settlement-border); } + .settlement-table-head .finance-table-controls { margin: 0; padding: 0; border: 0; background: transparent; } .settlement-table-title { display: flex; align-items: center; gap: .45rem; font-size: .9rem; font-weight: 700; } .settlement-table-title .material-icons-outlined { color: var(--settlement-accent); font-size: 1.1rem; } - .settlement-table-hint { color: var(--settlement-muted); font-size: .72rem; } - .settlement-table-meta { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .6rem 1rem; padding: .65rem 1.15rem; border-bottom: 1px solid var(--settlement-border); background: #fffdf8; color: var(--settlement-muted); font-size: .72rem; } - .settlement-table-meta strong { color: var(--settlement-ink); } - .settlement-table-scroll { max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; } + .settlement-table-hint { color: var(--settlement-muted); font-size: .68rem; } + .settlement-table-scroll { overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; } + .surcharge-settlement-page .settlement-table-scroll { max-height: max(14rem, min(calc(100vh - 12rem), 46rem)); } .settlement-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .75rem; } .settlement-table th, .settlement-table td { min-width: 7.5rem; padding: .62rem .7rem; border-right: 1px solid var(--settlement-border); border-bottom: 1px solid var(--settlement-border); text-align: left; vertical-align: top; white-space: nowrap; } @@ -161,6 +165,21 @@ .settlement-table td[data-finance-fixed="end"] { background: #fffdf8; } .settlement-table td { overflow: hidden; text-overflow: ellipsis; } .settlement-table .settlement-empty td { min-width: 22rem; padding: 3rem 1rem; text-align: center; color: var(--settlement-muted); white-space: normal; } + .settlement-table tbody tr.is-clickable { cursor: pointer; } + .settlement-table tbody tr.is-clickable:not(.is-active):hover td, + .settlement-table tbody tr.is-clickable:not(.is-active):hover td[data-finance-fixed="true"], + .settlement-table tbody tr.is-clickable:not(.is-active):hover td[data-finance-fixed="end"] { background: #fcfaf5; } + .settlement-table tbody tr.is-active td, + .settlement-table tbody tr.is-active td[data-finance-fixed="true"], + .settlement-table tbody tr.is-active td[data-finance-fixed="end"] { background: var(--settlement-soft); border-bottom-color: #e4d7ba; } + .settlement-table tbody tr.is-active td:first-child { box-shadow: inset 3px 0 0 var(--settlement-accent); } + .settlement-footer { display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .35rem .15rem 0; color: var(--settlement-muted); font-size: .72rem; } + .settlement-pagination { display: flex; align-items: center; gap: .35rem; } + .settlement-page-link { display: inline-flex; align-items: center; justify-content: center; min-width: 1.7rem; height: 1.6rem; border: 1px solid var(--settlement-border); border-radius: .4rem; background: #fff; color: var(--settlement-ink); text-decoration: none; } + .settlement-page-link:hover { border-color: var(--settlement-accent); background: var(--settlement-soft); } + .settlement-page-link.is-disabled { opacity: .4; pointer-events: none; } + .settlement-page-link .material-icons-outlined { font-size: 1rem; } + .settlement-filter-picker .finance-table-field-picker__panel { right: auto; left: 0; max-width: min(28rem, calc(100vw - 1rem)); } @media (max-width: 900px) { .settlement-summary { grid-template-columns: 1fr 1fr; } @@ -204,19 +223,19 @@
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    重置导出
    @@ -228,13 +247,13 @@
    下载导入模板 - 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。
    +
    当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
    + 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。
    -
    附加费结算明细
    快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。
    列设置按当前用户和本页面保存
    -
    当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
    +
    附加费结算明细
    快递名称和附加费类型固定显示,其余字段可在“列设置”中调整;点击行可高亮标记。
    @@ -244,7 +263,7 @@ @forelse ($rows as $row) - + @foreach ($columns as $column) @endforeach @@ -257,7 +276,114 @@ - @if (method_exists($rows, 'links') && $rows->hasPages()) -
    {{ $rows->withQueryString()->links() }}
    + @if (method_exists($rows, 'hasPages') && $rows->hasPages()) + @endif + + diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php index 41ce8674..e6d9dbc0 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php @@ -1,63 +1,5 @@ -@php - $surchargeTabUrls = collect(['quotes', 'settlement'])->mapWithKeys(fn ($tab) => [ - $tab => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => $tab]), - ])->all(); -@endphp
    - - - diff --git a/resources/views/business/pages/courier-payables/final-workspace.blade.php b/resources/views/business/pages/courier-payables/final-workspace.blade.php index f9e9a551..27334879 100644 --- a/resources/views/business/pages/courier-payables/final-workspace.blade.php +++ b/resources/views/business/pages/courier-payables/final-workspace.blade.php @@ -83,7 +83,8 @@ } // Preserve each page's own scripts, forms and routes in its document. const style = doc.createElement('style'); - style.textContent = '.js-menu,body>.container,#header [data-page-title-first]{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}'; + // 子页面在 iframe 里不再显示外层那一整排菜单和标签栏,只保留页面本身。 + style.textContent = '.js-menu,body>.container,#header [data-page-title-first],.open-tabs{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}'; doc.head.appendChild(style); frame.style.visibility = 'visible'; delete frame.dataset.failed; 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 +
    {{ $displayValue(data_get($row, $column['key']), $column['key']) }}
    + + + + @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-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/partials/warehouse-freight.blade.php b/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php index 4f69a773..8251716d 100644 --- a/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php +++ b/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php @@ -15,7 +15,7 @@
    -

    仓储运费

    +

    仓储费

    选择结算月份,一键按当期生效报价核算并存档,可查看和导出每日商品仓储费
    diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index eded0d9e..400a3699 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -4,9 +4,6 @@ $status = (string) ($dingPayableWorkflowStatus ?? 'unknown'); $syncedAt = $dingPayableWorkflowSyncedAt ?? null; $error = $dingPayableWorkflowError ?? null; - $json = static fn (mixed $value): string => is_array($value) - ? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-') - : (string) ($value ?? '-'); $statusLabel = static fn (array $workflow): string => \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::label($workflow); $statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions(); @@ -19,30 +16,41 @@
    @@ -90,7 +98,7 @@ 钉钉审批编号标题状态申请人申请部门创建时间完成时间 类型用途付款金额货款月份快递支付方式收款人全称开户银行银行账号 - 全部表单字段付款明细附件审批任务 + 附件 @forelse ($rows ?? [] as $row) @@ -99,7 +107,7 @@ @php($fields = $row['fields'] ?? []) @php($approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '-')) @php($originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '-'))) - + {{ $approvalNumber }} {{ $workflow['title'] ?? '-' }} {{ $statusLabel($workflow) }} @@ -116,13 +124,22 @@ {{ $value($detail, '收款人全称') }} {{ $value($detail, '开户银行') }} {{ $value($detail, '银行账号') }} -
    展开字段({{ count($fields) }} 项)
    {{ $json($fields) }}
    -
    展开付款明细
    {{ $json($fields['付款明细'] ?? []) }}
    -
    展开附件
    {{ $json($fields['附件'] ?? ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}
    -
    展开审批任务
    {{ $json($row['tasks'] ?? []) }}
    + @php($attachmentFiles = array_merge(is_array($fields['附件'] ?? null) ? $fields['附件'] : [], is_array($fields['未签字版合同附件或其他明细附件'] ?? null) ? $fields['未签字版合同附件或其他明细附件'] : [])) + + @forelse ($attachmentFiles as $attachmentFile) + @if (is_array($attachmentFile) && ! empty($attachmentFile['fileId'])) +
    + {{ $attachmentFile['fileName'] ?? ('附件 ' . $attachmentFile['fileId']) }} + @if (! empty($attachmentFile['fileSize'])){{ number_format(((int) $attachmentFile['fileSize']) / 1048576, 2) }} MB@endif +
    + @endif + @empty + - + @endforelse + @empty -
    {{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}
    +
    {{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}
    @endforelse @@ -135,3 +152,31 @@
    + + 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/pages/courier-payables/return-pickup-management.blade.php b/resources/views/business/pages/courier-payables/return-pickup-management.blade.php index d23c299c..9ffdcfbc 100644 --- a/resources/views/business/pages/courier-payables/return-pickup-management.blade.php +++ b/resources/views/business/pages/courier-payables/return-pickup-management.blade.php @@ -66,7 +66,7 @@ color: var(--vip-content-ink, #171717); font-family: JetBrains Mono, Microsoft YaHei, PingFang SC, monospace; font-size: 0.8125rem; - margin: 1.5rem 0; + margin: .5rem 0 .4rem; } .return-pickup-status { @@ -74,7 +74,7 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 14px; + padding: 6px 12px; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 12px; background: #fff; @@ -101,17 +101,17 @@ .return-pickup-status__meta { display: flex; flex-wrap: wrap; - gap: 4px 14px; + gap: 3px 12px; color: var(--vip-content-muted, #595959); - font-size: 0.75rem; + font-size: 0.7rem; } .return-pickup-filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px 14px; - margin-top: 14px; - padding: 14px; + gap: 6px 10px; + margin-top: 8px; + padding: 8px 10px; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 12px; background: #fff; @@ -121,21 +121,21 @@ display: flex; min-width: 0; flex-direction: column; - gap: 4px; + gap: 3px; } .return-pickup-field label { color: var(--vip-content-muted, #595959); - font-size: 0.75rem; + font-size: 0.7rem; } .return-pickup-field input, .return-pickup-field select { - min-height: 34px; + min-height: 28px; border: 1px solid var(--vip-border, #d8d2c7); - border-radius: 8px; + border-radius: 6px; background: #fff; - padding: 0 10px; + padding: 0 8px; color: var(--vip-content-ink, #171717); font: inherit; outline: none; @@ -144,18 +144,18 @@ .return-pickup-actions { display: flex; align-items: flex-end; - gap: 8px; + gap: 6px; } .return-pickup-button { display: inline-flex; - min-height: 34px; + min-height: 28px; align-items: center; - gap: 6px; + gap: 5px; border: 1px solid var(--vip-border, #d8d2c7); - border-radius: 8px; + border-radius: 6px; background: #fff; - padding: 0 12px; + padding: 0 9px; color: var(--vip-content-ink, #171717); font: inherit; cursor: pointer; @@ -210,11 +210,16 @@ } .return-pickup-table-wrap--scroll { - max-height: max(16rem, min(56vh, 40rem)); + max-height: max(14rem, min(calc(100vh - 19.5rem), 44rem)); overflow-y: auto; scrollbar-gutter: stable; } + /* 列表区填满底部:回收本页外层容器留白(仅本页生效)。 */ + .my-6:has(.return-pickup-page) { margin-bottom: .4rem; } + .business-content-section:has(.return-pickup-page) { padding-bottom: .4rem; } + .container:has(.return-pickup-page) { margin-bottom: 0; padding-bottom: 0; } + .return-pickup-table-wrap--scroll thead th { position: sticky; top: 0; @@ -296,7 +301,7 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 12px; + padding: 5px 12px; border-top: 1px solid var(--vip-border, #d8d2c7); color: var(--vip-content-muted, #595959); font-size: 0.75rem; diff --git a/resources/views/business/pages/financial-risk-control/index.blade.php b/resources/views/business/pages/financial-risk-control/index.blade.php index e09776a2..ca3c1156 100644 --- a/resources/views/business/pages/financial-risk-control/index.blade.php +++ b/resources/views/business/pages/financial-risk-control/index.blade.php @@ -3,20 +3,29 @@ $activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms); $panels = $storeViolationPanels ?? []; $filters = $storeViolationFilters ?? []; - $filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []]; + $filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []]; $platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []); $sync = $storeViolationSync ?? ['code' => 'pending', 'last_succeeded_at' => null, 'row_count' => 0, 'last_error' => null]; $error = $storeViolationError ?? null; $preview = $storeViolationPreview ?? null; $columns = config('store_violations.columns', []); $handlingStatuses = config('store_violations.handling_statuses', []); - $hasFilters = collect($filters)->contains(static fn ($value): bool => is_string($value) && $value !== ''); + $alertLevels = config('store_violations.alert_levels', []); + $defaultView = ($filters['view'] ?? 'default') !== 'all'; + $hasFilters = ($filters['has_explicit'] ?? '') === '1'; $handlingTone = static fn (string $status): string => match ($status) { - '已处理' => 'is-success', + '处理完成', '已处理' => 'is-success', '处理中' => 'is-info', - '无需处理' => 'is-muted', + '已忽略', '无需处理' => 'is-muted', default => 'is-warning', }; + $levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted'); + $levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒')); + $pushTone = static fn (?string $status): string => match ($status) { + '已推送' => 'is-success', + '推送失败' => 'is-danger', + default => 'is-muted', + }; $previewTone = static fn (string $status): string => match ($status) { 'new' => 'is-success', 'update' => 'is-info', @@ -334,7 +343,84 @@ } .financial-risk-control__table { - min-width: 78rem; + min-width: 108rem; + } + + .financial-risk-control__viewbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem; + font-size: 0.75rem; + color: var(--vip-content-muted, #595959); + } + + .financial-risk-control__viewbar-label { + font-weight: 600; + } + + .financial-risk-control__viewbar .upstream-data-button.is-active { + border-color: var(--vip-content-accent, #B9954F); + background: var(--vip-content-accent-soft, #F7F0DE); + color: var(--vip-content-accent-dark, #856632); + } + + .financial-risk-control__row-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + } + + .financial-risk-control__row-actions button { + padding: 0.125rem 0.5rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + background: #FFFFFF; + color: var(--vip-content-ink, #171717); + cursor: pointer; + font-size: 0.75rem; + white-space: nowrap; + } + + .financial-risk-control__row-actions button:hover { + border-color: var(--vip-content-accent, #B9954F); + color: var(--vip-content-accent-dark, #856632); + } + + .financial-risk-control__ignore summary { + padding: 0.125rem 0.5rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.75rem; + list-style: none; + white-space: nowrap; + } + + .financial-risk-control__ignore summary::-webkit-details-marker { + display: none; + } + + .financial-risk-control__ignore-body { + display: flex; + gap: 0.25rem; + margin-top: 0.25rem; + } + + .financial-risk-control__ignore-body input { + width: 8rem; + padding: 0.125rem 0.375rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + font-size: 0.75rem; + } + + .financial-risk-control__muted-note { + margin-top: 0.125rem; + color: var(--vip-content-muted, #595959); + font-size: 0.6875rem; } .financial-risk-control__table td.is-number { @@ -444,8 +530,15 @@ 当前筛选 {{ number_format($total) }} 条{{ $hasFilters ? '(已启用筛选条件)' : '' }}。

    + +
    +
    @@ -473,6 +566,32 @@ @endforeach
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + 忽略 +
    + + +
    +
    + @empty - + @if ($error) {{ $error }} @elseif ($platformTotal === 0) diff --git a/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php b/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php new file mode 100644 index 00000000..10b1261e --- /dev/null +++ b/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php @@ -0,0 +1,470 @@ +@php + $platforms = $storeViolationPlatforms ?? []; + $activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms); + $panels = $storeViolationPanels ?? []; + $platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []); + $error = $storeViolationError ?? null; + $alertLevels = config('store_violations.alert_levels', []); + $levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted'); + $levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒')); + $pushTone = static fn (?string $status): string => match ($status) { + '已推送' => 'is-success', + '推送失败' => 'is-danger', + default => 'is-muted', + }; +@endphp + + + +
    + @include('business.pages.partials.upstream-data-styles') + + + + @foreach ($platforms as $slug => $platform) + @php + $panel = $panels[$slug] ?? null; + $rows = $panel['rows'] ?? null; + $total = (int) ($panel['total'] ?? 0); + $stats = $panel['stats'] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0]; + $label = $platform['label'] ?? $slug; + @endphp + + + @endforeach +
    + + diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index b5d0bbc1..baf337c8 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1645,9 +1645,9 @@ 线上单号: -
    - 匹配状态: +
    + 差额:
    +
    + 匹配状态: + + + + + +
    未匹配原因: @@ -1689,6 +1700,7 @@ if (!api) { return; } var widgets = []; [ + ['rdDifferenceState', @json(['有差额', '无差额']), '打字找差额(可多选)'], ['rdPlatform', @json(array_values($receiptStats['platform_options'] ?? [])), '打字找平台(可多选)'], ['rdStore', @json(array_values($receiptStats['store_options'] ?? [])), '打字找店铺(可多选)'], ['rdStatus', @json(array_values($receiptStats['status_options'] ?? [])), '打字找匹配状态(可多选)'], @@ -1716,6 +1728,8 @@ }); var month = form.querySelector('[name="month"]'); if (month && month.value) { params.set('month', month.value); } + var differenceState = form.querySelector('[name="difference_state"]'); + if (differenceState && differenceState.value) { params.set('difference_state', differenceState.value); } var orderNo = form.querySelector('[name="online_order_no"]'); if (orderNo && orderNo.value.trim()) { params.set('online_order_no', orderNo.value.trim()); } @@ -1753,6 +1767,22 @@ widget.root.addEventListener('click', schedule); widget.root.addEventListener('change', schedule); }); + var differenceSelect = form.querySelector('[name="difference_state"]'); + if (differenceSelect) { differenceSelect.addEventListener('change', schedule); } + + // 月份选完即查询:月份是自定义选择器(选完只改隐藏值),这里接上自动提交筛选表单, + // 省掉「选完月份再点查询」这一步;清空 / 本月按钮同属值变更,一并自动查询。 + var monthInput = form.querySelector('[data-inventory-month-value]'); + if (monthInput) { + monthInput.addEventListener('change', function () { + window.clearTimeout(timer); + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.submit(); + } + }); + } schedule(); })(); @@ -2481,6 +2511,8 @@ if (date) viewYear = date.year; render(); close(); + // 通知使用方「月份已选好」:筛选栏可以据此自动查询,不用再点一次「查询」。 + valueInput.dispatchEvent(new Event('change', { bubbles: true })); }; label.textContent = formatLabel(valueInput.value); @@ -2590,6 +2622,9 @@ // 多选筛选提交的是 key[],重置时一并清掉。 url.searchParams.delete(`${key}[]`); }); + // 「差额」筛选(不在 fields 列表里,提交为 difference_state[])一并清掉。 + url.searchParams.delete('difference_state'); + url.searchParams.delete('difference_state[]'); url.searchParams.delete('page'); window.location.assign(url); }); diff --git a/resources/views/business/pages/partials/open-page-tabs.blade.php b/resources/views/business/pages/partials/open-page-tabs.blade.php new file mode 100644 index 00000000..291b94f5 --- /dev/null +++ b/resources/views/business/pages/partials/open-page-tabs.blade.php @@ -0,0 +1,203 @@ +{{-- + 已打开页面标签栏:点开过的业务页面固定在顶部并排显示,方便来回切换; + 只要没退出登录就一直保留(按账号存在浏览器本地,不外传)。 +--}} + + + + + 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/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index d3c4f797..4b385da7 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -1,5 +1,16 @@ + @php + // 钉钉工作台「财务系统」入口:在「财务经营风险管控」目录下隐藏全局菜单, + // 只保留该目录自己的页签(店铺违规信息预警 / 流程统计…), + // 让钉钉里点进来就只是这一块内容;网页端登录(非钉钉容器)不受影响。 + // 也支持显式 ?focus=1,便于以后给别的入口做同样的单页模式。 + // 路由参数在部分上下文取不到时退回按路径段判断(与目录表同一套兼容写法)。 + $focusSection = (string) (request()->route('section') ?: request()->segment(3)); + $focusEntry = in_array($focusSection, ['financial-risk-control'], true) + && (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk')); + @endphp + @if (! empty($metaTitle)) @@ -20,13 +31,20 @@ @stack('body_start') - + @unless ($focusEntry) + + @endunless -
    +
    + {{-- 已打开页面标签栏放在页面标题那一行上面(更靠近顶部,方便切换)。 --}} + @unless ($focusEntry) + @include('business.pages.partials.open-page-tabs') + @endunless + {!! ! empty($title->attributes->has('title')) ? $title->attributes->get('title') : $title !!} diff --git a/resources/views/components/layouts/admin/menu.blade.php b/resources/views/components/layouts/admin/menu.blade.php index 75ff220f..687c82cb 100644 --- a/resources/views/components/layouts/admin/menu.blade.php +++ b/resources/views/components/layouts/admin/menu.blade.php @@ -199,6 +199,22 @@ @endphp +
    + +
    +
    @foreach ($prototypeGroups as $group)
    @@ -226,6 +246,7 @@ @endforeach
    @endforeach +
    @stack('menu_end') + + diff --git a/resources/views/onboarding/courier-form.blade.php b/resources/views/onboarding/courier-form.blade.php index 616dc47d..52b7a41d 100644 --- a/resources/views/onboarding/courier-form.blade.php +++ b/resources/views/onboarding/courier-form.blade.php @@ -1,22 +1,30 @@ @php /** @var \App\Models\Business\CourierOnboarding $onboarding */ - $editable = $onboarding->courierCanEdit(); + // 后台变价复用本页的报价区:$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 = []; - foreach ($provinces as $index => $province) { - $row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($savedRows->get($province) ?? []); - $quoteRows[] = ['region' => $province, 'first_weight' => $row['first_weight'] ?? null, 'first_price' => $row['first_price'] ?? null, 'rate' => $row['rate'] ?? null, 'fixed' => true]; - } $customRows = $savedRows->except($provinces)->values()->all(); - for ($i = 0; $i < 4; $i++) { - $index = count($provinces) + $i; - $row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($customRows[$i] ?? []); - $quoteRows[] = ['region' => $row['region'] ?? null, 'first_weight' => $row['first_weight'] ?? null, 'first_price' => $row['first_price'] ?? null, 'rate' => $row['rate'] ?? null, 'fixed' => false]; + 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', @@ -32,7 +40,7 @@ - 快递合作入驻 + {{ $requote ? '快递变价' : '快递合作入驻' }} +@if ($requote) +
    +
    +

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

    +

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

    +

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

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

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

    @@ -140,6 +213,7 @@
    +@endif
    @php @@ -151,16 +225,22 @@ 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())