diff --git a/resources/views/business/pages/courier-payables/quote-change-editor.blade.php b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php
index 9f538508..621234ef 100644
--- a/resources/views/business/pages/courier-payables/quote-change-editor.blade.php
+++ b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php
@@ -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 @@
+ 每个地区一行,各重量区间横向填写新价;下方对照原价和差额。勾选地区、区间列或单个价格可批量调整。
+ @php
+ $priceRows = collect($editableCells)->groupBy('source_row', true);
+ $priceColumns = collect($editableCells)->unique('source_column')->sortBy('source_column');
+ @endphp
-
- | 单元格 | 地区/规则 | 重量段/计费项 | 原价格 | 新价格 | 差额 | 备注 |
+
+
+ |
+ @foreach ($priceColumns as $column)
+ |
+ @endforeach
+
- @foreach ($editableCells as $index => $cell)
-
- |
- {{ $cell['source_cell'] }} |
- {{ $cell['region'] ?: '—' }} |
- {{ $cell['weight_band'] ?: ($cell['charge_item'] ?: '—') }} |
- {{ rtrim(rtrim($cell['original_amount'], '0'), '.') }} |
- |
- 0.0000 |
- |
+ @foreach ($priceRows as $rowIndex => $rowCells)
+ @php($cellsByColumn = $rowCells->keyBy('source_column'))
+
+ |
+ @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']))
+
+
+
+
+
+ 原 {{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}0.0000
+ 备注
+ @foreach (['source_cell', 'source_row', 'source_column', 'region', 'weight_band', 'charge_item', 'original_amount', 'unit'] as $field)
+
+ @endforeach
+ |
+ @else
+ — |
+ @endif
+ @endforeach
@endforeach
@@ -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);
diff --git a/tests/Feature/Business/CourierQuoteChangeWorkflowTest.php b/tests/Feature/Business/CourierQuoteChangeWorkflowTest.php
index 512eea86..80547f0e 100644
--- a/tests/Feature/Business/CourierQuoteChangeWorkflowTest.php
+++ b/tests/Feature/Business/CourierQuoteChangeWorkflowTest.php
@@ -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('' . $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('' . $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();