供应商对账:厂家退货支持查询导出并精简字段 #375
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user