fix: 报价变价按地区与重量区间横向编辑 (#1169)

This commit is contained in:
limeng 2026-11-14 09:37:05 +08:00
parent cb6e65a6bf
commit 9c75c8faad
2 changed files with 161 additions and 41 deletions

View File

@ -11,7 +11,7 @@
--qc-border: var(--vip-border, #D8D2C7);
margin: 24px 40px;
color: var(--qc-ink);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-family: "JetBrains Mono", "CaiWu CJK", "Microsoft YaHei", monospace;
font-size: 13px;
}
.quote-change-workspace *, .quote-change-workspace *::before, .quote-change-workspace *::after { box-sizing: border-box; }
@ -50,25 +50,37 @@
.quote-change-source-meta dd { margin: 3px 0 0; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.quote-batch-tools { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(130px, .6fr) auto; gap: 8px; align-items: end; }
.quote-change-table-wrap { overflow: auto; max-height: 540px; border: 1px solid var(--qc-border); border-radius: 8px; }
.quote-change-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.quote-change-table th { position: sticky; top: 0; z-index: 1; padding: 9px 10px; border-bottom: 1px solid var(--qc-border); background: var(--qc-gold-soft); color: var(--qc-ink); font-size: 12px; font-weight: 600; text-align: left; white-space: nowrap; }
.quote-change-table td { padding: 7px 10px; border-bottom: 1px solid #ECE8E0; vertical-align: middle; }
.quote-change-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
.quote-change-table thead th { position: sticky; top: 0; z-index: 2; min-width: 150px; padding: 10px; border-bottom: 1px solid var(--qc-border); background: var(--qc-gold-soft); color: var(--qc-ink); font-size: 12px; font-weight: 600; text-align: left; }
.quote-change-table td { min-width: 150px; padding: 9px 10px; border-bottom: 1px solid #ECE8E0; border-left: 1px solid #ECE8E0; vertical-align: top; background: #fff; }
.quote-change-table .quote-change-region { position: sticky; left: 0; z-index: 1; min-width: 125px; width: 125px; padding: 12px; border-bottom: 1px solid #ECE8E0; border-right: 1px solid var(--qc-border); background: #fff; text-align: left; white-space: nowrap; }
.quote-change-table thead .quote-change-region { z-index: 3; background: var(--qc-gold-soft); }
.quote-change-table tbody tr:last-child th { border-bottom: 0; }
.quote-change-table tbody tr:last-child td { border-bottom: 0; }
.quote-change-table tbody tr.is-changed td { background: #FFFDF7; }
.quote-change-table__number { text-align: right !important; font-variant-numeric: tabular-nums; }
.quote-change-price-input { width: 108px; height: 30px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; text-align: right; font: inherit; font-variant-numeric: tabular-nums; }
.quote-change-table td.is-changed { background: #FFF8E8; box-shadow: inset 0 0 0 1px var(--qc-gold); }
.quote-change-column-label, .quote-change-cell-edit { display: flex; align-items: center; gap: 8px; }
.quote-change-column-label { line-height: 1.5; }
.quote-change-table input[type="checkbox"] { appearance: none; -webkit-appearance: none; flex: 0 0 15px; width: 15px; height: 15px; margin: 0; border: 1px solid var(--qc-gold); border-radius: 3px; background: #fff; cursor: pointer; }
.quote-change-table input[type="checkbox"]:checked { background: var(--qc-gold); box-shadow: inset 0 0 0 3px #fff; }
.quote-change-table input[type="checkbox"]:indeterminate { background: linear-gradient(var(--qc-gold),var(--qc-gold)) center / 9px 3px no-repeat #fff; }
.quote-change-table input[type="checkbox"]:focus-visible { outline: 2px solid var(--qc-gold); outline-offset: 2px; }
.quote-change-price-input { width: 100%; min-width: 0; height: 34px; border: 1px solid var(--qc-border); border-radius: 6px; background: #fff; padding: 0 8px; color: var(--qc-ink); text-align: right; font: inherit; font-variant-numeric: tabular-nums; }
.quote-change-price-input:focus { border-color: var(--qc-gold); outline: 0; box-shadow: 0 0 0 2px rgba(133, 102, 50, .16); }
.quote-change-delta { font-weight: 600; font-variant-numeric: tabular-nums; }
.quote-change-cell-reference { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 8px; margin-top: 5px; color: var(--qc-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.quote-change-delta { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.quote-change-delta--up { color: #935F07; }
.quote-change-delta--down { color: #53793E; }
.quote-change-note-input { width: 180px; height: 30px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; font: inherit; }
.quote-change-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding: 12px 16px; border: 1px solid var(--qc-border); border-radius: 12px; background: rgba(255, 255, 255, .96); }
.quote-change-cell-note { margin-top: 5px; color: var(--qc-muted); font-size: 11px; }
.quote-change-cell-note summary { cursor: pointer; }
.quote-change-cell-note summary:focus-visible { outline: 2px solid var(--qc-gold); }
.quote-change-note-input { width: 100%; height: 30px; margin-top: 5px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; color: var(--qc-ink); font: inherit; }
.quote-change-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding: 12px 16px; border: 1px solid var(--qc-border); border-radius: 12px; background: #fff; }
.quote-change-footer__summary { color: var(--qc-muted); font-size: 12px; }
.quote-change-footer__summary strong { color: var(--qc-ink); font-variant-numeric: tabular-nums; }
.quote-change-footer__actions { display: flex; gap: 8px; }
@media (max-width: 900px) {
.quote-change-workspace { margin: 16px; }
.quote-change-form-grid, .quote-change-source-meta { grid-template-columns: 1fr; }
.quote-change-source, .quote-change-form-grid, .quote-change-source-meta { grid-template-columns: 1fr; }
.quote-change-field--full { grid-column: auto; }
.quote-batch-tools { grid-template-columns: 1fr; }
.quote-change-footer { align-items: stretch; flex-direction: column; }
@ -133,22 +145,45 @@
<div class="quote-batch-tools" aria-label="批量调整">
<div class="quote-change-field"><label for="batch_mode">批量方式</label><select class="quote-change-control" id="batch_mode" data-batch-mode><option value="add">统一加减金额</option><option value="percent">按比例调整</option><option value="set">统一设为金额</option></select></div>
<div class="quote-change-field"><label for="batch_value">调整值</label><input class="quote-change-control" id="batch_value" type="number" step="0.0001" data-batch-value placeholder="例如 1 或 -0.5"></div>
<button class="quote-change-button" type="button" data-batch-apply><span class="material-icons-outlined" aria-hidden="true">calculate</span><span>应用到勾选行</span></button>
<button class="quote-change-button" type="button" data-batch-apply><span class="material-icons-outlined" aria-hidden="true">calculate</span><span>应用到勾选项</span></button>
</div>
<p class="quote-change-panel__hint" style="margin-top:10px">每个地区一行,各重量区间横向填写新价;下方对照原价和差额。勾选地区、区间列或单个价格可批量调整。</p>
@php
$priceRows = collect($editableCells)->groupBy('source_row', true);
$priceColumns = collect($editableCells)->unique('source_column')->sortBy('source_column');
@endphp
<div class="quote-change-table-wrap" style="margin-top:12px">
<table class="quote-change-table">
<thead><tr><th scope="col"><input type="checkbox" data-select-all aria-label="全选可调整行"></th><th scope="col">单元格</th><th scope="col">地区/规则</th><th scope="col">重量段/计费项</th><th scope="col" class="quote-change-table__number">原价格</th><th scope="col" class="quote-change-table__number">新价格</th><th scope="col" class="quote-change-table__number">差额</th><th scope="col">备注</th></tr></thead>
<table class="quote-change-table" aria-label="按地区和重量区间调整报价">
<thead><tr>
<th scope="col" class="quote-change-region"><label class="quote-change-column-label"><input type="checkbox" data-select-all aria-label="全选价格"><span>地区 / 规则</span></label></th>
@foreach ($priceColumns as $column)
<th scope="col"><label class="quote-change-column-label"><input type="checkbox" data-column-select="{{ $column['source_column'] }}" aria-label="选择整列 {{ $column['weight_band'] ?: $column['charge_item'] }}"><span>{{ $column['weight_band'] ?: ($column['charge_item'] ?: '价格') }}</span></label></th>
@endforeach
</tr></thead>
<tbody>
@foreach ($editableCells as $index => $cell)
<tr data-price-row data-original="{{ $cell['original_amount'] }}">
<td><input type="checkbox" data-row-select aria-label="选择 {{ $cell['source_cell'] }}" @checked($cell['selected'])></td>
<td><strong>{{ $cell['source_cell'] }}</strong><input type="hidden" name="items[{{ $index }}][source_cell]" value="{{ $cell['source_cell'] }}"><input type="hidden" name="items[{{ $index }}][source_row]" value="{{ $cell['source_row'] }}"><input type="hidden" name="items[{{ $index }}][source_column]" value="{{ $cell['source_column'] }}"></td>
<td>{{ $cell['region'] ?: '—' }}<input type="hidden" name="items[{{ $index }}][region]" value="{{ $cell['region'] }}"></td>
<td>{{ $cell['weight_band'] ?: ($cell['charge_item'] ?: '—') }}<input type="hidden" name="items[{{ $index }}][weight_band]" value="{{ $cell['weight_band'] }}"><input type="hidden" name="items[{{ $index }}][charge_item]" value="{{ $cell['charge_item'] }}"></td>
<td class="quote-change-table__number">{{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}<input type="hidden" name="items[{{ $index }}][original_amount]" value="{{ $cell['original_amount'] }}"></td>
<td class="quote-change-table__number"><input class="quote-change-price-input" type="number" name="items[{{ $index }}][new_amount]" min="0" step="0.0001" value="{{ old("items.$index.new_amount", $cell['new_amount']) }}" data-new-price required></td>
<td class="quote-change-table__number"><span class="quote-change-delta" data-price-delta>0.0000</span></td>
<td><input class="quote-change-note-input" name="items[{{ $index }}][note]" maxlength="500" value="{{ old("items.$index.note", $cell['note']) }}" placeholder="选填"><input type="hidden" name="items[{{ $index }}][unit]" value="{{ $cell['unit'] }}"></td>
@foreach ($priceRows as $rowIndex => $rowCells)
@php($cellsByColumn = $rowCells->keyBy('source_column'))
<tr data-region-row="{{ $rowIndex }}">
<th scope="row" class="quote-change-region"><label class="quote-change-column-label"><input type="checkbox" data-region-select aria-label="选择整行 {{ $rowCells->first()['region'] }}"><span>{{ $rowCells->first()['region'] ?: '—' }}</span></label></th>
@foreach ($priceColumns as $column)
@php($cell = $cellsByColumn->get($column['source_column']))
@if ($cell)
@php($index = $rowCells->search(fn ($item) => $item['source_cell'] === $cell['source_cell']))
<td data-price-cell data-column="{{ $cell['source_column'] }}" data-original="{{ $cell['original_amount'] }}">
<div class="quote-change-cell-edit">
<input type="checkbox" data-cell-select aria-label="选择 {{ $cell['region'] }} {{ $cell['weight_band'] }}" @checked($cell['selected'])>
<input class="quote-change-price-input" type="number" name="items[{{ $index }}][new_amount]" min="0" step="0.0001" value="{{ old("items.$index.new_amount", $cell['new_amount']) }}" aria-label="{{ $cell['region'] }} {{ $cell['weight_band'] }} 新价格" data-new-price required>
</div>
<div class="quote-change-cell-reference"><span title="原报价单元格 {{ $cell['source_cell'] }}">原 {{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}</span><span class="quote-change-delta" data-price-delta aria-label="差额">0.0000</span></div>
<details class="quote-change-cell-note" @if(filled(old("items.$index.note", $cell['note']))) open @endif><summary>备注</summary><input class="quote-change-note-input" name="items[{{ $index }}][note]" maxlength="500" value="{{ old("items.$index.note", $cell['note']) }}" aria-label="{{ $cell['region'] }} {{ $cell['weight_band'] }} 备注" placeholder="选填"></details>
@foreach (['source_cell', 'source_row', 'source_column', 'region', 'weight_band', 'charge_item', 'original_amount', 'unit'] as $field)
<input type="hidden" name="items[{{ $index }}][{{ $field }}]" value="{{ $cell[$field] }}">
@endforeach
</td>
@else
<td><span class="quote-change-panel__hint" aria-label="无可编辑价格">—</span></td>
@endif
@endforeach
</tr>
@endforeach
</tbody>
@ -173,7 +208,9 @@
var root = document.querySelector('[data-quote-change-editor]');
if (!root || root.dataset.initialized === 'true') return;
root.dataset.initialized = 'true';
var rows = Array.from(root.querySelectorAll('[data-price-row]'));
var cells = Array.from(root.querySelectorAll('[data-price-cell]'));
var regionRows = Array.from(root.querySelectorAll('[data-region-row]'));
var columnSelectors = Array.from(root.querySelectorAll('[data-column-select]'));
var count = root.querySelector('[data-changed-count]');
var effectiveDate = root.querySelector('[data-effective-date]');
var retrospectiveAlert = root.querySelector('[data-retrospective-alert]');
@ -183,27 +220,48 @@
retrospectiveAlert.hidden = !isRetrospective;
if (isRetrospective) retrospectiveAlert.querySelector('[data-retrospective-date]').textContent = effectiveDate.value;
}
function refreshRow(row) {
var original = Number(row.dataset.original);
var input = row.querySelector('[data-new-price]');
function refreshCell(cell) {
var original = Number(cell.dataset.original);
var input = cell.querySelector('[data-new-price]');
var next = Number(input.value);
var delta = Number.isFinite(next) ? next - original : 0;
var output = row.querySelector('[data-price-delta]');
output.textContent = (delta > 0 ? '+' : '') + delta.toFixed(4);
var hasPrice = input.value !== '' && Number.isFinite(next);
var delta = hasPrice ? next - original : 0;
var output = cell.querySelector('[data-price-delta]');
output.textContent = hasPrice ? (delta > 0 ? '+' : '') + delta.toFixed(4) : '—';
output.className = 'quote-change-delta' + (delta > 0 ? ' quote-change-delta--up' : delta < 0 ? ' quote-change-delta--down' : '');
row.classList.toggle('is-changed', Math.abs(delta) > 0.00005);
if (Math.abs(delta) > 0.00005) row.querySelector('[data-row-select]').checked = true;
cell.classList.toggle('is-changed', hasPrice && Math.abs(delta) > 0.00005);
}
function refreshAll() { rows.forEach(refreshRow); count.textContent = String(rows.filter(function (row) { return row.classList.contains('is-changed'); }).length); }
rows.forEach(function (row) { row.querySelector('[data-new-price]').addEventListener('input', refreshAll); });
function selected(cell) { return cell.querySelector('[data-cell-select]').checked; }
function syncSelector(selector, group) {
var checked = group.filter(selected).length;
selector.checked = group.length > 0 && checked === group.length;
selector.indeterminate = checked > 0 && checked < group.length;
}
function syncSelection() {
syncSelector(root.querySelector('[data-select-all]'), cells);
regionRows.forEach(function (row) { syncSelector(row.querySelector('[data-region-select]'), Array.from(row.querySelectorAll('[data-price-cell]'))); });
columnSelectors.forEach(function (selector) { syncSelector(selector, cells.filter(function (cell) { return cell.dataset.column === selector.dataset.columnSelect; })); });
}
function refreshAll() { cells.forEach(refreshCell); count.textContent = String(cells.filter(function (cell) { return cell.classList.contains('is-changed'); }).length); syncSelection(); }
cells.forEach(function (cell) {
cell.querySelector('[data-new-price]').addEventListener('input', function () {
if (this.value !== '' && Number(this.value) !== Number(cell.dataset.original)) cell.querySelector('[data-cell-select]').checked = true;
refreshAll();
});
cell.querySelector('[data-cell-select]').addEventListener('change', syncSelection);
});
if (effectiveDate) effectiveDate.addEventListener('change', refreshRetrospective);
root.querySelector('[data-select-all]').addEventListener('change', function (event) { rows.forEach(function (row) { row.querySelector('[data-row-select]').checked = event.target.checked; }); });
function selectGroup(group, checked) { group.forEach(function (cell) { cell.querySelector('[data-cell-select]').checked = checked; }); syncSelection(); }
root.querySelector('[data-select-all]').addEventListener('change', function () { selectGroup(cells, this.checked); });
regionRows.forEach(function (row) { row.querySelector('[data-region-select]').addEventListener('change', function () { selectGroup(Array.from(row.querySelectorAll('[data-price-cell]')), this.checked); }); });
columnSelectors.forEach(function (selector) { selector.addEventListener('change', function () { selectGroup(cells.filter(function (cell) { return cell.dataset.column === selector.dataset.columnSelect; }), this.checked); }); });
root.querySelector('[data-batch-apply]').addEventListener('click', function () {
var value = Number(root.querySelector('[data-batch-value]').value);
if (!Number.isFinite(value)) { root.querySelector('[data-batch-value]').focus(); return; }
var valueInput = root.querySelector('[data-batch-value]');
var value = Number(valueInput.value);
if (valueInput.value.trim() === '' || !Number.isFinite(value)) { valueInput.focus(); return; }
var mode = root.querySelector('[data-batch-mode]').value;
rows.filter(function (row) { return row.querySelector('[data-row-select]').checked; }).forEach(function (row) {
var input = row.querySelector('[data-new-price]');
cells.filter(selected).forEach(function (cell) {
var input = cell.querySelector('[data-new-price]');
var current = Number(input.value);
var result = mode === 'add' ? current + value : mode === 'percent' ? current * (1 + value / 100) : value;
input.value = Math.max(0, result).toFixed(4);

View File

@ -79,7 +79,7 @@ class CourierQuoteChangeWorkflowTest extends FeatureTestCase
->assertSeeText('批量方式')
->assertSeeText('原价格')
->assertSeeText('新价格')
->assertSee('data-price-row', false)
->assertSee('data-region-row', false)
->assertSee('name="items[0][new_amount]"', false)
->assertSee('data-effective-date', false)
->assertDontSee('type="date" min=', false)
@ -87,6 +87,68 @@ class CourierQuoteChangeWorkflowTest extends FeatureTestCase
->assertSeeText('保存草稿');
}
public function test_horizontal_editor_saves_the_rendered_cell_coordinates_and_restores_draft_values(): void
{
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:D3',
'values' => [
['省份', '0-3公斤面单费/元', '3公斤以上面单费/元', '0-3公斤费率'],
['江苏省', 0, 1.2, 0.45],
['安徽省', 4, 1, null],
],
'formulas' => [[], ['', '', '=0.6*2'], []],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$response = $this->loginAs()->get(route('business.courier-quote-changes.create', ['source_quote_id' => 'QB-001']))->assertOk();
$document = new \DOMDocument();
@$document->loadHTML('<?xml encoding="UTF-8">' . $response->getContent());
$xpath = new \DOMXPath($document);
$this->assertSame(2, $xpath->query('//tr[@data-region-row]')->length);
$this->assertSame(3, $xpath->query('//input[@data-column-select]')->length);
$this->assertSame(2, $xpath->query('//tr[@data-region-row="1"]/td[@data-price-cell]')->length);
$this->assertSame(2, $xpath->query('//tr[@data-region-row="2"]/td[@data-price-cell]')->length);
// Submit the names/coordinates actually rendered in the rearranged form.
$fields = [];
foreach ($xpath->query('//form[@data-quote-change-form]//input[@name]') as $input) {
$fields[] = urlencode($input->getAttribute('name')) . '=' . urlencode($input->getAttribute('value'));
}
parse_str(implode('&', $fields), $payload);
$this->assertSame(['B2', 'D2', 'B3', 'C3'], array_column($payload['items'], 'source_cell'));
$this->assertSame('0.0000', $payload['items'][0]['new_amount']);
$payload['change_type'] = '费率调整';
$payload['effective_date'] = now()->addDay()->toDateString();
$payload['summary'] = '江苏和安徽按区间调整。';
$payload['items'][1]['new_amount'] = '0.5678';
$payload['items'][1]['note'] = '江苏费率调整';
$payload['items'][3]['new_amount'] = '0';
$this->post(route('business.courier-quote-changes.store'), $payload)->assertRedirect();
$change = CourierQuoteChange::with('items')->firstOrFail();
$items = $change->items->keyBy('source_cell');
$this->assertCount(2, $items);
$this->assertSame('江苏省', $items['D2']->region);
$this->assertSame('0.4500', $items['D2']->original_amount);
$this->assertSame('0.5678', $items['D2']->new_amount);
$this->assertSame('江苏费率调整', $items['D2']->note);
$this->assertSame('安徽省', $items['C3']->region);
$this->assertSame('0.0000', $items['C3']->new_amount);
$this->assertEquals(0.45, app(QuoteManagementSnapshot::class)->load()['quotes'][0]['values'][1][3]);
$draft = $this->get(route('business.courier-quote-changes.edit', $change))->assertOk();
@$document->loadHTML('<?xml encoding="UTF-8">' . $draft->getContent());
$xpath = new \DOMXPath($document);
$this->assertSame('0.5678', $xpath->query('//input[@name="items[1][new_amount]"]')->item(0)->getAttribute('value'));
$this->assertSame('江苏费率调整', $xpath->query('//input[@name="items[1][note]"]')->item(0)->getAttribute('value'));
$this->assertTrue($xpath->query('//input[@name="items[1][note]"]/parent::details')->item(0)->hasAttribute('open'));
$this->assertSame('0.0000', $xpath->query('//input[@name="items[3][new_amount]"]')->item(0)->getAttribute('value'));
}
public function test_historical_effective_date_can_be_saved_and_submitted_for_retrospective_recalculation(): void
{
$payload = $this->validPayload();