货款对账:差异支持勾选多条推送 (#379)

This commit is contained in:
renshen 2026-10-17 13:05:10 +08:00
parent 41f6946a2b
commit 3638a8cee9
3 changed files with 132 additions and 2 deletions

View File

@ -130,10 +130,12 @@ class SupplierReconciliation extends Controller
'supplier' => ['required', 'string', 'max:191'],
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row'],
'scope' => ['required', 'in:all,row,selected'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'unit_price' => ['nullable', 'numeric'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
@ -152,6 +154,18 @@ class SupplierReconciliation extends Controller
&& (string) ($row['type'] ?? '') === $type
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
foreach ((array) $request->input('selected_rows', []) as $token) {
$row = json_decode((string) $token, true);
if (is_array($row)) {
$selected[$this->differenceRowKey($row)] = true;
}
}
$differences = array_values(array_filter(
$differences,
fn (array $row): bool => isset($selected[$this->differenceRowKey($row)]),
));
}
if ($differences === []) {
@ -196,4 +210,14 @@ class SupplierReconciliation extends Controller
return [trim((string) $request->input('supplier')), (int) (company_id() ?: 1)];
}
/** @param array<string,mixed> $row */
private function differenceRowKey(array $row): string
{
return implode("\0", [
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['type'] ?? '')),
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
]);
}
}

View File

@ -37,6 +37,8 @@
.supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; }
.supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; }
.supplier-reconciliation-page .sr-robot-check { display: flex; align-items: center; gap: 6px; min-height: 36px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-reconciliation-page .sr-select-cell { width: 34px; text-align: center; }
.supplier-reconciliation-page .sr-select-cell input { width: 15px; height: 15px; accent-color: var(--vip-content-accent-dark, #856632); }
.supplier-reconciliation-page .sr-robot-form input[type="url"],
.supplier-reconciliation-page .sr-robot-form input[type="password"] { width: 100%; min-width: 0; }
@media (max-width: 900px) {
@ -138,6 +140,22 @@
推送全部差异({{ $differenceCount }} 条)
</button>
</form>
<form id="push-selected-form"
method="POST"
action="{{ route('business.supplier-reconciliation.push-differences') }}">
@csrf
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<input type="hidden" name="month" value="{{ $month }}">
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
<input type="hidden" name="scope" value="selected">
<button id="push-selected-button"
type="submit"
class="sr-button is-primary is-disabled"
disabled
title="先在表格中勾选需要推送的差异">
推送选中(<span id="push-selected-count">0</span>)
</button>
</form>
@endif
</div>
<details class="sr-robot-config" @if (! $robot['configured']) open @endif>
@ -231,6 +249,9 @@
<table>
<thead>
<tr>
<th scope="col" class="sr-select-cell">
<input id="select-all-differences" type="checkbox" aria-label="选择全部差异">
</th>
<th scope="col">比对结果</th>
<th scope="col">条码</th>
<th scope="col">单价</th>
@ -254,6 +275,18 @@
};
@endphp
<tr class="{{ $tagClass }}">
<td class="sr-select-cell">
@if ($row['type'] !== '一致')
<input class="js-difference-checkbox"
form="push-selected-form"
type="checkbox"
name="selected_rows[]"
value="{{ json_encode(['barcode' => $row['barcode'], 'type' => $row['type'], 'unitPrice' => $row['unitPrice']], JSON_UNESCAPED_UNICODE) }}"
aria-label="选择差异 {{ $row['barcode'] }}">
@else
<span class="sr-muted">—</span>
@endif
</td>
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
<td>
{{ $row['barcode'] }}
@ -294,7 +327,7 @@
</td>
</tr>
@empty
<tr><td colspan="10"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
<tr><td colspan="11"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
@endforelse
</tbody>
</table>
@ -302,3 +335,45 @@
</div>
@endif
</div>
@if ($detail && $differenceCount > 0)
<script>
(() => {
const form = document.getElementById('push-selected-form');
const button = document.getElementById('push-selected-button');
const countLabel = document.getElementById('push-selected-count');
const selectAll = document.getElementById('select-all-differences');
const checkboxes = Array.from(document.querySelectorAll('.js-difference-checkbox'));
const robotConfigured = @json($robot['configured']);
if (!form || !button || !countLabel || !selectAll) {
return;
}
const updateSelection = () => {
const selected = checkboxes.filter((checkbox) => checkbox.checked).length;
countLabel.textContent = String(selected);
button.disabled = !robotConfigured || selected === 0;
button.classList.toggle('is-disabled', button.disabled);
selectAll.checked = selected > 0 && selected === checkboxes.length;
selectAll.indeterminate = selected > 0 && selected < checkboxes.length;
};
checkboxes.forEach((checkbox) => checkbox.addEventListener('change', updateSelection));
selectAll.addEventListener('change', () => {
checkboxes.forEach((checkbox) => {
checkbox.checked = selectAll.checked;
});
updateSelection();
});
form.addEventListener('submit', (event) => {
const selected = checkboxes.filter((checkbox) => checkbox.checked).length;
if (selected === 0 || !window.confirm(`确认将这 ${selected} 条差异推送到该厂家钉钉群?`)) {
event.preventDefault();
}
});
updateSelection();
})();
</script>
@endif

View File

@ -29,6 +29,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
$this->loginAs()->get($this->pageUrl())
->assertOk()
->assertSeeText('推送全部差异(3 条)')
->assertSeeText('推送选中(')
->assertSee('aria-label="选择全部差异"', false)
->assertSeeText('推送本条')
->assertSeeText('厂家钉钉群机器人')
->assertSeeText('未配置,配置后才能推送差异')
@ -140,6 +142,35 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
});
}
public function test_push_selected_differences_only_sends_checked_rows(): void
{
$this->seedReconciliation();
$this->saveRobot();
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$this->loginAs()
->post(route('business.supplier-reconciliation.push-differences'), [
'supplier' => '晨丰',
'month' => '2026-09',
'reconciler' => '张三',
'scope' => 'selected',
'selected_rows' => [
json_encode(['barcode' => 'B-1', 'type' => '金额不一致', 'unitPrice' => 50]),
json_encode(['barcode' => 'D-1', 'type' => '厂家多', 'unitPrice' => 80]),
],
])
->assertStatus(302)
->assertSessionHas('success', '已推送 2 条差异到 晨丰 钉钉群(1 条消息)。');
Http::assertSent(function ($request): bool {
$text = (string) ($request['markdown']['text'] ?? '');
return str_contains($text, 'B-1')
&& str_contains($text, 'D-1')
&& ! str_contains($text, '`C-1`');
});
}
public function test_push_without_robot_configuration_is_rejected_without_http_call(): void
{
$this->seedReconciliation();