feat: 供应商列表分页并增加整月退货推送 (#847)
This commit is contained in:
parent
661fbca7d3
commit
0ed02e95f7
@ -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 [
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user