diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php new file mode 100644 index 00000000..121a8bbc --- /dev/null +++ b/app/Support/QuoteManagementSnapshot.php @@ -0,0 +1,197 @@ +path ?? storage_path(self::RELATIVE_PATH); + + if (! is_file($path)) { + return $this->emptySnapshot('missing', '尚未导入本机报价文件。'); + } + + 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', '本机报价快照结构不完整,请重新导入源文件。'); + } + + $quotes = []; + + foreach (array_values($payload['sheets']) as $position => $sheet) { + if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') { + continue; + } + + $quotes[] = $this->normalizeSheet($sheet, $position); + } + + return [ + 'available' => true, + 'state' => 'ready', + 'message' => null, + 'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '报价单合并.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')), + ], + ]; + } + + 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 未提供', + 'values' => $values, + 'formulas' => $formulas, + ]; + } + + private function inferCompanyName(string $name): string + { + $companyName = trim((string) preg_replace('/(?:报价表|报价)$/u', '', $name)); + + return $companyName !== '' ? $companyName : $name; + } + + 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, + ], + ]; + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index 6badb7da..6ad65233 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -202,6 +202,7 @@ return [ 'title' => '快递应付', 'icon' => 'local_shipping', 'pages' => [ + ['slug' => 'quote-management', 'title' => '报价管理'], ['slug' => 'freight-reconciliation', 'title' => '运费对账'], ['slug' => 'bookkeeping-details', 'title' => '记账明细'], ['slug' => 'compensation-details', 'title' => '赔偿明细'], diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php new file mode 100644 index 00000000..9077b4e8 --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -0,0 +1,1348 @@ +@php + $quoteSnapshot = app(\App\Support\QuoteManagementSnapshot::class)->load(); + $quotes = $quoteSnapshot['quotes']; + $quoteCompanies = collect($quotes) + ->pluck('company_name') + ->filter() + ->unique() + ->sort() + ->values() + ->all(); + $quoteTypes = collect($quotes)->pluck('quote_type')->unique()->sort()->values(); +@endphp + + + + + + + + description + + + {{ $quoteSnapshot['source_file'] }} + 本机私有报价源 · 原始工作簿未修改 + + + + 报价版本 + {{ $quoteSnapshot['summary']['quote_count'] }} + + + 快递品牌 + {{ $quoteSnapshot['summary']['carrier_count'] }} + + + 已标执行日期 + {{ $quoteSnapshot['summary']['dated_count'] }} + + + 原表公式单元格 + {{ $quoteSnapshot['summary']['formula_count'] }} + + + + + + + 报价名称 + + + + 快递公司 + + 全部公司 + @foreach ($quoteCompanies as $companyName) + {{ $companyName }} + @endforeach + + + + 报价类型 + + 全部类型 + @foreach ($quoteTypes as $type) + {{ $type }} + @endforeach + + + + 资料状态 + + 全部状态 + 已标执行日期 + 未标执行日期 + + + + + filter_alt_off + + + history + 报价历史 + + + edit_note + 发起报价变更 + + + + + @if ($quoteSnapshot['available']) + + 共 {{ count($quotes) }} 个报价版本 + + verified_user + 原始报价规则保留,当前页面不重新计算金额 + + + + + + + + 报价版本 + 快递公司 + 报价类型 + 原始范围 + 数据规模 + 执行日期 + 签章 + 资料状态 + 操作 + + + + + + + + + filter_alt_off + 没有符合当前条件的报价 + 重置筛选后可查看全部已导入版本。 + + + + + + + + chevron_left + 上一页 + + + 下一页 + chevron_right + + + + @else + + + file_present + {{ $quoteSnapshot['message'] }} + 报价页面仍可访问,重新导入后会显示工作表列表。 + + + @endif + + + @if ($quoteSnapshot['available']) + + + + + + 原工作表内容 · 只读 + + + close + + + + 版本号 + 快递公司 + 报价类型 + 原始范围 + 执行日期 + 公式单元格 + + + + + + + + + + + + 发起报价变更 + 创建本机变更草稿 · 原始工作簿保持不变 + + + close + + + + + + info + 当前为本机只读报价源。提交后只生成浏览器会话内的草稿提示,不会修改金额、数据库或原始 Excel。 + + + + 报价版本 * + + + + 变更类型 * + + 费率调整 + 面单调整 + 规则备注 + 执行日期 + 其他 + + + + 拟执行日期 * + + + + 变更说明 * + + + + + + + + + + + + @endif + diff --git a/tests/Feature/Business/QuoteManagementPageTest.php b/tests/Feature/Business/QuoteManagementPageTest.php new file mode 100644 index 00000000..47b460fb --- /dev/null +++ b/tests/Feature/Business/QuoteManagementPageTest.php @@ -0,0 +1,170 @@ +fixturePath = storage_path('framework/testing/quote-management-' . uniqid('', true) . '.json'); + file_put_contents($this->fixturePath, json_encode([ + 'sourcePath' => 'C:/private/报价单合并.xlsx', + 'sheets' => [ + [ + 'index' => 0, + 'name' => '新北中通报价', + 'address' => 'A1:D4', + 'values' => [ + ['省份', '面单', '费率', "备注:\n执行时间:2025.12.01"], + [null, null, null, null], + ['江苏省', 3.8, 0.5, null], + ['安徽省', 3.8, 0.5, null], + ], + 'formulas' => [[], [], ['', '', '=B3/2', ''], []], + ], + [ + 'index' => 1, + 'name' => '常州圆通-上楼', + 'address' => 'A1:H2', + 'values' => [ + ['报价表', '常州圆通'], + ['江苏省', 4.2], + ], + 'formulas' => [[], []], + ], + [ + 'index' => 2, + 'name' => '新德邦快递超长报价', + 'address' => 'A1:F2', + 'values' => [ + ['0-1kg', '1kg以上'], + ['江苏省', 5.5], + ], + 'formulas' => [[], []], + ], + [ + 'index' => 3, + 'name' => '新北中通其他报价', + 'address' => 'A1:D2', + 'values' => [ + ['省份', '面单', '费率', '备注'], + ['江苏省', 4.1, 0.6, null], + ], + 'formulas' => [[], []], + ], + ], + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + + app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath)); + } + + protected function tearDown(): void + { + if (isset($this->fixturePath) && is_file($this->fixturePath)) { + unlink($this->fixturePath); + } + + parent::tearDown(); + } + + public function testQuoteManagementPageRendersImportedWorkbookVersions(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'quote-management', + ])) + ->assertOk() + ->assertSeeText('报价管理') + ->assertSeeText('报价单合并.xlsx') + ->assertSeeText('报价名称') + ->assertSeeText('快递公司') + ->assertSeeText('资料状态') + ->assertSeeText('报价历史') + ->assertSeeText('发起报价变更') + ->assertSee('data-quote-change-open', false) + ->assertSee('data-quote-change-modal', false) + ->assertSeeText('变更类型') + ->assertSeeText('变更说明') + ->assertSeeText('生成变更草稿') + ->assertDontSee('>draft', false) + ->assertSee('新北中通', false) + ->assertSee('新北中通其他', false) + ->assertSee('新德邦快递超长', false) + ->assertSee('"id":"QB-001"', false) + ->assertSee('"company_name":"新北中通"', false) + ->assertSee('"company_name":"新北中通其他"', false) + ->assertSee('新北中通报价', false) + ->assertSee('常州圆通-上楼', false) + ->assertSee('2025-12-01', false) + ->assertSee('=B3/2', false) + ->assertSeeText('原始报价规则保留,当前页面不重新计算金额') + ->assertDontSee('报价列表正在配置中'); + + $quotes = app(QuoteManagementSnapshot::class)->load()['quotes']; + + $this->assertSame(['QB-001', 'QB-002', 'QB-003', 'QB-004'], array_column($quotes, 'id')); + $this->assertSame( + ['新北中通', '常州圆通-上楼', '新德邦快递超长', '新北中通其他'], + array_column($quotes, 'company_name') + ); + } + + public function testQuoteManagementPageKeepsTheShellWhenThePrivateSnapshotIsMissing(): void + { + app()->instance( + QuoteManagementSnapshot::class, + new QuoteManagementSnapshot(storage_path('framework/testing/missing-quote-snapshot.json')) + ); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'quote-management', + ])) + ->assertOk() + ->assertSeeText('尚未导入本机报价文件。') + ->assertSeeText('报价页面仍可访问') + ->assertDontSee('class="business-blank-page"', false); + } + + public function testQuoteManagementPageRejectsUsersWithoutItsSidebarPermission(): void + { + $subject = 'user_' . $this->user->id; + + setting()->set('system.sidebar_permissions', json_encode([ + 'configured' => [$subject], + 'subjects' => [ + $subject => ['courier-payables/freight-reconciliation'], + ], + ], JSON_UNESCAPED_UNICODE)); + + try { + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'quote-management', + ])); + $this->fail('缺少报价管理页面权限的用户不应访问该页面。'); + } catch (HttpException|HttpResponseException $exception) { + $status = method_exists($exception, 'getStatusCode') + ? $exception->getStatusCode() + : $exception->getResponse()->getStatusCode(); + + $this->assertSame(404, $status); + } + } +}