path ?? storage_path(self::RELATIVE_PATH); $versions = $this->versionQuotes(); $payload = null; $failure = null; if (! is_file($path)) { $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', '本机报价快照结构不完整,请重新导入源文件。'); } } // 没有 Excel 快照时,系统内生成的报价版本(变价、快递入驻)照常显示。 if ($failure !== null && $versions === []) { return $failure; } $quotes = []; foreach (array_values($failure === null ? $payload['sheets'] : []) as $position => $sheet) { if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') { continue; } $quotes[] = $this->normalizeSheet($sheet, $position); } array_push($quotes, ...$versions); return [ 'available' => true, 'state' => 'ready', 'message' => null, 'source_file' => basename(str_replace('\\', '/', (string) (($payload['sourcePath'] ?? null) ?: '报价单合并.xlsx'))), 'quotes' => $quotes, 'summary' => [ 'quote_count' => count($quotes), 'carrier_count' => count(array_unique(array_column($quotes, 'carrier'))), 'dated_count' => count(array_filter($quotes, fn (array $quote) => $quote['effective_date'] !== null)), 'formula_count' => array_sum(array_column($quotes, 'formula_count')), ], ]; } /** @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', 'change.stockEntry'])->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 && blank($version->courier_expcode) && blank($version->billing_rules) && ! $version->change?->isStockQuote() ? route('business.courier-onboardings.requote', $onboardingId) : null; return $quote; }) ->all(); } private function normalizeSheet(array $sheet, int $position): array { $name = trim((string) $sheet['name']); $values = $this->normalizeMatrix($sheet['values'] ?? []); $formulas = $this->normalizeMatrix($sheet['formulas'] ?? []); $columnCount = max(array_map('count', $values ?: [[]])); return [ 'id' => sprintf('QB-%03d', $position + 1), 'sheet_index' => (int) ($sheet['index'] ?? $position), 'sheet_name' => $name, 'company_name' => $this->inferCompanyName($name), 'carrier' => $this->inferCarrier($name), 'quote_type' => $this->inferQuoteType($name), 'effective_date' => $this->extractEffectiveDate($values), 'source_range' => trim((string) ($sheet['address'] ?? '')), 'row_count' => count($values), 'column_count' => $columnCount, 'formula_count' => $this->formulaCount($formulas), 'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期', 'signature_status' => 'Excel 未提供', 'signed_files' => [], 'signed_at' => null, 'values' => $values, 'formulas' => $formulas, ]; } private function inferCompanyName(string $name): string { $companyName = trim((string) preg_replace('/(?:报价表|报价)$/u', '', $name)); return $companyName !== '' ? $companyName : $name; } /** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */ private function billingSummary(CourierQuoteVersion $version): string { $structure = (array) $version->billing_structure; $kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value); $parts = array_filter([ CourierQuoteStructure::describe($structure), ($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null, filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null, filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null, filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null, filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null, ]); return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)'; } private function normalizeVersion(CourierQuoteVersion $version): array { $values = $this->decodeMatrix($version->values_json); $formulas = $this->decodeMatrix($version->formulas_json); $columnCount = max(array_map('count', $values ?: [[]])); return [ 'id' => $version->version_code, 'sheet_index' => null, 'sheet_name' => $version->sheet_name, 'company_name' => $version->company_name, 'courier_expcode' => $version->courier_expcode, 'billing_rules' => $version->billing_rules, 'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null, 'carrier' => $version->carrier_name, 'quote_type' => $version->quote_type, 'effective_date' => $version->effective_date?->toDateString(), 'end_date' => $version->end_date?->toDateString(), 'period_label' => $version->end_date !== null ? $version->periodLabel() : null, 'group_key' => (filled($version->courier_expcode) ? 'code:' . $version->courier_expcode : 'name:' . $version->company_name) . ':' . $version->quote_type, 'stock_entry_id' => $version->change?->isStockQuote() ? data_get($version->change->quote_payload, 'stock_quote_entry_id') : null, 'eligible' => !$version->isVoided() && $this->isExecutable($version), 'is_stock' => (bool) $version->change?->isStockQuote(), 'period_end_url' => $version->isApprovedStockQuote() && in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::SUPER_ADMIN], true) ? route('business.courier-stock-quotes.period-end', data_get($version->change->quote_payload, 'stock_quote_entry_id')) : null, 'source_range' => $version->source_range ?? '', 'row_count' => count($values), 'column_count' => $columnCount, 'formula_count' => $this->formulaCount($formulas), 'status' => $this->versionLifecycleStatus($version), 'signature_status' => $version->signatureLabel(), 'values' => $values, 'formulas' => $formulas, 'source_change_id' => $version->source_change_id, 'signed_files' => $this->signedFiles($version), 'stock_originals' => $this->stockOriginals($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 { if ($version->signature_status === '历史签署资料已归档' && ($entry = $version->change?->stockEntry)) { foreach ($entry->files as $index => $file) { if ($file['kind'] === 'signed') { return ['signed' => route('business.courier-stock-quotes.download', [$entry, $index])]; } } } $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 stockOriginals(CourierQuoteVersion $version): array { if (!$version->change?->isStockQuote() || !($entry = $version->change->stockEntry)) return []; $files = []; foreach ($entry->files as $index => $file) { if (($file['kind'] ?? '') !== 'signed') continue; // 早期手动上传未记录 MIME;入口按扩展名提供,响应仍按实际 MIME 白名单校验。 $previewable = isset($file['mime_type']) ? in_array($file['mime_type'], ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true) : in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true); $files[] = [ 'name' => $file['name'], 'previewable' => $previewable, 'url' => route('business.courier-stock-quotes.download', [$entry, $index, 'preview' => $previewable ? 1 : 0]), ]; } return $files; } private function decodeMatrix(?string $json): array { if ($json === null || trim($json) === '') { return []; } try { return $this->normalizeMatrix(json_decode($json, true, 512, JSON_THROW_ON_ERROR)); } catch (JsonException) { return []; } } private function versionLifecycleStatus(CourierQuoteVersion $version): string { if ($version->isVoided()) { return '已作废'; } if ($version->end_date?->lt(today()) && $this->isExecutable($version)) return '已到期'; if (! $this->isExecutable($version)) { return '待生效(待签章)'; } if ($version->effective_date?->isFuture()) { return $version->isTemporary() ? '待生效(临时)' : '待生效'; } return $version->isTemporary() ? '生效中(临时)' : '生效中'; } /** 存量报价审核生效;其他报价仍要求回签归档。 */ private function isExecutable(CourierQuoteVersion $version): bool { return $version->isApprovedStockQuote() || in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true) || $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(static fn (CourierQuoteVersion $version): string => filled($version->courier_expcode) ? 'code:' . $version->courier_expcode . ':' . $version->quote_type : ($version->change?->isStockQuote() || filled($version->billing_rules) ? 'stock-name:' . $version->company_name . ':' . $version->quote_type : 'name:' . $version->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->isExecutable($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->end_date?->lt($today)) { $result[$version->id] = ['status' => '已到期', 'status_note' => ($version->isTemporary() ? '临时价 ' : '执行期间 ') . $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 { if (! is_array($matrix)) { return []; } return array_map(function ($row) { if (! is_array($row)) { return [$this->normalizeCell($row)]; } return array_map(fn ($cell) => $this->normalizeCell($cell), array_values($row)); }, array_values($matrix)); } private function normalizeCell(mixed $cell): string|int|float|bool|null { if ($cell === null || is_string($cell) || is_int($cell) || is_float($cell) || is_bool($cell)) { return $cell; } return (string) $cell; } private function inferCarrier(string $name): string { foreach ([ '中通快运', '顺心捷达', '安能快运', '京广', 'EMS', '中通', '圆通', '德邦', '申通', '极兔', '安能', '邮政', '韵达', '顺丰', ] as $carrier) { if (str_contains($name, $carrier)) { return $carrier; } } return '其他快递'; } private function inferQuoteType(string $name): string { foreach ([ '其他配件' => '其他配件报价', '配件' => '配件报价', '超长' => '超长报价', '上楼' => '上楼报价', '大件' => '大件报价', '小件' => '小件报价', '其他' => '其他报价', '快运' => '快运报价', ] as $keyword => $type) { if (str_contains($name, $keyword)) { return $type; } } return '标准报价'; } private function extractEffectiveDate(array $values): ?string { foreach ($values as $row) { foreach ($row as $cell) { if (! is_string($cell)) { continue; } if (preg_match('/(20\d{2})[.\/\-年]\s*(\d{1,2})[.\/\-月]\s*(\d{1,2})日?/u', $cell, $match) === 1) { return sprintf('%04d-%02d-%02d', (int) $match[1], (int) $match[2], (int) $match[3]); } } } return null; } private function formulaCount(array $formulas): int { $count = 0; foreach ($formulas as $row) { foreach ($row as $formula) { if (is_string($formula) && trim($formula) !== '') { $count++; } } } return $count; } private function emptySnapshot(string $state, string $message): array { return [ 'available' => false, 'state' => $state, 'message' => $message, 'source_file' => '报价单合并.xlsx', 'quotes' => [], 'summary' => [ 'quote_count' => 0, 'carrier_count' => 0, 'dated_count' => 0, 'formula_count' => 0, ], ]; } }