diff --git a/app/Business/CourierPayableSummaryFilter.php b/app/Business/CourierPayableSummaryFilter.php new file mode 100644 index 00000000..8a5f2144 --- /dev/null +++ b/app/Business/CourierPayableSummaryFilter.php @@ -0,0 +1,120 @@ +query($key, ''); + + if (! is_scalar($value)) { + return ''; + } + + return mb_substr(trim((string) $value), 0, $maxLength); + }; + + return [ + 'date' => $clean('date', 50), + 'person' => $clean('person', 100), + 'courier' => $clean('courier', 100), + 'keyword' => $clean('keyword', 191), + ]; + } + + /** @param array{date:string,person:string,courier:string,keyword:string} $filters */ + public function hasFilters(array $filters): bool + { + return collect($filters)->contains(static fn (string $value): bool => $value !== ''); + } + + /** + * @param Collection $rows + * @param list $headers + * @param array{date:string,person:string,courier:string,keyword:string} $filters + * @return Collection + */ + public function apply(Collection $rows, array $headers, array $filters): Collection + { + if (! $this->hasFilters($filters)) { + return $rows; + } + + $dateColumn = $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']); + $personColumn = $this->columnIndex($headers, ['负责人', '责任人']); + $courierColumn = $this->columnIndex($headers, ['快递公司', '快递名称', '快递名', '快递']); + + return $rows->filter(function (mixed $row) use ($filters, $dateColumn, $personColumn, $courierColumn): bool { + if (! is_array($row)) { + return false; + } + + if (! $this->columnMatches($row, $dateColumn, $filters['date']) + || ! $this->columnMatches($row, $personColumn, $filters['person']) + || ! $this->columnMatches($row, $courierColumn, $filters['courier'])) { + return false; + } + + if ($filters['keyword'] === '') { + return true; + } + + foreach ($row as $value) { + if ($this->contains($value, $filters['keyword'])) { + return true; + } + } + + return false; + }); + } + + /** @param list $headers */ + public function dateColumnIndex(array $headers): ?int + { + return $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']); + } + + /** @param list $headers */ + private function columnIndex(array $headers, array $aliases): ?int + { + $normalizedAliases = array_map( + static fn (string $alias): string => mb_strtolower(preg_replace('/[\\s ]+/u', '', $alias) ?? $alias), + $aliases, + ); + + foreach ($headers as $index => $header) { + $normalizedHeader = mb_strtolower(preg_replace('/[\\s ]+/u', '', trim((string) $header)) ?? trim((string) $header)); + + if (in_array($normalizedHeader, $normalizedAliases, true)) { + return (int) $index; + } + } + + return null; + } + + private function columnMatches(array $row, ?int $column, string $needle): bool + { + if ($needle === '') { + return true; + } + + return $column !== null && $this->contains($row[$column] ?? '', $needle); + } + + private function contains(mixed $value, string $needle): bool + { + if (! is_scalar($value) && ! $value instanceof \Stringable) { + return false; + } + + return mb_stripos((string) $value, $needle, 0, 'UTF-8') !== false; + } +} diff --git a/app/Http/Controllers/Business/CourierPayableSummaries.php b/app/Http/Controllers/Business/CourierPayableSummaries.php index fa29b02f..e0bd8e86 100644 --- a/app/Http/Controllers/Business/CourierPayableSummaries.php +++ b/app/Http/Controllers/Business/CourierPayableSummaries.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Business; +use App\Business\CourierPayableSummaryFilter; use App\Business\CourierPayableSummaryStore; use App\Utilities\SidebarPermissions; use Illuminate\Http\RedirectResponse; @@ -31,8 +32,10 @@ class CourierPayableSummaries extends Controller ], ]; - public function __construct(private readonly CourierPayableSummaryStore $store) - { + public function __construct( + private readonly CourierPayableSummaryStore $store, + private readonly CourierPayableSummaryFilter $filter, + ) { $this->middleware('auth'); $this->middleware('company.identify'); } @@ -85,7 +88,7 @@ class CourierPayableSummaries extends Controller ); } - public function export(string $summaryPage): StreamedResponse|RedirectResponse + public function export(Request $request, string $summaryPage): StreamedResponse|RedirectResponse { $this->authorizePage($summaryPage); $title = $this->store->title($summaryPage); @@ -100,7 +103,12 @@ class CourierPayableSummaries extends Controller return $this->redirectToPage($summaryPage)->with('error', '当前没有可导出的汇总数据。'); } - return response()->streamDownload(function () use ($snapshot): void { + $filters = $this->filter->fromRequest($request); + $headers = $snapshot['headers']; + $rows = $this->filter->apply(collect($snapshot['rows']), $headers, $filters)->values(); + $dateColumn = $this->filter->dateColumnIndex($headers); + + return response()->streamDownload(function () use ($headers, $rows, $dateColumn): void { $csv = fopen('php://output', 'w'); if ($csv === false) { @@ -108,10 +116,14 @@ class CourierPayableSummaries extends Controller } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); - fputcsv($csv, array_map([$this, 'csvText'], $snapshot['headers'])); + fputcsv($csv, array_map([$this, 'csvText'], $headers)); - foreach ($snapshot['rows'] as $row) { - fputcsv($csv, array_map([$this, 'csvText'], $row)); + foreach ($rows as $row) { + fputcsv($csv, array_map( + fn (mixed $value, int $column): string => $this->csvText($value, $column === $dateColumn), + $row, + array_keys($row), + )); } fclose($csv); @@ -141,10 +153,14 @@ class CourierPayableSummaries extends Controller abort_unless($pages->contains('slug', $summaryPage), 403, '当前账号没有该页面权限。'); } - private function csvText(mixed $value): string + private function csvText(mixed $value, bool $preserveYearMonth = false): string { $value = (string) $value; + if ($preserveYearMonth && preg_match('/^\d{4}-(?:0[1-9]|1[0-2])$/', $value) === 1) { + return '="' . $value . '"'; + } + if (preg_match('/^[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)%?$/u', $value) === 1) { return $value; } diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index b6199a3a..45068300 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\CourierPayableSummaryFilter; use App\Business\CourierPayableSummaryStore; use App\Business\FundForecastTemplate; use App\Business\TableRegistry; @@ -728,8 +729,9 @@ class Pages extends Controller $headers = $snapshot['headers'] ?? []; $allRows = collect($snapshot['rows'] ?? []); - $filters = $this->courierSummaryFilters(); - $filteredRows = $this->filterCourierSummaryRows($allRows, $headers, $filters); + $filter = app(CourierPayableSummaryFilter::class); + $filters = $filter->fromRequest(request()); + $filteredRows = $filter->apply($allRows, $headers, $filters); $perPage = 50; $lastPage = max((int) ceil($filteredRows->count() / $perPage), 1); $currentPage = min(max((int) request()->query('page', 1), 1), $lastPage); @@ -750,9 +752,7 @@ class Pages extends Controller )], ), 'courierSummaryFilters' => $filters, - 'courierSummaryHasFilters' => collect($filters)->contains( - static fn (string $value): bool => $value !== '', - ), + 'courierSummaryHasFilters' => $filter->hasFilters($filters), 'courierSummarySourceTotal' => $allRows->count(), 'courierSummarySourceName' => $snapshot['source_name'] ?? null, 'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null, @@ -760,105 +760,6 @@ class Pages extends Controller ]; } - /** @return array{date:string,person:string,courier:string,keyword:string} */ - private function courierSummaryFilters(): array - { - $clean = static function (string $key, int $maxLength): string { - $value = request()->query($key, ''); - - if (! is_scalar($value)) { - return ''; - } - - return mb_substr(trim((string) $value), 0, $maxLength); - }; - - return [ - 'date' => $clean('date', 50), - 'person' => $clean('person', 100), - 'courier' => $clean('courier', 100), - 'keyword' => $clean('keyword', 191), - ]; - } - - /** - * @param Collection $rows - * @param list $headers - * @param array{date:string,person:string,courier:string,keyword:string} $filters - * @return Collection - */ - private function filterCourierSummaryRows(Collection $rows, array $headers, array $filters): Collection - { - if (! collect($filters)->contains(static fn (string $value): bool => $value !== '')) { - return $rows; - } - - $dateColumn = $this->courierSummaryColumnIndex($headers, ['日期', '结算月份', '结算月', '月份']); - $personColumn = $this->courierSummaryColumnIndex($headers, ['负责人', '责任人']); - $courierColumn = $this->courierSummaryColumnIndex($headers, ['快递公司', '快递名称', '快递名', '快递']); - - return $rows->filter(function (mixed $row) use ($filters, $dateColumn, $personColumn, $courierColumn): bool { - if (! is_array($row)) { - return false; - } - - if (! $this->courierSummaryColumnMatches($row, $dateColumn, $filters['date']) - || ! $this->courierSummaryColumnMatches($row, $personColumn, $filters['person']) - || ! $this->courierSummaryColumnMatches($row, $courierColumn, $filters['courier'])) { - return false; - } - - if ($filters['keyword'] === '') { - return true; - } - - foreach ($row as $value) { - if ($this->courierSummaryContains($value, $filters['keyword'])) { - return true; - } - } - - return false; - }); - } - - /** @param list $headers */ - private function courierSummaryColumnIndex(array $headers, array $aliases): ?int - { - $normalizedAliases = array_map( - static fn (string $alias): string => mb_strtolower(preg_replace('/[\\s ]+/u', '', $alias) ?? $alias), - $aliases, - ); - - foreach ($headers as $index => $header) { - $normalizedHeader = mb_strtolower(preg_replace('/[\\s ]+/u', '', trim((string) $header)) ?? trim((string) $header)); - - if (in_array($normalizedHeader, $normalizedAliases, true)) { - return (int) $index; - } - } - - return null; - } - - private function courierSummaryColumnMatches(array $row, ?int $column, string $needle): bool - { - if ($needle === '') { - return true; - } - - return $column !== null && $this->courierSummaryContains($row[$column] ?? '', $needle); - } - - private function courierSummaryContains(mixed $value, string $needle): bool - { - if (! is_scalar($value) && ! $value instanceof \Stringable) { - return false; - } - - return mb_stripos((string) $value, $needle, 0, 'UTF-8') !== false; - } - /** * Consolidated, non-destructive view of the withdrawal configuration kept * with each store receiving account. Updates remain in the source page so diff --git a/resources/views/business/pages/courier-payables/summary-table.blade.php b/resources/views/business/pages/courier-payables/summary-table.blade.php index 943a54e6..1f9eaa2f 100644 --- a/resources/views/business/pages/courier-payables/summary-table.blade.php +++ b/resources/views/business/pages/courier-payables/summary-table.blade.php @@ -43,7 +43,10 @@ @endif $courierSummarySourceTotal === 0]) @if ($courierSummarySourceTotal === 0) aria-disabled="true" tabindex="-1" @endif > diff --git a/tests/Feature/Business/CourierPayableSummaryPagesTest.php b/tests/Feature/Business/CourierPayableSummaryPagesTest.php index 2d66edf7..15acca2f 100644 --- a/tests/Feature/Business/CourierPayableSummaryPagesTest.php +++ b/tests/Feature/Business/CourierPayableSummaryPagesTest.php @@ -141,6 +141,61 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase ->assertSeeText('查询到 0 条 / 共 51 条'); } + public function test_each_summary_page_exports_only_filtered_rows_and_preserves_year_month_display(): void + { + $pages = [ + 'freight-difference' => ['结算月份', '快递公司'], + 'other-surcharge-summary' => ['日期', '快递公司'], + 'return-fee' => ['日期', '快递名称'], + ]; + + foreach ($pages as $page => [$dateHeader, $courierHeader]) { + $this->storeSnapshot($page, [ + $dateHeader, + '负责人', + $courierHeader, + '运单号码', + '备注', + ], [ + ['2026-07', '苏木', '测试中通', 'SF000000000000000001', '需要导出'], + ['2026-08', '苏木', '测试中通', 'SF000000000000000002', '其他月份'], + ['2026-07', '周生辰', '测试圆通', 'SF000000000000000003', '其他负责人'], + ]); + + $filters = [ + 'date' => '2026-07', + 'person' => '苏木', + 'courier' => '中通', + 'keyword' => '需要导出', + ]; + $exportUrl = route('business.courier-payable-summaries.export', [ + 'company_id' => $this->company->id, + 'summaryPage' => $page, + ...$filters, + ]); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'courier-payables', + 'page' => $page, + ...$filters, + ])) + ->assertOk() + ->assertSee(htmlspecialchars($exportUrl, ENT_QUOTES, 'UTF-8'), false); + + $response = $this->loginAs()->get($exportUrl); + $content = $response->streamedContent(); + $lines = preg_split('/\r\n|\r|\n/', trim(substr($content, 3))); + + $response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); + $this->assertCount(2, $lines); + $this->assertSame('="2026-07"', str_getcsv($lines[1])[0]); + $this->assertStringContainsString('SF000000000000000001', $content); + $this->assertStringNotContainsString('SF000000000000000002', $content); + $this->assertStringNotContainsString('SF000000000000000003', $content); + } + } + public function test_freight_difference_import_dialog_downloads_the_supplied_xlsx_template(): void { $pageResponse = $this->loginAs()->get(route('business.pages.show', [ @@ -561,7 +616,7 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase $response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); $this->assertStringContainsString('快递公司,结算月份,运费差,备注', $content); - $this->assertStringContainsString('测试快递,2026-08,-18.50,复核', $content); + $this->assertStringContainsString('测试快递,"=""2026-08""",-18.50,复核', $content); $this->assertStringContainsString("\t=待确认", $content); }