fix: 按筛选条件导出快递汇总

This commit is contained in:
苏木 2026-09-21 10:56:46 +08:00
parent f40c102a7b
commit aab245c8b5
5 changed files with 209 additions and 114 deletions

View File

@ -0,0 +1,120 @@
<?php
namespace App\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class CourierPayableSummaryFilter
{
/** @return array{date:string,person:string,courier:string,keyword:string} */
public function fromRequest(Request $request): array
{
$clean = static function (string $key, int $maxLength) use ($request): 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 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<int, mixed> $rows
* @param list<string> $headers
* @param array{date:string,person:string,courier:string,keyword:string} $filters
* @return Collection<int, mixed>
*/
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<string> $headers */
public function dateColumnIndex(array $headers): ?int
{
return $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
}
/** @param list<string> $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;
}
}

View File

@ -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;
}

View File

@ -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<int, mixed> $rows
* @param list<string> $headers
* @param array{date:string,person:string,courier:string,keyword:string} $filters
* @return Collection<int, mixed>
*/
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<string> $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

View File

@ -43,7 +43,10 @@
</button>
@endif
<a
href="{{ route('business.courier-payable-summaries.export', ['summaryPage' => $courierSummaryPage]) }}"
href="{{ route('business.courier-payable-summaries.export', array_filter([
'summaryPage' => $courierSummaryPage,
...$courierSummaryFilters,
], static fn ($value) => $value !== '')) }}"
@class(['courier-summary__button', 'is-disabled' => $courierSummarySourceTotal === 0])
@if ($courierSummarySourceTotal === 0) aria-disabled="true" tabindex="-1" @endif
>

View File

@ -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);
}