fix: 按筛选条件导出快递汇总
This commit is contained in:
parent
f40c102a7b
commit
aab245c8b5
120
app/Business/CourierPayableSummaryFilter.php
Normal file
120
app/Business/CourierPayableSummaryFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user