From 23be86c8170d3fcce0d4a6290ff57550b2beef3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Sat, 17 Oct 2026 11:12:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=EF=BC=9A=E5=8E=82=E5=AE=B6=E9=80=80=E8=B4=A7=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=AF=BC=E5=87=BA=E5=B9=B6=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/SupplierReconciliation.php | 72 ++++++- .../View/SupplierReconciliationPageData.php | 176 +++++++++++++++++- .../supplier-data.blade.php | 110 ++++++++--- routes/admin.php | 4 + .../Business/SupplierReconciliationTest.php | 174 ++++++++++++++++- 5 files changed, 501 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php index c4754279..ab1c7eff 100644 --- a/app/Http/Controllers/Business/SupplierReconciliation.php +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -10,7 +10,9 @@ use Modules\EcommerceFinance\Services\SupplierReturnConfirmation; use Modules\EcommerceFinance\Services\SupplierStatementStore; use Modules\EcommerceFinance\Services\SupplierStatementTemplate; use Modules\EcommerceFinance\View\SupplierReconciliationPageData; +use RuntimeException; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。 @@ -78,7 +80,7 @@ class SupplierReconciliation extends Controller [$supplier, $companyId] = $this->returnTarget($request); $keys = array_values(array_map( static fn (array $row): string => (string) ($row['_action_key'] ?? ''), - $this->pageData->returnRows($supplier, $companyId), + $this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()), )); $confirmed = $this->returnConfirmations->confirmAll( $supplier, @@ -90,6 +92,55 @@ class SupplierReconciliation extends Controller return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。"); } + public function exportReturns(Request $request): StreamedResponse + { + [$supplier, $companyId] = $this->returnTarget($request); + $rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query()); + $filenameSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家'; + $filename = $filenameSupplier . '_退货明细_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($rows): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new RuntimeException('无法创建导出文件。'); + } + + fwrite($csv, "\xEF\xBB\xBF"); + fputcsv($csv, [ + '发货时间', '明细供应商', '条形码', '商品名称', '发货量', '单价', '金额', + '供应商条码', '备注', '标题', 'wms配货单号', '仓库名称', '快递单号', '类型', '确认状态', + ]); + + foreach ($rows as $row) { + fputcsv($csv, array_map( + fn (mixed $value): string => $this->csvCell($value), + [ + $row['send_time'] ?? '', + $row['supplier'] ?? '', + $row['barcode'] ?? '', + $row['sku_name'] ?? '', + $row['qty'] ?? '', + $row['unit_price'] ?? '', + $row['amount'] ?? '', + $row['supplier_barcode'] ?? '', + $row['remark'] ?? '', + $row['title'] ?? '', + $row['wms_pick_no'] ?? '', + $row['warehouse'] ?? '', + $row['express_no'] ?? '', + '退货', + $this->returnStatusLabel((string) ($row['_status'] ?? '')), + ], + )); + } + + fclose($csv); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + private function markReturn(Request $request, string $status, string $message): RedirectResponse { $request->validate(['key' => ['required', 'string', 'max:191']]); @@ -112,4 +163,23 @@ class SupplierReconciliation extends Controller return [trim((string) $request->input('supplier')), (int) (company_id() ?: 1)]; } + + private function returnStatusLabel(string $status): string + { + return match ($status) { + SupplierReturnConfirmation::STATUS_CONFIRMED => '已确认', + SupplierReturnConfirmation::STATUS_DISPUTED => '异议中', + default => '待确认', + }; + } + + private function csvCell(mixed $value): string + { + $text = trim((string) $value); + if (is_numeric(str_replace(',', '', $text))) { + return $text; + } + + return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text; + } } diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php index d19fa4a5..79c8a214 100644 --- a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -151,8 +151,27 @@ class SupplierReconciliationPageData $page = max((int) request()->query('rows_page', 1), 1); $statementRows = $selected !== '' ? $this->statements->rows($selected, $companyId) : []; - $returnRows = $selected !== '' ? $this->returnRows($selected, $companyId) : []; - $returnPanel = $selected !== '' ? $this->returnConfirmationPanel($selected, $returnRows, $companyId) : null; + $allReturnRows = $selected !== '' ? $this->returnRows($selected, $companyId) : []; + $returnWarehouses = []; + foreach ($allReturnRows as $row) { + $warehouse = $this->filterText($row['warehouse'] ?? ''); + if ($warehouse !== '') { + $returnWarehouses[$warehouse] = true; + } + } + $returnWarehouses = array_keys($returnWarehouses); + sort($returnWarehouses, SORT_NATURAL | SORT_FLAG_CASE); + $returnFilters = $this->returnFilters(request()->query()); + $returnRows = $selected !== '' + ? $this->filterReturnRows( + $allReturnRows, + $returnFilters, + $this->returnConfirmations->statuses($selected, $companyId), + ) + : []; + $returnPanel = $selected !== '' + ? $this->returnConfirmationPanel($selected, $returnRows, $returnFilters, count($allReturnRows)) + : null; return [ 'view' => 'business.pages.supplier-reconciliation.supplier-data', @@ -162,6 +181,7 @@ class SupplierReconciliationPageData 'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), 'statementTotal' => count($statementRows), 'returnPanel' => $returnPanel, + 'returnWarehouses' => $returnWarehouses, 'rowsPage' => $page, 'perPage' => self::PER_PAGE, 'statementTemplateUrl' => route('business.supplier-reconciliation.template'), @@ -173,14 +193,14 @@ class SupplierReconciliationPageData } /** - * 退货扣减确认面板:共 N 行、合计扣减、异议暂不扣减;表格展示金额最高的 15 行。 + * 退货扣减确认面板:按筛选结果汇总并分页,保留导入顺序。 * * @param list> $returnRows + * @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters * @return array */ - private function returnConfirmationPanel(string $supplier, array $returnRows, int $companyId): array + private function returnConfirmationPanel(string $supplier, array $returnRows, array $filters, int $allRows): array { - $statuses = $this->returnConfirmations->statuses($supplier, $companyId); $total = 0.0; $confirmedAmount = 0.0; $disputedAmount = 0.0; @@ -191,7 +211,7 @@ class SupplierReconciliationPageData foreach ($returnRows as $row) { $key = (string) ($row['_action_key'] ?? ''); $deduction = $this->returnDeduction($row); - $status = $statuses[$key] ?? ''; + $status = (string) ($row['_status'] ?? ''); $total += $deduction; if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) { @@ -210,6 +230,8 @@ class SupplierReconciliationPageData $pages = max((int) ceil(count($rows) / $perPage), 1); $page = min(max((int) request()->query('return_page', 1), 1), $pages); + $filterQuery = $this->returnFilterQuery($filters); + return [ 'supplier' => $supplier, 'rows' => array_slice($rows, ($page - 1) * $perPage, $perPage), @@ -217,11 +239,15 @@ class SupplierReconciliationPageData 'pages' => $pages, 'per_page' => $perPage, 'total_rows' => count($returnRows), + 'all_rows' => $allRows, 'total_amount' => round($total, 2), 'confirmed_count' => $confirmedCount, 'confirmed_amount' => round($confirmedAmount, 2), 'disputed_count' => $disputedCount, 'disputed_amount' => round($disputedAmount, 2), + 'filters' => $filters, + 'filter_query' => $filterQuery, + 'is_filtered' => $filterQuery !== [], ]; } @@ -268,6 +294,144 @@ class SupplierReconciliationPageData )); } + /** + * 厂家端当前查询条件下的全部退货行(用于导出,不分页)。 + * + * @param array $input + * @return list> + */ + public function returnRowsForFilters(string $supplier, int $companyId, array $input): array + { + return $this->filterReturnRows( + $this->returnRows($supplier, $companyId), + $this->returnFilters($input), + $this->returnConfirmations->statuses($supplier, $companyId), + ); + } + + /** + * 解析厂家退货查询条件,空值会被忽略。 + * + * @param array $input + * @return array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} + */ + public function returnFilters(array $input): array + { + $dateFrom = $this->normalizeFilterDate($input['return_date_from'] ?? ''); + $dateTo = $this->normalizeFilterDate($input['return_date_to'] ?? ''); + if ($dateFrom !== '' && $dateTo !== '' && $dateFrom > $dateTo) { + [$dateFrom, $dateTo] = [$dateTo, $dateFrom]; + } + + $status = $this->filterText($input['return_status'] ?? ''); + if (! in_array($status, ['pending', SupplierReturnConfirmation::STATUS_CONFIRMED, SupplierReturnConfirmation::STATUS_DISPUTED], true)) { + $status = ''; + } + + return [ + 'keyword' => $this->filterText($input['return_keyword'] ?? ''), + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'warehouse' => $this->filterText($input['return_warehouse'] ?? ''), + 'status' => $status, + ]; + } + + /** + * @param list> $rows + * @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters + * @param array $statuses + * @return list> + */ + private function filterReturnRows(array $rows, array $filters, array $statuses): array + { + $filtered = []; + foreach ($rows as $row) { + $key = (string) ($row['_action_key'] ?? ''); + $status = $statuses[$key] ?? ''; + + if ($filters['status'] !== '') { + $matchesStatus = $filters['status'] === 'pending' + ? $status === '' + : $status === $filters['status']; + if (! $matchesStatus) { + continue; + } + } + + $rowDate = $this->normalizeFilterDate($row['send_time'] ?? ''); + if ($filters['date_from'] !== '' && ($rowDate === '' || $rowDate < $filters['date_from'])) { + continue; + } + if ($filters['date_to'] !== '' && ($rowDate === '' || $rowDate > $filters['date_to'])) { + continue; + } + + if ( + $filters['warehouse'] !== '' + && $this->filterText($row['warehouse'] ?? '') !== $filters['warehouse'] + ) { + continue; + } + + if ($filters['keyword'] !== '') { + $haystack = implode("\n", [ + $row['send_time'] ?? '', + $row['supplier'] ?? '', + $row['barcode'] ?? '', + $row['sku_name'] ?? '', + $row['supplier_barcode'] ?? '', + $row['remark'] ?? '', + $row['title'] ?? '', + $row['wms_pick_no'] ?? '', + $row['warehouse'] ?? '', + $row['express_no'] ?? '', + ]); + if (stripos($haystack, $filters['keyword']) === false) { + continue; + } + } + + $row['_status'] = $status; + $filtered[] = $row; + } + + return $filtered; + } + + /** @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters */ + private function returnFilterQuery(array $filters): array + { + return array_filter([ + 'return_keyword' => $filters['keyword'], + 'return_date_from' => $filters['date_from'], + 'return_date_to' => $filters['date_to'], + 'return_warehouse' => $filters['warehouse'], + 'return_status' => $filters['status'], + ], static fn (string $value): bool => $value !== ''); + } + + private function filterText(mixed $value): string + { + return is_scalar($value) ? trim((string) $value) : ''; + } + + private function normalizeFilterDate(mixed $value): string + { + $raw = $this->filterText($value); + if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) !== 1) { + return ''; + } + + $year = (int) $matches[1]; + $month = (int) $matches[2]; + $day = (int) $matches[3]; + + return checkdate($month, $day, $year) + ? sprintf('%04d-%02d-%02d', $year, $month, $day) + : ''; + } + private function companyId(): int { return (int) (company_id() ?: 1); diff --git a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php index d4071706..f3d3f32c 100644 --- a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php @@ -6,7 +6,9 @@ .supplier-reconciliation-page .sr-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; } .supplier-reconciliation-page .sr-field { display: grid; gap: 4px; } .supplier-reconciliation-page .sr-field span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; } - .supplier-reconciliation-page select { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; } + .supplier-reconciliation-page select, + .supplier-reconciliation-page input[type="text"], + .supplier-reconciliation-page input[type="date"] { width: 100%; height: 36px; min-width: 0; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; } .supplier-reconciliation-page .sr-button { display: inline-flex; height: 36px; align-items: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; } .supplier-reconciliation-page .sr-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; } .supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; } @@ -28,7 +30,15 @@ } .supplier-reconciliation-page .sr-return-banner .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 18px; } .supplier-reconciliation-page .sr-return-status { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--vip-content-muted, #595959); font-size: .75rem; } - .supplier-reconciliation-page .sr-return-table { min-width: 1900px; } + .supplier-reconciliation-page .sr-return-table { min-width: 1420px; } + .supplier-reconciliation-page .sr-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; } + .supplier-reconciliation-page .sr-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; } + @media (max-width: 960px) { + .supplier-reconciliation-page .sr-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); } + } + @media (max-width: 560px) { + .supplier-reconciliation-page .sr-filter { grid-template-columns: minmax(0, 1fr); } + } .supplier-reconciliation-page .sr-type-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; background: #EFEAE0; color: #595959; font-size: .75rem; white-space: nowrap; @@ -136,11 +146,11 @@ $pageCount = max((int) ceil($statementTotal / $perPage), 1); $pageStart = max(1, min($rowsPage - 4, $pageCount - 8)); $pageEnd = min($pageCount, $pageStart + 8); - $pageUrl = fn (int $number): string => route('business.pages.show', array_filter([ + $pageUrl = fn (int $number): string => route('business.pages.show', array_merge($returnPanel['filter_query'], array_filter([ 'section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier, 'rows_page' => $number, 'return_page' => $returnPanel['page'] > 1 ? $returnPanel['page'] : null, - ], static fn (mixed $value): bool => $value !== null && $value !== '')); + ], static fn (mixed $value): bool => $value !== null && $value !== ''))); @endphp
共 {{ number_format($statementTotal) }} 行,每页 {{ $perPage }} 行 · 第 {{ $rowsPage }} / {{ $pageCount }} 页 @@ -171,8 +181,14 @@
- ① 退货扣减确认(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。 - 核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。 + ① 退货扣减确认 + @if ($returnPanel['is_filtered']) + (筛选出 {{ number_format($returnPanel['total_rows']) }} 行,全部 {{ number_format($returnPanel['all_rows']) }} 行,筛选结果合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。 + 如当前筛选结果核对无误,可点「全部确认当前筛选」;其他退货行不受影响。 + @else + (共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。 + 核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。 + @endif
@@ -180,23 +196,63 @@ · 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }}) · 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行
+
+ + + + + + + +
- - - - + + + - - + @forelse ($returnPanel['rows'] as $row) - @@ -204,17 +260,12 @@ - - - - - @empty - + @endforelse
月份发货时间明细供应商条形码商品名称发货量单价金额预估单价预估金额供应商条码备注发货时间明细供应商条形码商品名称发货量单价金额供应商条码备注 标题wms配货单号仓库名称快递单号标记多标签创建人对账人类型操作快递单号类型操作
{{ $row['summary_month'] ?? '—' }} {{ $row['send_time'] ?? '—' }} {{ $row['supplier'] ?? '—' }} {{ $row['barcode'] ?? '—' }}{{ $row['qty'] ?? '—' }} {{ $row['unit_price'] ?? '—' }} {{ $row['amount'] ?? '—' }}{{ $row['guess_unit_price'] ?? '—' }}{{ $row['guess_amount'] ?? '—' }} {{ $row['supplier_barcode'] ?? '—' }} {{ $row['remark'] ?? '—' }} {{ $row['title'] ?? '—' }} {{ $row['wms_pick_no'] ?? '—' }} {{ $row['warehouse'] ?? '—' }} {{ $row['express_no'] ?? '—' }}{{ $row['labels'] ?? '—' }}{{ $row['creator'] ?? '—' }}{{ $row['reconciler'] ?? '—' }} 退货 @@ -240,15 +291,19 @@
该厂家暂无退货数据。
没有符合当前查询条件的退货数据。

- 列名与「供应商应付(终版)→ 退货对账」的退货明细表头一致,左右滑动查看全部。 - 退货共 {{ number_format($returnPanel['total_rows']) }} 行,按导入顺序展示,每页 {{ $returnPanel['per_page'] }} 行(第 {{ $returnPanel['page'] }} / {{ $returnPanel['pages'] }} 页)。 - 金额列为空的行以预估金额参与扣减,与采购方记账口径一致。 + 厂家端只展示核对退货所需的字段,左右滑动查看全部。 + @if ($returnPanel['is_filtered']) + 当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行(全部 {{ number_format($returnPanel['all_rows']) }} 行),导出包含当前查询条件下的全部数据。 + @else + 退货共 {{ number_format($returnPanel['total_rows']) }} 行,导出包含该厂家的全部退货数据。 + @endif + 按导入顺序展示,每页 {{ $returnPanel['per_page'] }} 行(第 {{ $returnPanel['page'] }} / {{ $returnPanel['pages'] }} 页)。

@if ($returnPanel['pages'] > 1) @php @@ -256,13 +311,13 @@ $returnPageCurrent = $returnPanel['page']; $returnPageStart = max(1, min($returnPageCurrent - 4, $returnPageCount - 8)); $returnPageEnd = min($returnPageCount, $returnPageStart + 8); - $returnPageUrl = fn (int $number): string => route('business.pages.show', array_filter([ + $returnPageUrl = fn (int $number): string => route('business.pages.show', array_merge($returnPanel['filter_query'], array_filter([ 'section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier, 'rows_page' => $rowsPage > 1 ? $rowsPage : null, 'return_page' => $number, - ], static fn (mixed $value): bool => $value !== null && $value !== '')); + ], static fn (mixed $value): bool => $value !== null && $value !== ''))); @endphp
第 {{ $returnPageCurrent }} / {{ $returnPageCount }} 页(每页 {{ $returnPanel['per_page'] }} 行) @@ -291,7 +346,12 @@
@csrf - + @foreach ($returnPanel['filter_query'] as $filterName => $filterValue) + + @endforeach +
确认后退货扣减生效,明细列入结算确认函。
diff --git a/routes/admin.php b/routes/admin.php index 44c1f4bf..288db41e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -395,6 +395,10 @@ Route::post('workspace/supplier-reconciliation/statement', [SupplierReconciliati ->middleware(['business.page:supplier-reconciliation,supplier-data', 'import']) ->name('business.supplier-reconciliation.statement.upload'); +Route::get('workspace/supplier-reconciliation/returns/export', [SupplierReconciliationActions::class, 'exportReturns']) + ->middleware('business.page:supplier-reconciliation,supplier-data') + ->name('business.supplier-reconciliation.returns.export'); + // 退货扣减确认:逐行确认/异议 + 一键全部确认 Route::post('workspace/supplier-reconciliation/returns/confirm', [SupplierReconciliationActions::class, 'confirmReturn']) ->middleware('business.page:supplier-reconciliation,supplier-data') diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php index 915374c3..30189ec1 100644 --- a/tests/Feature/Business/SupplierReconciliationTest.php +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -118,7 +118,7 @@ class SupplierReconciliationTest extends FeatureTestCase null, ); - $this->loginAs() + $response = $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', @@ -131,11 +131,24 @@ class SupplierReconciliationTest extends FeatureTestCase ->assertSeeText('合计扣减') ->assertSeeText('全部确认无异议') ->assertSeeText('待确认') - ->assertSeeText('月份') ->assertSeeText('条形码') ->assertSeeText('商品名称') ->assertSeeText('发货时间') - ->assertSeeText('ZSJ-1'); + ->assertSeeText('ZSJ-1') + ->assertSeeText('查询') + ->assertSeeText('导出当前筛选结果'); + + $html = (string) $response->getContent(); + $tableStart = strpos($html, ''); + $tableEnd = $tableStart === false ? false : strpos($html, '
', $tableStart); + $this->assertNotFalse($tableStart, '厂家退货表应正常渲染。'); + $this->assertNotFalse($tableEnd, '厂家退货表应正常结束。'); + + $returnTable = substr($html, $tableStart, $tableEnd - $tableStart); + $this->assertSame(15, substr_count($returnTable, '')); + foreach (['月份', '预估单价', '预估金额', '标记多标签', '创建人', '对账人'] as $hiddenColumn) { + $this->assertStringNotContainsString('' . $hiddenColumn . '', $returnTable); + } } public function test_statement_rows_are_paginated_fifteen_per_page(): void @@ -199,6 +212,161 @@ class SupplierReconciliationTest extends FeatureTestCase ->assertDontSeeText('RT-01'); } + public function test_supplier_return_rows_can_be_queried_and_pagination_keeps_filters(): void + { + $store = app(SupplierPayableOverrideStore::class); + $companyId = (int) $this->company->id; + for ($i = 1; $i <= 20; $i++) { + $store->save('final:supplier:return-reconciliation', sprintf('match:%02d', $i), [ + 'summary_month' => '2026-09', + 'send_time' => $i <= 15 ? '2026-09-20 10:00:00' : '2026-09-21 10:00:00', + 'supplier' => '晨丰', + 'barcode' => sprintf('MATCH-%02d', $i), + 'sku_name' => '查询商品', + 'qty' => '1', + 'unit_price' => '100', + 'amount' => '100', + 'warehouse' => '客退临时上架仓', + 'express_no' => sprintf('SF%018d', $i), + ], $companyId, null); + } + $store->save('final:supplier:return-reconciliation', 'other:01', [ + 'summary_month' => '2026-09', + 'send_time' => '2026-09-20 09:00:00', + 'supplier' => '晨丰', + 'barcode' => 'OTHER-01', + 'sku_name' => '其他商品', + 'qty' => '1', + 'unit_price' => '100', + 'amount' => '100', + 'warehouse' => '其他仓', + ], $companyId, null); + app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class) + ->mark('晨丰', 'match:20', 'confirmed', $companyId, null); + $this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '1', '128.00')]); + + $filters = [ + 'section' => 'supplier-reconciliation', + 'page' => 'supplier-data', + 'supplier' => '晨丰', + 'return_keyword' => 'MATCH', + 'return_date_from' => '2026-09-20', + 'return_date_to' => '2026-09-21', + 'return_warehouse' => '客退临时上架仓', + 'return_status' => 'pending', + ]; + + $this->loginAs()->get(route('business.pages.show', $filters)) + ->assertOk() + ->assertSeeText('筛选出 19 行,全部 21 行') + ->assertSeeText('MATCH-01') + ->assertDontSeeText('MATCH-20') + ->assertDontSeeText('OTHER-01') + ->assertSeeText('导出当前筛选结果(19 行)'); + + $this->loginAs()->get(route('business.pages.show', $filters + ['return_page' => 2])) + ->assertOk() + ->assertSeeText('MATCH-19') + ->assertDontSeeText('MATCH-01') + ->assertSee('return_keyword=MATCH', false) + ->assertSee('return_status=pending', false); + } + + public function test_confirm_all_only_applies_to_the_current_return_filter(): void + { + $store = app(SupplierPayableOverrideStore::class); + $companyId = (int) $this->company->id; + foreach ([ + 'return:filtered' => 'FILTER-ONE', + 'return:other' => 'FILTER-TWO', + ] as $key => $barcode) { + $store->save('final:supplier:return-reconciliation', $key, [ + 'summary_month' => '2026-09', + 'send_time' => '2026-09-20', + 'supplier' => '晨丰', + 'barcode' => $barcode, + 'qty' => '1', + 'unit_price' => '100', + 'amount' => '100', + ], $companyId, null); + } + $this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '1', '128.00')]); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.return.confirm-all'), [ + 'supplier' => '晨丰', + 'return_keyword' => 'FILTER-ONE', + ]) + ->assertStatus(302) + ->assertSessionHas('success', '已全部确认:1 行退货扣减生效(异议行保持暂不扣减)。'); + + $statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class) + ->statuses('晨丰', $companyId); + $this->assertSame('confirmed', $statuses['return:filtered']); + $this->assertArrayNotHasKey('return:other', $statuses); + } + + public function test_supplier_return_export_includes_all_filtered_rows_without_finance_columns(): void + { + $store = app(SupplierPayableOverrideStore::class); + $companyId = (int) $this->company->id; + for ($i = 1; $i <= 20; $i++) { + $store->save('final:supplier:return-reconciliation', sprintf('export:%02d', $i), [ + 'summary_month' => '2026-09', + 'send_time' => '2026-09-20 10:00:00', + 'supplier' => '晨丰', + 'barcode' => sprintf('EXPORT-%02d', $i), + 'sku_name' => '导出商品', + 'qty' => '2', + 'unit_price' => '50', + 'amount' => '100', + 'supplier_barcode' => sprintf('SUPPLIER-%02d', $i), + 'warehouse' => '客退临时上架仓', + 'express_no' => sprintf('SF%018d', $i), + 'creator' => '财务甲', + 'reconciler' => '财务乙', + 'labels' => '财务标记', + ], $companyId, null); + } + $store->save('final:supplier:return-reconciliation', 'export:other', [ + 'summary_month' => '2026-10', + 'send_time' => '2026-10-01', + 'supplier' => '晨丰', + 'barcode' => 'EXPORT-OTHER', + 'qty' => '1', + 'unit_price' => '50', + 'amount' => '50', + 'warehouse' => '其他仓', + ], $companyId, null); + + $response = $this->loginAs()->get(route('business.supplier-reconciliation.returns.export', [ + 'supplier' => '晨丰', + 'return_keyword' => 'EXPORT-', + 'return_date_from' => '2026-09-01', + 'return_date_to' => '2026-09-30', + 'return_warehouse' => '客退临时上架仓', + ])); + $content = $response->streamedContent(); + + $response->assertOk() + ->assertDownload() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $lines = preg_split('/\r\n|\r|\n/', trim(substr($content, 3))); + $this->assertCount(21, $lines, '导出应包含全部 20 条筛选结果及 1 行表头。'); + $this->assertSame([ + '发货时间', '明细供应商', '条形码', '商品名称', '发货量', '单价', '金额', + '供应商条码', '备注', '标题', 'wms配货单号', '仓库名称', '快递单号', '类型', '确认状态', + ], str_getcsv($lines[0])); + $this->assertStringContainsString('EXPORT-01', $content); + $this->assertStringContainsString('EXPORT-20', $content); + $this->assertStringNotContainsString('EXPORT-OTHER', $content); + + foreach (['月份', '预估单价', '预估金额', '标记多标签', '创建人', '对账人', '财务甲', '财务乙', '财务标记'] as $financeValue) { + $this->assertStringNotContainsString($financeValue, $content); + } + } + public function test_return_rows_can_be_confirmed_disputed_and_confirmed_all(): void { $store = app(SupplierPayableOverrideStore::class); -- 2.47.2