供应商对账:厂家退货支持查询导出并精简字段 #375

Merged
renshen merged 1 commits from feature/sumu-return-query-export into main 2026-10-17 11:13:19 +08:00
5 changed files with 501 additions and 35 deletions

View File

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

View File

@ -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<array<string,mixed>> $returnRows
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
* @return array<string,mixed>
*/
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<string,mixed> $input
* @return list<array<string,mixed>>
*/
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<string,mixed> $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<array<string,mixed>> $rows
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
* @param array<string,string> $statuses
* @return list<array<string,mixed>>
*/
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);

View File

@ -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
<div class="sr-pagination">
<span>共 {{ number_format($statementTotal) }} 行,每页 {{ $perPage }} 行 · 第 {{ $rowsPage }} / {{ $pageCount }} 页</span>
@ -171,8 +181,14 @@
<div class="sr-return-banner" role="note">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>
<strong>① 退货扣减确认</strong>(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。
<strong>① 退货扣减确认</strong>
@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
</span>
</div>
<div class="sr-return-status">
@ -180,23 +196,63 @@
<span>· 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
<span>· 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
</div>
<form method="GET"
action="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
class="sr-filter"
aria-label="查询退货明细">
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<label class="sr-field"><span>关键字</span>
<input type="text" name="return_keyword"
value="{{ $returnPanel['filters']['keyword'] }}"
maxlength="100"
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
</label>
<label class="sr-field"><span>发货日期起</span>
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
</label>
<label class="sr-field"><span>发货日期止</span>
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
</label>
<label class="sr-field"><span>仓库名称</span>
<select name="return_warehouse">
<option value="">全部仓库</option>
@foreach ($returnWarehouses as $warehouse)
<option value="{{ $warehouse }}" @selected($returnPanel['filters']['warehouse'] === $warehouse)>{{ $warehouse }}</option>
@endforeach
</select>
</label>
<label class="sr-field"><span>确认状态</span>
<select name="return_status">
<option value="">全部状态</option>
<option value="pending" @selected($returnPanel['filters']['status'] === 'pending')>待确认</option>
<option value="confirmed" @selected($returnPanel['filters']['status'] === 'confirmed')>已确认</option>
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
</select>
</label>
<div class="sr-filter-actions">
<button type="submit" class="sr-button is-primary">查询</button>
<a class="sr-button"
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier]) }}">重置</a>
<a class="sr-button"
href="{{ route('business.supplier-reconciliation.returns.export', array_merge(['supplier' => $selectedSupplier], $returnPanel['filter_query'])) }}">
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
</a>
</div>
</form>
<div class="sr-scroll">
<table class="sr-return-table">
<thead>
<tr>
<th scope="col">月份</th><th scope="col">发货时间</th><th scope="col">明细供应商</th>
<th scope="col">条形码</th><th scope="col">商品名称</th><th scope="col">发货量</th>
<th scope="col">单价</th><th scope="col">金额</th><th scope="col">预估单价</th>
<th scope="col">预估金额</th><th scope="col">供应商条码</th><th scope="col">备注</th>
<th scope="col">发货时间</th><th scope="col">明细供应商</th><th scope="col">条形码</th>
<th scope="col">商品名称</th><th scope="col">发货量</th><th scope="col">单价</th>
<th scope="col">金额</th><th scope="col">供应商条码</th><th scope="col">备注</th>
<th scope="col">标题</th><th scope="col">wms配货单号</th><th scope="col">仓库名称</th>
<th scope="col">快递单号</th><th scope="col">标记多标签</th><th scope="col">创建人</th>
<th scope="col">对账人</th><th scope="col">类型</th><th scope="col">操作</th>
<th scope="col">快递单号</th><th scope="col">类型</th><th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse ($returnPanel['rows'] as $row)
<tr>
<td>{{ $row['summary_month'] ?? '—' }}</td>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
@ -204,17 +260,12 @@
<td class="is-num">{{ $row['qty'] ?? '—' }}</td>
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
<td class="is-num">{{ $row['guess_unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['guess_amount'] ?? '—' }}</td>
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
<td>{{ $row['remark'] ?? '—' }}</td>
<td>{{ $row['title'] ?? '—' }}</td>
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
<td>{{ $row['warehouse'] ?? '—' }}</td>
<td>{{ $row['express_no'] ?? '—' }}</td>
<td>{{ $row['labels'] ?? '—' }}</td>
<td>{{ $row['creator'] ?? '—' }}</td>
<td>{{ $row['reconciler'] ?? '—' }}</td>
<td><span class="sr-type-tag">退货</span></td>
<td>
<span class="sr-return-actions">
@ -240,15 +291,19 @@
</td>
</tr>
@empty
<tr><td colspan="21"><div class="sr-empty">该厂家暂无退货数据。</div></td></tr>
<tr><td colspan="15"><div class="sr-empty">没有符合当前查询条件的退货数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
<p class="sr-hint">
列名与「供应商应付(终版)→ 退货对账」的退货明细表头一致,左右滑动查看全部。
退货共 {{ 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'] }} 页)。
</p>
@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
<div class="sr-pagination">
<span>第 {{ $returnPageCurrent }} / {{ $returnPageCount }} 页(每页 {{ $returnPanel['per_page'] }} 行)</span>
@ -291,7 +346,12 @@
<form method="POST" action="{{ $returnConfirmAllUrl }}" class="sr-inline-form">
@csrf
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<button type="submit" class="sr-button is-primary">全部确认无异议</button>
@foreach ($returnPanel['filter_query'] as $filterName => $filterValue)
<input type="hidden" name="{{ $filterName }}" value="{{ $filterValue }}">
@endforeach
<button type="submit" class="sr-button is-primary">
{{ $returnPanel['is_filtered'] ? '全部确认当前筛选' : '全部确认无异议' }}
</button>
</form>
<span class="sr-hint">确认后退货扣减生效,明细列入结算确认函。</span>
</div>

View File

@ -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')

View File

@ -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, '<table class="sr-return-table">');
$tableEnd = $tableStart === false ? false : strpos($html, '</table>', $tableStart);
$this->assertNotFalse($tableStart, '厂家退货表应正常渲染。');
$this->assertNotFalse($tableEnd, '厂家退货表应正常结束。');
$returnTable = substr($html, $tableStart, $tableEnd - $tableStart);
$this->assertSame(15, substr_count($returnTable, '<th scope="col">'));
foreach (['月份', '预估单价', '预估金额', '标记多标签', '创建人', '对账人'] as $hiddenColumn) {
$this->assertStringNotContainsString('<th scope="col">' . $hiddenColumn . '</th>', $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);