diff --git a/app/Business/CourierPayableSummaryStore.php b/app/Business/CourierPayableSummaryStore.php new file mode 100644 index 00000000..ffe12e9e --- /dev/null +++ b/app/Business/CourierPayableSummaryStore.php @@ -0,0 +1,250 @@ + '运费差', + 'other-surcharge-summary' => '其他附加费汇总', + ]; + + private const MAX_COLUMNS = 200; + + private const MAX_ROWS = 10000; + + private const MAX_CELL_LENGTH = 10000; + + public function title(string $page): string + { + abort_unless(isset(self::PAGES[$page]), 404); + + return self::PAGES[$page]; + } + + /** + * @return array{version:int,headers:list,rows:list>,source_name:string,imported_at:string,imported_by:int|null}|null + */ + public function snapshot(int $companyId, string $page): ?array + { + $this->title($page); + $path = $this->path($companyId, $page); + $disk = Storage::disk('uploads'); + + if (! $disk->exists($path)) { + return null; + } + + try { + $snapshot = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable $exception) { + report($exception); + + throw new RuntimeException('courier_payable_summary_unreadable', previous: $exception); + } + + if (! is_array($snapshot) + || ! is_array($snapshot['headers'] ?? null) + || ! is_array($snapshot['rows'] ?? null)) { + throw new RuntimeException('courier_payable_summary_invalid'); + } + + return $snapshot; + } + + /** @return array{rows:int,columns:int} */ + public function import(UploadedFile $file, int $companyId, string $page, ?int $userId): array + { + $this->title($page); + $valueBinder = Cell::getValueBinder(); + Cell::setValueBinder(new StringValueBinder()); + + try { + $reader = IOFactory::createReaderForFile($file->getRealPath()); + $reader->setReadDataOnly(true); + $spreadsheet = $reader->load($file->getRealPath()); + [$headers, $rows] = $this->readSpreadsheet($spreadsheet); + } catch (ValidationException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw ValidationException::withMessages([ + 'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', + ]); + } finally { + Cell::setValueBinder($valueBinder); + + if (isset($spreadsheet)) { + $spreadsheet->disconnectWorksheets(); + } + } + + $payload = json_encode([ + 'version' => 1, + 'headers' => $headers, + 'rows' => $rows, + 'source_name' => mb_substr($file->getClientOriginalName(), 0, 255), + 'imported_at' => now()->toIso8601String(), + 'imported_by' => $userId, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + + $disk = Storage::disk('uploads'); + $path = $this->path($companyId, $page); + $previousPath = $this->previousPath($companyId, $page); + + if ($disk->exists($path)) { + $previous = $disk->get($path); + + if (! $disk->put($previousPath, $previous)) { + throw new RuntimeException('无法备份当前汇总数据。'); + } + } + + if (! $disk->put($path, $payload)) { + throw new RuntimeException('无法保存汇总数据。'); + } + + return ['rows' => count($rows), 'columns' => count($headers)]; + } + + public function path(int $companyId, string $page): string + { + $this->title($page); + + return "courier-payable-summaries/{$companyId}/{$page}.json"; + } + + private function previousPath(int $companyId, string $page): string + { + return "courier-payable-summaries/{$companyId}/{$page}.previous.json"; + } + + /** @return array{0:list,1:list>} */ + private function readSpreadsheet($spreadsheet): array + { + foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { + $rawRows = $worksheet->toArray(null, false, true, false); + $headerIndex = $this->headerIndex($rawRows); + + if ($headerIndex === null) { + continue; + } + + $lastColumn = $this->lastUsedColumn($rawRows, $headerIndex); + + if ($lastColumn >= self::MAX_COLUMNS) { + throw ValidationException::withMessages([ + 'import' => '单个汇总表最多支持 ' . self::MAX_COLUMNS . ' 列。', + ]); + } + + $headers = []; + for ($column = 0; $column <= $lastColumn; $column++) { + $label = $this->cell($rawRows[$headerIndex][$column] ?? null); + $headers[] = $label !== '' ? $label : '未命名列' . ($column + 1); + } + + $rows = []; + foreach (array_slice($rawRows, $headerIndex + 1) as $rawRow) { + $row = []; + $hasValue = false; + + for ($column = 0; $column <= $lastColumn; $column++) { + $value = $this->cell($rawRow[$column] ?? null); + $row[] = $value; + $hasValue = $hasValue || $value !== ''; + } + + if (! $hasValue) { + continue; + } + + $rows[] = $row; + + if (count($rows) > self::MAX_ROWS) { + throw ValidationException::withMessages([ + 'import' => '单次最多导入 ' . number_format(self::MAX_ROWS) . ' 条汇总数据,请拆分文件后重试。', + ]); + } + } + + if ($rows === []) { + throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']); + } + + return [$headers, $rows]; + } + + throw ValidationException::withMessages(['import' => '没有找到有效的表头和数据。']); + } + + private function headerIndex(array $rows): ?int + { + $firstNonEmpty = null; + + foreach (array_slice($rows, 0, 20, true) as $index => $row) { + $count = count(array_filter( + $row, + fn (mixed $value): bool => $this->cell($value) !== '', + )); + + if ($count === 0) { + continue; + } + + $firstNonEmpty ??= $index; + + if ($count >= 2) { + return $index; + } + } + + return $firstNonEmpty; + } + + private function lastUsedColumn(array $rows, int $headerIndex): int + { + $lastColumn = -1; + + foreach (array_slice($rows, $headerIndex) as $row) { + foreach ($row as $column => $value) { + if ($this->cell($value) !== '') { + $lastColumn = max($lastColumn, (int) $column); + } + } + } + + return max($lastColumn, 0); + } + + private function cell(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_bool($value)) { + return $value ? '是' : '否'; + } + + if (! is_scalar($value) && ! $value instanceof \Stringable) { + return ''; + } + + $value = trim((string) $value); + + if (mb_strlen($value) > self::MAX_CELL_LENGTH) { + throw ValidationException::withMessages(['import' => '单元格内容过长,请精简后重试。']); + } + + return $value; + } +} diff --git a/app/Http/Controllers/Business/CourierPayableSummaries.php b/app/Http/Controllers/Business/CourierPayableSummaries.php new file mode 100644 index 00000000..58fc94c0 --- /dev/null +++ b/app/Http/Controllers/Business/CourierPayableSummaries.php @@ -0,0 +1,114 @@ +middleware('auth'); + $this->middleware('company.identify'); + } + + public function import(Request $request, string $summaryPage): RedirectResponse + { + $this->authorizePage($summaryPage); + $this->store->title($summaryPage); + $request->validate([ + 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'], + ]); + + try { + $result = $this->store->import( + $request->file('import'), + (int) (company_id() ?: 1), + $summaryPage, + user_id() ? (int) user_id() : null, + ); + } catch (RuntimeException $exception) { + report($exception); + + return $this->redirectToPage($summaryPage)->with('error', '汇总数据保存失败,请稍后重试。'); + } + + return $this->redirectToPage($summaryPage)->with( + 'success', + "导入完成:{$result['rows']} 条数据,{$result['columns']} 个字段。", + ); + } + + public function export(string $summaryPage): StreamedResponse|RedirectResponse + { + $this->authorizePage($summaryPage); + $title = $this->store->title($summaryPage); + + try { + $snapshot = $this->store->snapshot((int) (company_id() ?: 1), $summaryPage); + } catch (RuntimeException) { + return $this->redirectToPage($summaryPage)->with('error', '汇总数据暂时无法读取,请重新导入后再试。'); + } + + if ($snapshot === null) { + return $this->redirectToPage($summaryPage)->with('error', '当前没有可导出的汇总数据。'); + } + + return response()->streamDownload(function () use ($snapshot): void { + $csv = fopen('php://output', 'w'); + + if ($csv === false) { + throw new RuntimeException('无法创建汇总数据导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_map([$this, 'csvText'], $snapshot['headers'])); + + foreach ($snapshot['rows'] as $row) { + fputcsv($csv, array_map([$this, 'csvText'], $row)); + } + + fclose($csv); + }, $title . '_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + private function redirectToPage(string $summaryPage): RedirectResponse + { + return redirect()->route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => $summaryPage, + ]); + } + + private function authorizePage(string $summaryPage): void + { + $section = collect(SidebarPermissions::navigationFor(user())) + ->firstWhere('slug', 'courier-payables'); + $pages = collect($section['pages'] ?? [])->flatMap(static fn (array $page): array => [ + $page, + ...($page['children'] ?? []), + ]); + + abort_unless($pages->contains('slug', $summaryPage), 403, '当前账号没有该页面权限。'); + } + + private function csvText(mixed $value): string + { + $value = (string) $value; + + if (preg_match('/^[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)%?$/u', $value) === 1) { + return $value; + } + + return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index ba531d40..4b1ba6fc 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Business; use App\Business\BusinessTableImporter; +use App\Business\CourierPayableSummaryStore; use App\Business\FundForecastTemplate; use App\Business\TableRegistry; use App\Business\UpstreamBusinessInformation; @@ -52,6 +53,7 @@ use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData; use Modules\EcommerceFinance\View\OrderDataPageData; +use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Throwable; @@ -632,12 +634,47 @@ class Pages extends Controller 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), + 'freight-difference', 'other-surcharge-summary' => $this->contentForCourierPayableSummary($page), 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, }; } + protected function contentForCourierPayableSummary(string $page): array + { + $store = app(CourierPayableSummaryStore::class); + $snapshot = null; + $error = null; + + try { + $snapshot = $store->snapshot((int) (company_id() ?: 1), $page); + } catch (RuntimeException) { + $error = '汇总数据暂时无法读取,请重新导入后再试。'; + } + + $allRows = collect($snapshot['rows'] ?? []); + $currentPage = max((int) request()->query('page', 1), 1); + $perPage = 50; + + return [ + 'view' => 'business.pages.courier-payables.summary-table', + 'courierSummaryPage' => $page, + 'courierSummaryTitle' => $store->title($page), + 'courierSummaryHeaders' => $snapshot['headers'] ?? [], + 'courierSummaryRows' => new LengthAwarePaginator( + $allRows->forPage($currentPage, $perPage)->values(), + $allRows->count(), + $perPage, + $currentPage, + ['path' => request()->url(), 'query' => request()->except('page')], + ), + 'courierSummarySourceName' => $snapshot['source_name'] ?? null, + 'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null, + 'courierSummaryError' => $error, + ]; + } + /** * Consolidated, non-destructive view of the withdrawal configuration kept * with each store receiving account. Updates remain in the source page so diff --git a/config/business_navigation.php b/config/business_navigation.php index 3f2e14c5..1ea8cea1 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -506,6 +506,8 @@ $navigation = [ ['slug' => 'courier-surcharge', 'title' => '附加费报价维护'], ['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'], ['slug' => 'courier-payables-report', 'title' => '快递应付表'], + ['slug' => 'freight-difference', 'title' => '运费差'], + ['slug' => 'other-surcharge-summary', 'title' => '其他附加费汇总'], ], ], [ diff --git a/resources/views/business/pages/courier-payables/summary-table.blade.php b/resources/views/business/pages/courier-payables/summary-table.blade.php new file mode 100644 index 00000000..f68c5588 --- /dev/null +++ b/resources/views/business/pages/courier-payables/summary-table.blade.php @@ -0,0 +1,108 @@ +
+
+
+ {{ $courierSummaryTitle }} + @if ($courierSummarySourceName) + {{ $courierSummarySourceName }} · 共 {{ number_format($courierSummaryRows->total()) }} 条 + @else + 共 0 条 + @endif +
+ +
+ + @if (is_string(session('success'))) +
{{ session('success') }}
+ @endif + + @if (is_string(session('error'))) + + @endif + + @if ($errors->has('import')) + + @endif + + @if ($courierSummaryError) + + @endif + +
+ + + + + @foreach ($courierSummaryHeaders as $header) + + @endforeach + + + + @forelse ($courierSummaryRows as $rowIndex => $row) + + + @foreach ($courierSummaryHeaders as $columnIndex => $header) + + @endforeach + + @empty + + + + @endforelse + +
序号{{ $header }}
{{ ($courierSummaryRows->currentPage() - 1) * $courierSummaryRows->perPage() + $rowIndex + 1 }}{{ ($row[$columnIndex] ?? '') !== '' ? $row[$columnIndex] : '-' }}
暂无数据,请先点击“导入”上传汇总表。
+
+ + @if ($courierSummaryRows->hasPages()) +
+ 第 {{ $courierSummaryRows->currentPage() }} / {{ $courierSummaryRows->lastPage() }} 页 + {{ $courierSummaryRows->onEachSide(1)->links() }} +
+ @endif +
+ + diff --git a/routes/admin.php b/routes/admin.php index 2b11b814..41430275 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Business\CourierQuoteChanges; use App\Http\Controllers\Business\CourierQuoteSignatures; use App\Http\Controllers\Business\CourierAgreementSignatures; +use App\Http\Controllers\Business\CourierPayableSummaries; use App\Http\Controllers\Business\CourierSurchargeQuotes; use App\Http\Controllers\Business\CourierSurchargeTypes; use App\Http\Controllers\Business\ProductPrices; @@ -196,6 +197,13 @@ Route::prefix('workspace/courier-payables/courier-surcharge') Route::post('types/{type}/status', [CourierSurchargeTypes::class, 'setStatus'])->name('types.status'); }); +Route::post('workspace/courier-payables/{summaryPage}/summary-import', [CourierPayableSummaries::class, 'import']) + ->where('summaryPage', 'freight-difference|other-surcharge-summary') + ->name('business.courier-payable-summaries.import'); +Route::get('workspace/courier-payables/{summaryPage}/summary-export', [CourierPayableSummaries::class, 'export']) + ->where('summaryPage', 'freight-difference|other-surcharge-summary') + ->name('business.courier-payable-summaries.export'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/CourierPayableSummaryPagesTest.php b/tests/Feature/Business/CourierPayableSummaryPagesTest.php new file mode 100644 index 00000000..b8f27e3b --- /dev/null +++ b/tests/Feature/Business/CourierPayableSummaryPagesTest.php @@ -0,0 +1,108 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + Storage::fake('uploads'); + } + + public function test_final_workspace_places_both_summary_pages_after_courier_payables_report(): void + { + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables-final', + ])); + + $response->assertOk()->assertSeeInOrder([ + route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-payables-report']), + route('business.pages.show', ['section' => 'courier-payables', 'page' => 'freight-difference']), + route('business.pages.show', ['section' => 'courier-payables', 'page' => 'other-surcharge-summary']), + ], false); + } + + public function test_summary_pages_render_import_and_export_buttons(): void + { + foreach (CourierPayableSummaryStore::PAGES as $page => $title) { + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => $page, + ])) + ->assertOk() + ->assertSee("data-courier-payable-summary=\"{$page}\"", false) + ->assertSeeText($title) + ->assertSeeText('导入') + ->assertSeeText('导出') + ->assertSeeText('暂无数据,请先点击“导入”上传汇总表。'); + } + } + + public function test_import_replaces_the_page_snapshot_and_export_returns_all_rows(): void + { + $file = UploadedFile::fake()->createWithContent( + '运费差.csv', + "快递公司,结算月份,运费差,备注\n测试快递,2026-08,-18.50,复核\n第二快递,2026-08,20,=待确认\n", + ); + + $this->loginAs()->post(route('business.courier-payable-summaries.import', [ + 'summaryPage' => 'freight-difference', + ]), ['import' => $file]) + ->assertRedirect(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'freight-difference', + ])) + ->assertSessionHas('success', '导入完成:2 条数据,4 个字段。'); + + Storage::disk('uploads')->assertExists( + app(CourierPayableSummaryStore::class)->path((int) $this->company->id, 'freight-difference') + ); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'freight-difference', + ])) + ->assertOk() + ->assertSeeText('导入完成:2 条数据,4 个字段。') + ->assertSeeText('测试快递') + ->assertSeeText('-18.50') + ->assertSeeText('第二快递'); + + $response = $this->loginAs()->get(route('business.courier-payable-summaries.export', [ + 'summaryPage' => 'freight-difference', + ])); + $content = $response->streamedContent(); + + $response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); + $this->assertStringContainsString('快递公司,结算月份,运费差,备注', $content); + $this->assertStringContainsString('测试快递,2026-08,-18.50,复核', $content); + $this->assertStringContainsString("\t=待确认", $content); + } + + public function test_summary_actions_require_the_matching_page_permission(): void + { + $this->withExceptionHandling(); + $this->user->roles()->sync([ + \App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id, + ]); + setting()->set('system.sidebar_permissions', [ + 'configured' => ['user_' . $this->user->id], + 'subjects' => ['user_' . $this->user->id => ['courier-payables/courier-information']], + ]); + + $this->loginAs()->get(route('business.courier-payable-summaries.export', [ + 'summaryPage' => 'freight-difference', + ]))->assertForbidden(); + } +}