From e1a0012ba7890431730637f4d8ffc7441ffad3d6 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Wed, 12 Aug 2026 16:19:43 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/business_navigation.php | 1 + .../quote-management.blade.php | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 resources/views/business/pages/courier-payables/quote-management.blade.php diff --git a/config/business_navigation.php b/config/business_navigation.php index 702d1808..cb0913b0 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -190,6 +190,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..7a142530 --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -0,0 +1,27 @@ +
+
+ + + +
+ +
+ + 报价列表正在配置中 +
+
-- 2.47.2 From 870ce07a9b0509d6c57c83233d00e601d814d041 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Thu, 13 Aug 2026 09:58:25 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=B7=A5=E4=BD=9C=E7=B0=BF=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E4=B8=8E=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Support/QuoteManagementSnapshot.php | 189 +++ .../quote-management.blade.php | 1100 ++++++++++++++++- .../Business/QuoteManagementPageTest.php | 142 +++ 3 files changed, 1408 insertions(+), 23 deletions(-) create mode 100644 app/Support/QuoteManagementSnapshot.php create mode 100644 tests/Feature/Business/QuoteManagementPageTest.php diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php new file mode 100644 index 00000000..4c1486bc --- /dev/null +++ b/app/Support/QuoteManagementSnapshot.php @@ -0,0 +1,189 @@ +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, + '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 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/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 7a142530..302f1094 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -1,27 +1,1081 @@ -
-
- - - +@php + $quoteSnapshot = app(\App\Support\QuoteManagementSnapshot::class)->load(); + $quotes = $quoteSnapshot['quotes']; + $quoteCompanies = collect($quotes) + ->mapWithKeys(function (array $quote): array { + $label = preg_replace('/(?:报价表|报价)$/u', '', (string) $quote['sheet_name']); + + return [$quote['sheet_name'] => $label ?: $quote['sheet_name']]; + }) + ->sort() + ->all(); + $quoteTypes = collect($quotes)->pluck('quote_type')->unique()->sort()->values(); +@endphp + +
+ + +
+
+ +
+
{{ $quoteSnapshot['source_file'] }}
+
本机私有报价源 · 原始工作簿未修改
+
+
+
+
报价版本
+
{{ $quoteSnapshot['summary']['quote_count'] }}
+
+
+
快递品牌
+
{{ $quoteSnapshot['summary']['carrier_count'] }}
+
+
+
已标执行日期
+
{{ $quoteSnapshot['summary']['dated_count'] }}
+
+
+
原表公式单元格
+
{{ $quoteSnapshot['summary']['formula_count'] }}
+
-
- - 报价列表正在配置中 +
+
+ + + + +
+ + + +
+
+ + @if ($quoteSnapshot['available']) +
+ 共 {{ count($quotes) }} 个报价版本 + + + 原始报价规则保留,当前页面不重新计算金额 + +
+ +
+ + + + + + + + + + + + + + + +
报价版本快递公司报价类型原始范围数据规模执行日期签章资料状态操作
+
+ + + +
+ + + + + +
+ @else +
+
+ +
{{ $quoteSnapshot['message'] }}
+
报价页面仍可访问,重新导入后会显示工作表列表。
+
+
+ @endif
+ + @if ($quoteSnapshot['available']) + + + + @endif
diff --git a/tests/Feature/Business/QuoteManagementPageTest.php b/tests/Feature/Business/QuoteManagementPageTest.php new file mode 100644 index 00000000..36f59b9f --- /dev/null +++ b/tests/Feature/Business/QuoteManagementPageTest.php @@ -0,0 +1,142 @@ +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' => [[], []], + ], + ], + ], 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('', false) + ->assertSee('', false) + ->assertSee('新北中通报价', false) + ->assertSee('常州圆通-上楼', false) + ->assertSee('2025-12-01', false) + ->assertSee('=B3/2', false) + ->assertSeeText('原始报价规则保留,当前页面不重新计算金额') + ->assertDontSee('报价列表正在配置中'); + } + + 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); + } + } +} -- 2.47.2 From 833f2937165fb85191e2c2004385e48cd0b19857 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Thu, 13 Aug 2026 10:18:57 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E5=85=A5=E5=8F=A3=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quote-management.blade.php | 277 +++++++++++++++++- .../Business/QuoteManagementPageTest.php | 5 + 2 files changed, 278 insertions(+), 4 deletions(-) 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 302f1094..582e9cd3 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -424,6 +424,16 @@ box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10); } + .quote-change-modal__panel { + grid-template-rows: auto minmax(0, 1fr); + width: min(42rem, calc(100vw - 3rem)); + height: auto; + max-height: min(42rem, calc(100vh - 3rem)); + margin: auto 1.5rem auto auto; + border: 1px solid var(--quote-border); + border-radius: 12px; + } + .quote-modal__header { display: flex; min-width: 0; @@ -519,6 +529,86 @@ font-size: 0.7rem; } + .quote-change-form { + display: grid; + min-height: 0; + grid-template-rows: minmax(0, 1fr) auto; + } + + .quote-change-form__body { + display: grid; + gap: 1rem; + min-height: 0; + overflow-y: auto; + padding: 1.25rem; + } + + .quote-change-form__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } + + .quote-change-form__field { + display: grid; + gap: 0.4rem; + min-width: 0; + } + + .quote-change-form__field--full { + grid-column: 1 / -1; + } + + .quote-change-form__label { + color: var(--quote-muted); + font-size: 0.75rem; + font-weight: 500; + } + + .quote-change-form__required { + color: #B80000; + } + + .quote-change-form__textarea { + min-height: 8rem; + resize: vertical; + padding: 0.65rem 0.75rem; + line-height: 1.5; + } + + .quote-change-form__notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.7rem 0.8rem; + border: 1px solid var(--quote-border); + border-radius: 8px; + background: var(--quote-accent-soft); + color: var(--quote-accent); + font-size: 0.75rem; + line-height: 1.5; + } + + .quote-change-form__notice .material-icons-outlined { + flex: 0 0 auto; + font-size: 18px; + } + + .quote-change-form__status { + min-height: 1.25rem; + color: #53793E; + font-size: 0.75rem; + } + + .quote-change-form__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.85rem 1.25rem; + border-top: 1px solid var(--quote-border); + } + [hidden] { display: none !important; } @@ -610,6 +700,29 @@ .quote-modal__panel { width: 100%; } + + .quote-change-modal__panel { + width: calc(100% - 1rem); + max-height: calc(100vh - 1rem); + margin: auto 0.5rem; + } + + .quote-change-form__grid { + grid-template-columns: 1fr; + } + + .quote-change-form__field--full { + grid-column: auto; + } + + .quote-change-form__footer { + flex-direction: column-reverse; + align-items: stretch; + } + + .quote-change-form__footer .quote-button { + width: 100%; + } } @@ -681,7 +794,7 @@ 报价历史 - @@ -774,6 +887,60 @@
+ +