feat: 供应商列表分页并增加整月退货推送 (#847)

This commit is contained in:
renshen 2026-11-04 10:08:50 +08:00
parent 661fbca7d3
commit 0ed02e95f7
4 changed files with 262 additions and 18 deletions

View File

@ -25,7 +25,31 @@ class SupplierDingTalkNotifier
/** @return array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int} */
public function configuration(string $supplier): array
{
$config = $this->configs()[$supplier] ?? null;
return $this->configurationFrom($this->configs()[$supplier] ?? null);
}
/**
* 一次读取配置并返回多个厂家的推送状态,避免供应商列表逐行查询。
*
* @param list<string> $suppliers
* @return array<string,array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int}>
*/
public function configurationMap(array $suppliers): array
{
$configs = $this->configs();
$result = [];
foreach (array_unique(array_filter(array_map('trim', $suppliers))) as $supplier) {
$result[$supplier] = $this->configurationFrom($configs[$supplier] ?? null);
}
return $result;
}
/**
* @return array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int}
*/
private function configurationFrom(mixed $config): array
{
$webhook = is_array($config) ? trim((string) ($config['webhook'] ?? '')) : '';
return [

View File

@ -27,6 +27,9 @@ class SupplierReconciliationPageData
/** 财务端比对明细:每页 15 条。 */
private const COMPARISON_PER_PAGE = 15;
/** 财务端供应商列表:每页 15 家。 */
private const SUPPLIER_PER_PAGE = 15;
public function __construct(
private readonly SupplierPayableOverrideStore $store,
private readonly SupplierStatementStore $statements,
@ -46,8 +49,24 @@ class SupplierReconciliationPageData
PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation',
$companyId,
);
$returnRows = $this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
);
$months = [];
$returnSupplierMonths = [];
foreach ($returnRows as $row) {
$returnMonth = $this->returnRowMonth($row);
$supplier = trim((string) ($row['supplier'] ?? ''));
if ($returnMonth !== '') {
$months[$returnMonth] = true;
}
if ($returnMonth !== '' && $supplier !== '') {
$returnSupplierMonths[$supplier][$returnMonth] =
(int) ($returnSupplierMonths[$supplier][$returnMonth] ?? 0) + 1;
}
}
foreach ($mine as $row) {
$month = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7);
if ($month !== '') {
@ -95,10 +114,7 @@ class SupplierReconciliationPageData
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
$suppliers[$name] = true;
}
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
foreach ($returnRows as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
@ -107,16 +123,27 @@ class SupplierReconciliationPageData
$supplierNames = array_keys($suppliers);
sort($supplierNames);
$uploads = [];
foreach ($supplierNames as $name) {
$latest = $this->statements->uploads($name, $companyId, 1);
$uploads[$name] = $latest[0] ?? null;
}
$supplierTotal = count($supplierNames);
$supplierPages = max((int) ceil($supplierTotal / self::SUPPLIER_PER_PAGE), 1);
$supplierPage = min(max((int) request()->query('supplier_page', 1), 1), $supplierPages);
$supplierListRows = array_slice(
$supplierNames,
($supplierPage - 1) * self::SUPPLIER_PER_PAGE,
self::SUPPLIER_PER_PAGE,
);
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
$uploads = [];
$uploadSuppliers = $selected !== '' ? [$selected] : $supplierListRows;
foreach ($uploadSuppliers as $name) {
$latest = $this->statements->uploads($name, $companyId, 1);
$uploads[$name] = $latest[0] ?? null;
}
$detail = null;
if ($selected !== '') {
$detail = $this->detail($mine, $selected, $companyId);
@ -133,11 +160,39 @@ class SupplierReconciliationPageData
self::COMPARISON_PER_PAGE,
);
}
$returnPushMonth = $month !== '' ? $month : now()->format('Y-m');
$returnLatestMonths = [];
$returnDisplayMonths = [];
$returnCounts = [];
foreach ($supplierListRows as $supplier) {
$supplierMonths = array_keys((array) ($returnSupplierMonths[$supplier] ?? []));
rsort($supplierMonths);
$latestMonth = (string) ($supplierMonths[0] ?? '');
$displayMonth = $month !== '' ? $month : $latestMonth;
$returnLatestMonths[$supplier] = $latestMonth;
$returnDisplayMonths[$supplier] = $displayMonth;
$returnCounts[$supplier] = (int) ($returnSupplierMonths[$supplier][$displayMonth] ?? 0);
}
$selectedMonths = array_keys((array) ($returnSupplierMonths[$selected] ?? []));
rsort($selectedMonths);
$selectedLatestReturnMonth = (string) ($selectedMonths[0] ?? '');
$returnPushMonth = $month !== ''
? $month
: ($selectedLatestReturnMonth !== '' ? $selectedLatestReturnMonth : ($months[0] ?? now()->format('Y-m')));
$robotSuppliers = $selected !== '' ? [$selected] : $supplierListRows;
$robotConfigurations = $this->dingTalkNotifier->configurationMap($robotSuppliers);
return [
'view' => 'business.pages.supplier-reconciliation.finance-data',
'supplierNames' => $supplierNames,
'supplierListRows' => $supplierListRows,
'supplierTotal' => $supplierTotal,
'supplierPages' => $supplierPages,
'supplierPage' => $supplierPage,
'supplierPerPage' => self::SUPPLIER_PER_PAGE,
'supplierQuery' => $supplierQuery,
'supplierNotFound' => $supplierNotFound,
'uploads' => $uploads,
@ -151,12 +206,17 @@ class SupplierReconciliationPageData
? $this->financeReturnPanel($selected, $companyId, $month, $reconciler)
: null,
'returnPushMonth' => $returnPushMonth,
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
'returnLatestMonths' => $returnLatestMonths,
'returnDisplayMonths' => $returnDisplayMonths,
'returnCounts' => $returnCounts,
'robot' => $selected !== '' ? ($robotConfigurations[$selected] ?? null) : null,
'robotConfigurations' => $robotConfigurations,
'listUrl' => route('business.pages.show', array_filter([
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'month' => $month,
'reconciler' => $reconciler,
'supplier_page' => $supplierPage > 1 ? $supplierPage : null,
], static fn (mixed $value): bool => $value !== '' && $value !== null)),
'statementTemplateUrl' => route('business.supplier-reconciliation.template'),
'uploadUrl' => route('business.supplier-reconciliation.statement.upload'),

View File

@ -15,6 +15,8 @@
.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-button.is-disabled { opacity: .55; cursor: not-allowed; }
.supplier-reconciliation-page .sr-button.is-small { height: 26px; padding: 0 9px; font-size: .75rem; }
.supplier-reconciliation-page .sr-list-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.supplier-reconciliation-page .sr-list-actions form { margin: 0; }
.supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; }
.supplier-reconciliation-page .sr-scroll { min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
.supplier-reconciliation-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; }
@ -234,33 +236,105 @@
@else
<div class="sr-card">
<h2>各厂家上传情况(实时)</h2>
<p class="sr-hint">
每页显示 15 家。选择具体月份后,退货推送按该月份统计;全部月份下优先显示各厂家最新有数据的退货月份。
整月退货会包含待确认、已确认和异议中记录,并自动按钉钉消息容量分批发送。
</p>
<div class="sr-scroll">
<table>
<table data-finance-supplier-list>
<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>
</tr>
</thead>
<tbody>
@forelse ($supplierNames as $supplier)
@php $upload = $uploads[$supplier] ?? null; @endphp
@forelse ($supplierListRows as $supplier)
@php
$upload = $uploads[$supplier] ?? null;
$returnMonth = (string) ($returnDisplayMonths[$supplier] ?? '');
$returnCount = (int) ($returnCounts[$supplier] ?? 0);
$robotConfigured = (bool) ($robotConfigurations[$supplier]['configured'] ?? false);
$detailUrl = route('business.pages.show', array_filter([
'section' => $section['slug'],
'page' => $page['slug'],
'month' => $month,
'reconciler' => $reconciler,
'supplier' => $supplier,
'supplier_page' => $supplierPage > 1 ? $supplierPage : null,
], static fn (mixed $value): bool => $value !== '' && $value !== null));
@endphp
<tr>
<td>{{ $supplier }}</td>
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
<td class="is-num">{{ $upload ? number_format((int) ($upload['rows'] ?? 0)) : '—' }}</td>
<td class="sr-muted">{{ $returnMonth !== '' ? $returnMonth : '—' }}</td>
<td class="is-num">{{ $returnCount > 0 ? number_format($returnCount) : '—' }}</td>
<td>
<a class="sr-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'month' => $month, 'reconciler' => $reconciler, 'supplier' => $supplier]) }}">查看比对</a>
<div class="sr-list-actions">
<a class="sr-button is-small" href="{{ $detailUrl }}">查看比对</a>
@if ($returnCount > 0)
@if ($robotConfigured)
<form method="POST"
action="{{ route('business.supplier-reconciliation.push-returns') }}"
onsubmit="return confirm('确认将该厂家该月全部退货推送到钉钉群?')">
@csrf
<input type="hidden" name="supplier" value="{{ $supplier }}">
<input type="hidden" name="month" value="{{ $returnMonth }}">
<button type="submit" class="sr-button is-primary is-small">一键推送退货</button>
</form>
@else
<a class="sr-button is-small" href="{{ $detailUrl }}#dingtalk-robot-config">配置机器人</a>
@endif
@endif
</div>
</td>
</tr>
@empty
<tr><td colspan="4"><div class="sr-empty">还没有可对账的供应商:请先在「供应商应付(终版)→ 货款对账」导入我方结算数据。</div></td></tr>
<tr><td colspan="6"><div class="sr-empty">还没有可对账的供应商:请先在「供应商应付(终版)→ 货款对账」导入我方结算数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="sr-pagination">
<span>
共 {{ number_format($supplierTotal) }} 家
· 每页 {{ number_format($supplierPerPage) }} 家
· 第 {{ $supplierPage }} / {{ $supplierPages }} 页
</span>
@php
$supplierPageStart = max(1, min($supplierPage - 4, $supplierPages - 8));
$supplierPageEnd = min($supplierPages, $supplierPageStart + 8);
$supplierPageUrl = fn (int $number): string => route('business.pages.show', array_merge(request()->query(), [
'section' => $section['slug'],
'page' => $page['slug'],
'supplier_page' => $number,
]));
@endphp
@if ($supplierPage > 1)
<a class="sr-button is-small" href="{{ $supplierPageUrl($supplierPage - 1) }}">上一页</a>
@endif
@if ($supplierPageStart > 1)
<a class="sr-button is-small" href="{{ $supplierPageUrl(1) }}">1</a>
@if ($supplierPageStart > 2) <span>…</span> @endif
@endif
@for ($supplierPageNumber = $supplierPageStart; $supplierPageNumber <= $supplierPageEnd; $supplierPageNumber++)
<a class="sr-button is-small {{ $supplierPageNumber === $supplierPage ? 'is-primary' : '' }}"
@if ($supplierPageNumber === $supplierPage) aria-current="page" @endif
href="{{ $supplierPageUrl($supplierPageNumber) }}">{{ $supplierPageNumber }}</a>
@endfor
@if ($supplierPageEnd < $supplierPages)
@if ($supplierPageEnd < $supplierPages - 1) <span>…</span> @endif
<a class="sr-button is-small" href="{{ $supplierPageUrl($supplierPages) }}">{{ $supplierPages }}</a>
@endif
@if ($supplierPage < $supplierPages)
<a class="sr-button is-small" href="{{ $supplierPageUrl($supplierPage + 1) }}">下一页</a>
@endif
</div>
</div>
@endif

View File

@ -241,6 +241,92 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
->assertSee('name="month"', false);
}
public function test_finance_supplier_list_is_paginated_fifteen_per_page_with_return_push_action(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
for ($index = 1; $index <= 16; $index++) {
$supplier = sprintf('分页厂家%02d', $index);
$store->save('final:supplier:return-reconciliation', 'return:' . $index, [
'summary_month' => '2026-10',
'send_time' => '2026-10-' . str_pad((string) $index, 2, '0', STR_PAD_LEFT) . ' 10:00:00',
'supplier' => $supplier,
'barcode' => 'RETURN-PAGE-' . $index,
'sku_name' => '分页退货',
'qty' => '1',
'unit_price' => '10',
'amount' => '10',
], $companyId, null);
}
app(SupplierDingTalkNotifier::class)->save('分页厂家01', self::WEBHOOK, 'SEC-test-123', false);
$pageUrl = route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
]);
$firstPage = $this->loginAs()->get($pageUrl)->assertOk();
$firstPage->assertSeeText('共 16 家')
->assertSeeText('每页 15 家')
->assertSeeText('第 1 / 2 页')
->assertSeeText('一键推送退货')
->assertSee('name="month" value="2026-10"', false);
$firstHtml = (string) $firstPage->getContent();
$firstTableStart = strpos($firstHtml, 'data-finance-supplier-list');
$firstTableEnd = strpos($firstHtml, '</table>', $firstTableStart);
$this->assertNotFalse($firstTableStart);
$this->assertNotFalse($firstTableEnd);
$firstTable = substr($firstHtml, $firstTableStart, $firstTableEnd - $firstTableStart);
$this->assertStringContainsString('分页厂家01', $firstTable);
$this->assertStringContainsString('分页厂家15', $firstTable);
$this->assertStringNotContainsString('分页厂家16', $firstTable);
$secondPage = $this->loginAs()->get($pageUrl . '?supplier_page=2')->assertOk();
$secondPage->assertSeeText('第 2 / 2 页');
$secondHtml = (string) $secondPage->getContent();
$secondTableStart = strpos($secondHtml, 'data-finance-supplier-list');
$secondTableEnd = strpos($secondHtml, '</table>', $secondTableStart);
$this->assertNotFalse($secondTableStart);
$this->assertNotFalse($secondTableEnd);
$secondTable = substr($secondHtml, $secondTableStart, $secondTableEnd - $secondTableStart);
$this->assertStringContainsString('分页厂家16', $secondTable);
$this->assertStringNotContainsString('分页厂家01', $secondTable);
}
public function test_return_months_are_available_and_default_to_the_latest_imported_month(): void
{
$this->seedReconciliation();
$this->saveRobot();
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:return-reconciliation',
'return:october-entry',
[
'summary_month' => '2026-10',
'send_time' => '2026-10-08 10:00:00',
'supplier' => '晨丰',
'barcode' => 'RETURN-OCTOBER-ENTRY',
'sku_name' => '十月退货',
'qty' => '1',
'unit_price' => '10',
'amount' => '10',
],
(int) $this->company->id,
null,
);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSee('<option value="2026-10"', false)
->assertSeeText('一键推送整月退货')
->assertSee('name="month" value="2026-10" required', false);
}
public function test_finance_return_table_uses_the_supplier_data_headers(): void
{
$this->seedReconciliation();