From 01de726227d6fde8aaf27c808054a7f8c3ec42a1 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 23 Sep 2026 11:09:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E9=80=92=E9=99=84=E5=8A=A0=E8=B4=B9?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=94=AF=E6=8C=81=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E3=80=81=E6=97=A5=E5=BF=97=E7=AD=9B=E9=80=89=E4=B8=8E=E5=88=97?= =?UTF-8?q?=E5=AE=BD=E8=B0=83=E6=95=B4=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CourierSurchargeAuditLogRepository.php | 3 + .../CourierSurchargeQuoteRepository.php | 21 ++- .../CourierSurchargeQuoteWorkbookImporter.php | 14 +- .../Business/CourierSurchargeQuotes.php | 2 + app/Http/Controllers/Business/Pages.php | 1 + app/Models/Business/CourierSurchargeQuote.php | 1 + ...lier_to_courier_surcharge_quotes_table.php | 32 ++++ .../courier-surcharge.blade.php | 156 ++++++++++++++++-- .../Business/CourierSurchargePageTest.php | 108 ++++++++++-- 9 files changed, 301 insertions(+), 37 deletions(-) create mode 100644 database/migrations/2026_09_22_010000_add_supplier_to_courier_surcharge_quotes_table.php diff --git a/app/Business/CourierSurchargeAuditLogRepository.php b/app/Business/CourierSurchargeAuditLogRepository.php index f6140b4b..eb3acdd5 100644 --- a/app/Business/CourierSurchargeAuditLogRepository.php +++ b/app/Business/CourierSurchargeAuditLogRepository.php @@ -20,6 +20,7 @@ class CourierSurchargeAuditLogRepository 'courier' => '快递名称', 'scope' => '快递归属', 'reconciler' => '对账人', + 'supplier' => '供应商', 'types' => '附加费类型', 'mode' => '结算方式', 'standard' => '计价标准', @@ -98,6 +99,8 @@ class CourierSurchargeAuditLogRepository 'actor_name' => $log->actor?->name ?: '系统', 'action' => $log->action, 'action_label' => self::ACTION_LABELS[$log->action] ?? $log->action, + 'target_type' => $log->target_type, + 'target_id' => $log->target_id, 'target_type_label' => self::TARGET_LABELS[$log->target_type] ?? $log->target_type, 'target_label' => $log->target_label, 'changes_label' => $this->changesLabel($log), diff --git a/app/Business/CourierSurchargeQuoteRepository.php b/app/Business/CourierSurchargeQuoteRepository.php index 8f43052b..bd084c20 100644 --- a/app/Business/CourierSurchargeQuoteRepository.php +++ b/app/Business/CourierSurchargeQuoteRepository.php @@ -40,7 +40,11 @@ class CourierSurchargeQuoteRepository public function ready(): bool { try { - return Schema::hasTable((new CourierSurchargeQuote())->getTable()) && $this->types->ready(); + $table = (new CourierSurchargeQuote())->getTable(); + + return Schema::hasTable($table) + && Schema::hasColumn($table, 'supplier') + && $this->types->ready(); } catch (QueryException) { return false; } @@ -63,8 +67,7 @@ class CourierSurchargeQuoteRepository try { $all = CourierSurchargeQuote::query() - ->orderByDesc('effective_at') - ->orderByDesc('id') + ->orderBy('id') ->get(); } catch (QueryException) { return [ @@ -76,7 +79,7 @@ class CourierSurchargeQuoteRepository } $filtered = $all->filter(function (CourierSurchargeQuote $quote) use ($filters): bool { - foreach (['courier', 'scope', 'reconciler', 'mode'] as $key) { + foreach (['courier', 'scope', 'reconciler', 'supplier', 'mode'] as $key) { if (($filters[$key] ?? '') !== '' && (string) $quote->{$key} !== $filters[$key]) { return false; } @@ -96,6 +99,7 @@ class CourierSurchargeQuoteRepository $quote->courier, $quote->scope, $quote->reconciler, + (string) $quote->supplier, $quote->typeLabel(), $quote->mode, $quote->standard, @@ -139,6 +143,7 @@ class CourierSurchargeQuoteRepository 'modes' => collect(self::MODES), 'scopes' => self::SCOPES, 'reconcilers' => $all->pluck('reconciler')->filter()->unique()->sort()->values(), + 'suppliers' => $all->pluck('supplier')->filter()->unique()->sort()->values(), 'statuses' => ['已启用', '已停用'], ], ]; @@ -168,6 +173,9 @@ class CourierSurchargeQuoteRepository ->where('condition', trim((string) $data['condition'])) ->whereDate('effective_at', (string) $data['effective_at']); + $supplier = trim((string) ($data['supplier'] ?? '')); + $supplier === '' ? $query->whereNull('supplier') : $query->where('supplier', $supplier); + if ($lockForUpdate) { $query->lockForUpdate(); } @@ -186,6 +194,7 @@ class CourierSurchargeQuoteRepository trim((string) ($data['courier'] ?? '')), trim((string) ($data['scope'] ?? '')), trim((string) ($data['reconciler'] ?? '')), + trim((string) ($data['supplier'] ?? '')), $this->normalizedTypes((array) ($data['types'] ?? [])), trim((string) ($data['mode'] ?? '')), trim((string) ($data['effective_at'] ?? '')), @@ -279,6 +288,7 @@ class CourierSurchargeQuoteRepository 'courier' => $quote->courier, 'scope' => $quote->scope, 'reconciler' => $quote->reconciler, + 'supplier' => $quote->supplier, 'types' => array_values((array) $quote->types), 'mode' => $quote->mode, 'standard' => $quote->standard, @@ -302,6 +312,7 @@ class CourierSurchargeQuoteRepository 'courier' => trim((string) $data['courier']), 'scope' => trim((string) $data['scope']), 'reconciler' => trim((string) $data['reconciler']), + 'supplier' => trim((string) ($data['supplier'] ?? '')) ?: null, 'types' => array_values(array_unique(array_map('strval', $data['types']))), 'mode' => trim((string) $data['mode']), 'standard' => trim((string) $data['standard']), @@ -333,6 +344,7 @@ class CourierSurchargeQuoteRepository 'courier' => $quote->courier, 'scope' => $quote->scope, 'reconciler' => $quote->reconciler, + 'supplier' => $quote->supplier, 'types' => array_values((array) $quote->types), 'type' => $quote->typeLabel(), 'mode' => $quote->mode, @@ -356,6 +368,7 @@ class CourierSurchargeQuoteRepository 'modes' => collect(self::MODES), 'scopes' => self::SCOPES, 'reconcilers' => collect(), + 'suppliers' => collect(), 'statuses' => ['已启用', '已停用'], ]; } diff --git a/app/Business/CourierSurchargeQuoteWorkbookImporter.php b/app/Business/CourierSurchargeQuoteWorkbookImporter.php index 22ecff3a..a630cc1f 100644 --- a/app/Business/CourierSurchargeQuoteWorkbookImporter.php +++ b/app/Business/CourierSurchargeQuoteWorkbookImporter.php @@ -29,6 +29,7 @@ class CourierSurchargeQuoteWorkbookImporter '快递名称', '快递归属', '对账人', + '供应商', '附加费类型', '结算方式', '计价标准', @@ -64,7 +65,7 @@ class CourierSurchargeQuoteWorkbookImporter $sheet->freezePane('A2'); $sheet->setAutoFilter($headerRange); - $widths = [18, 14, 14, 24, 18, 30, 32, 14, 12, 30]; + $widths = [18, 14, 14, 20, 24, 18, 30, 32, 14, 12, 30]; foreach ($widths as $index => $width) { $column = Coordinate::stringFromColumnIndex($index + 1); $sheet->getColumnDimension($column)->setWidth($width); @@ -78,6 +79,7 @@ class CourierSurchargeQuoteWorkbookImporter $instructions->fromArray([ ['项目', '填写要求'], ['必填字段', '快递名称、快递归属、对账人、附加费类型、结算方式、计价标准、生效条件、生效日期、状态。'], + ['供应商', '选填;没有供应商时保持空白。'], ['附加费类型', '多种类型使用“、”分隔;只能填写当前已启用类型。当前可选:' . implode('、', $types)], ['快递归属', '自有快递 或 代发仓。'], ['结算方式', implode('、', CourierSurchargeQuoteRepository::MODES)], @@ -92,8 +94,8 @@ class CourierSurchargeQuoteWorkbookImporter ]); $instructions->getColumnDimension('A')->setWidth(18); $instructions->getColumnDimension('B')->setWidth(90); - $instructions->getStyle('A1:B9')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true); - foreach (range(1, 9) as $row) { + $instructions->getStyle('A1:B10')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true); + foreach (range(1, 10) as $row) { $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34); } @@ -144,7 +146,7 @@ class CourierSurchargeQuoteWorkbookImporter } if ($headers !== self::HEADERS) { throw ValidationException::withMessages([ - 'import' => '表头不正确。请下载最新模板,并保持 10 个字段名称及顺序不变。', + 'import' => '表头不正确。请下载最新模板,并保持 11 个字段名称及顺序不变。', ]); } @@ -273,7 +275,7 @@ class CourierSurchargeQuoteWorkbookImporter } $errors = []; - foreach (array_slice(self::HEADERS, 0, 9) as $header) { + foreach (['快递名称', '快递归属', '对账人', '附加费类型', '结算方式', '计价标准', '生效条件', '生效日期', '状态'] as $header) { if ($display[$header] === '') { $errors[] = $header . '不能为空。'; } @@ -281,6 +283,7 @@ class CourierSurchargeQuoteWorkbookImporter $this->checkLength($display, '快递名称', 191, $errors); $this->checkLength($display, '对账人', 100, $errors); + $this->checkLength($display, '供应商', 191, $errors); $this->checkLength($display, '计价标准', 2000, $errors); $this->checkLength($display, '生效条件', 2000, $errors); $this->checkLength($display, '备注', 2000, $errors); @@ -333,6 +336,7 @@ class CourierSurchargeQuoteWorkbookImporter 'courier' => $display['快递名称'], 'scope' => $display['快递归属'], 'reconciler' => $display['对账人'], + 'supplier' => $display['供应商'] !== '' ? $display['供应商'] : null, 'types' => $types, 'mode' => $display['结算方式'], 'standard' => $display['计价标准'], diff --git a/app/Http/Controllers/Business/CourierSurchargeQuotes.php b/app/Http/Controllers/Business/CourierSurchargeQuotes.php index 5825c7f6..3364bf36 100644 --- a/app/Http/Controllers/Business/CourierSurchargeQuotes.php +++ b/app/Http/Controllers/Business/CourierSurchargeQuotes.php @@ -134,6 +134,7 @@ class CourierSurchargeQuotes extends Controller 'courier' => ['required', 'string', 'max:191'], 'scope' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::SCOPES)], 'reconciler' => ['required', 'string', 'max:100'], + 'supplier' => ['nullable', 'string', 'max:191'], 'types' => ['required', 'array', 'min:1', 'max:50'], 'types.*' => ['string', 'in:' . implode(',', $this->quotes->activeTypeNames())], 'mode' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::MODES)], @@ -145,6 +146,7 @@ class CourierSurchargeQuotes extends Controller $data['courier'] = trim($data['courier']); $data['reconciler'] = trim($data['reconciler']); + $data['supplier'] = trim((string) ($data['supplier'] ?? '')) ?: null; $data['types'] = array_values(array_unique($data['types'])); return $data; diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index d8e77296..c3a51bd6 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1128,6 +1128,7 @@ class Pages extends Controller 'mode' => $clean(request('mode', ''), 40), 'scope' => $clean(request('scope', ''), 20), 'reconciler' => $clean(request('reconciler', ''), 40), + 'supplier' => $clean(request('supplier', ''), 191), 'status' => $clean(request('status', ''), 20), ]; $data = $this->courierSurchargeQuotes->pageData($filters); diff --git a/app/Models/Business/CourierSurchargeQuote.php b/app/Models/Business/CourierSurchargeQuote.php index 8cf53c08..5f3ff814 100644 --- a/app/Models/Business/CourierSurchargeQuote.php +++ b/app/Models/Business/CourierSurchargeQuote.php @@ -17,6 +17,7 @@ class CourierSurchargeQuote extends Model 'courier', 'scope', 'reconciler', + 'supplier', 'types', 'mode', 'standard', diff --git a/database/migrations/2026_09_22_010000_add_supplier_to_courier_surcharge_quotes_table.php b/database/migrations/2026_09_22_010000_add_supplier_to_courier_surcharge_quotes_table.php new file mode 100644 index 00000000..0ba54e23 --- /dev/null +++ b/database/migrations/2026_09_22_010000_add_supplier_to_courier_surcharge_quotes_table.php @@ -0,0 +1,32 @@ +string('supplier', 191)->nullable()->after('reconciler'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('business_courier_surcharge_quotes') + || ! Schema::hasColumn('business_courier_surcharge_quotes', 'supplier')) { + return; + } + + Schema::table('business_courier_surcharge_quotes', function (Blueprint $table): void { + $table->dropColumn('supplier'); + }); + } +}; diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index 5b72ec04..bfc82ce6 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -17,6 +17,7 @@ ['key' => 'courier', 'label' => '快递名称', 'exportable' => true], ['key' => 'scope', 'label' => '快递归属', 'exportable' => true], ['key' => 'reconciler', 'label' => '对账人', 'exportable' => true], + ['key' => 'supplier', 'label' => '供应商', 'exportable' => true], ['key' => 'type', 'label' => '附加费类型', 'exportable' => true], ['key' => 'mode', 'label' => '结算方式', 'exportable' => true], ['key' => 'standard', 'label' => '计价标准', 'exportable' => true], @@ -62,6 +63,16 @@ .sm-audit-table td:nth-child(5) { min-width:24rem; white-space:normal; line-height:1.5; } .sm-audit-action { display:inline-flex; align-items:center; padding:.15rem .45rem; border-radius:999px; background:var(--sm-soft); color:var(--sm-accent); font-size:.7rem; font-weight:700; } .sm-audit-empty { padding:1.8rem 1rem; text-align:center; color:var(--sm-muted); } + .sm-audit-empty[hidden],.sm-audit-table-wrap[hidden],.sm-audit-table tr[hidden] { display:none; } + .sm-table tr[data-surcharge-row] { cursor:pointer; } + .sm-table tr[data-surcharge-row]:focus-visible { outline:2px solid var(--sm-accent); outline-offset:-2px; } + .sm-table tr.is-log-selected td,.sm-table tr.is-log-selected:hover td,.sm-table tr.is-log-selected [data-column-key="id"],.sm-table tr.is-log-selected [data-column-key="actions"] { background:var(--sm-soft); } + .sm-table td { overflow:hidden; text-overflow:ellipsis; } + .sm-table th .sm-table__resize { position:absolute; top:0; right:-.3rem; z-index:7; width:.6rem; height:100%; cursor:col-resize; touch-action:none; } + .sm-table th .sm-table__resize::after { position:absolute; top:20%; bottom:20%; left:calc(50% - .5px); width:1px; background:var(--sm-border); content:""; } + .sm-table th .sm-table__resize:hover::after,.sm-table th .sm-table__resize:focus-visible::after { width:2px; background:var(--sm-accent); } + .sm-table th .sm-table__resize:focus-visible { outline:2px solid var(--sm-accent); outline-offset:-2px; } + .sm-table.is-resizing,.sm-table.is-resizing * { cursor:col-resize !important; user-select:none; } .sm-column-picker { position:relative; } .sm-column-picker__panel { position:absolute; right:0; z-index:20; display:grid; width:min(22rem,calc(100vw - 2rem)); gap:.65rem; margin-top:.35rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.55rem; background:#fff; box-shadow:0 4px 8px rgba(23,23,23,.1); } .sm-column-picker__panel[hidden] { display:none; } .sm-column-picker__intro { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.7rem; line-height:1.45; } .sm-column-picker__intro strong { color:var(--sm-ink); font-size:.78rem; } .sm-visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .sm-column-picker__list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.35rem; max-height:15rem; overflow:auto; } .sm-column-picker__option { display:flex; align-items:center; gap:.35rem; min-width:0; padding:.35rem .4rem; border:1px solid transparent; border-radius:.35rem; color:var(--sm-ink); font-size:.7rem; cursor:grab; } .sm-column-picker__option:hover,.sm-column-picker__option.is-drop-target { border-color:var(--sm-border); background:#fffdf8; } .sm-column-picker__option.is-fixed { cursor:default; } .sm-column-picker__option.is-dragging { opacity:.55; } .sm-column-picker__option input { flex:none; accent-color:var(--sm-accent); } .sm-column-picker__option .material-icons-outlined { color:var(--sm-muted); font-size:.95rem; } .sm-column-picker__foot { display:flex; justify-content:flex-end; padding-top:.35rem; border-top:1px solid var(--sm-border); } .sm-badge { display:inline-flex; align-items:center; padding:.15rem .5rem; border-radius:999px; font-size:.7rem; font-weight:700; } .sm-badge.is-active { background:#edf5e8; color:#53793e; } .sm-badge.is-inactive { background:#fff4df; color:#935f07; } .sm-empty { padding:3rem 1rem; text-align:center; color:var(--sm-muted); } .sm-rule-note { padding:1rem 1.15rem; color:var(--sm-muted); font-size:.75rem; line-height:1.65; background:#fffdf8; } @@ -114,10 +125,11 @@
| 操作时间 | 操作人 | 动作 | 对象 | 变更内容 |
|---|---|---|---|---|
| {{ $log['occurred_at'] }} | {{ $log['actor_name'] }} | {{ $log['action_label'] }} | @@ -242,6 +254,7 @@ + @@ -278,6 +291,13 @@ if (!root) return; const table = root.querySelector('[data-surcharge-table]'); const tableWrap = root.querySelector('[data-surcharge-table-wrap]'); + const auditPanel = root.querySelector('[data-surcharge-audit-panel]'); + const auditTitle = root.querySelector('[data-surcharge-audit-title]'); + const auditHint = root.querySelector('[data-surcharge-audit-hint]'); + const auditPlaceholder = root.querySelector('[data-surcharge-audit-placeholder]'); + const auditEmpty = root.querySelector('[data-surcharge-audit-empty]'); + const auditTableWrap = root.querySelector('[data-surcharge-audit-table-wrap]'); + const auditRows = [...root.querySelectorAll('[data-audit-log]')]; const picker = root.querySelector('[data-column-picker]'); const columnToggle = picker?.querySelector('[data-column-toggle]'); const columnPanel = picker?.querySelector('[data-column-options]') || picker?.querySelector('.sm-column-picker__panel'); @@ -289,7 +309,10 @@ const mutableKeys = defaultOrder.filter((key) => !fixedKeys.has(key)); const storageKey = root.dataset.columnStorageKey || 'caiwu:table-columns:courier-surcharge:quotes'; const emptyCell = table?.querySelector('[data-empty-cell]'); - let state = { order: [...defaultOrder], visible: new Set(defaultOrder) }; + const minColumnWidth = 72; + const maxColumnWidth = 720; + const clampColumnWidth = (width) => Math.min(maxColumnWidth, Math.max(minColumnWidth, Math.round(Number(width) || minColumnWidth))); + let state = { order: [...defaultOrder], visible: new Set(defaultOrder), widths: {} }; const readState = () => { try { @@ -301,16 +324,19 @@ const visible = Array.isArray(saved.visible) ? new Set(saved.visible.filter((key) => defaultOrder.includes(key))) : new Set(defaultOrder); + const widths = Object.fromEntries(Object.entries(saved.widths || {}) + .filter(([key, width]) => defaultOrder.includes(key) && Number.isFinite(Number(width))) + .map(([key, width]) => [key, clampColumnWidth(width)])); fixedKeys.forEach((key) => visible.add(key)); - state = { order, visible }; + state = { order, visible, widths }; } catch (_) { - state = { order: [...defaultOrder], visible: new Set(defaultOrder) }; + state = { order: [...defaultOrder], visible: new Set(defaultOrder), widths: {} }; } }; const writeState = () => { try { - window.localStorage.setItem(storageKey, JSON.stringify({ order: state.order, visible: [...state.visible] })); + window.localStorage.setItem(storageKey, JSON.stringify({ order: state.order, visible: [...state.visible], widths: state.widths })); } catch (_) {} }; @@ -331,6 +357,22 @@ keys.forEach((key) => { if (cells.has(key)) row.appendChild(cells.get(key)); }); }; + const applyColumnWidths = () => { + if (!table) return; + table.querySelectorAll('[data-column-key]').forEach((cell) => { + const width = state.widths[cell.dataset.columnKey]; + if (Number.isFinite(width)) { + cell.style.width = `${width}px`; + cell.style.minWidth = `${width}px`; + cell.style.maxWidth = `${width}px`; + } else { + cell.style.removeProperty('width'); + cell.style.removeProperty('min-width'); + cell.style.removeProperty('max-width'); + } + }); + }; + const applyColumns = (save = true) => { if (!table) return; const keys = orderedKeys(); @@ -348,6 +390,7 @@ const options = new Map([...columnList.querySelectorAll('[data-column-option]')].map((option) => [option.dataset.columnKey, option])); keys.forEach((key) => { if (options.has(key)) columnList.appendChild(options.get(key)); }); } + applyColumnWidths(); if (save) writeState(); }; @@ -402,12 +445,13 @@ applyColumns(); })); picker?.querySelector('[data-column-reset]')?.addEventListener('click', () => { - state = { order: [...defaultOrder], visible: new Set(defaultOrder) }; + state = { order: [...defaultOrder], visible: new Set(defaultOrder), widths: {} }; applyColumns(); }); const dragSource = (element, sourceKey) => { element.addEventListener('dragstart', (event) => { + if (event.target.closest?.('[data-column-resize]')) { event.preventDefault(); return; } if (fixedKeys.has(sourceKey)) { event.preventDefault(); return; } event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', sourceKey); @@ -431,6 +475,51 @@ moveMutableKey(event.dataTransfer.getData('text/plain'), key, event.offsetX < header.offsetWidth / 2); }); }); + headers.forEach((header) => { + const handle = header.querySelector('[data-column-resize]'); + const key = header.dataset.columnKey; + if (!handle || !key) return; + const setWidth = (width, save = false) => { + state.widths[key] = clampColumnWidth(width); + applyColumnWidths(); + if (save) writeState(); + }; + handle.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startWidth = header.getBoundingClientRect().width; + table?.classList.add('is-resizing'); + handle.setPointerCapture?.(event.pointerId); + const move = (moveEvent) => setWidth(startWidth + moveEvent.clientX - startX); + const finish = (finishEvent) => { + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + if (handle.hasPointerCapture?.(finishEvent.pointerId)) handle.releasePointerCapture(finishEvent.pointerId); + table?.classList.remove('is-resizing'); + writeState(); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + }); + handle.addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + const current = state.widths[key] || header.getBoundingClientRect().width; + setWidth(current + (event.key === 'ArrowRight' ? 10 : -10), true); + }); + handle.addEventListener('dblclick', (event) => { + event.preventDefault(); + event.stopPropagation(); + delete state.widths[key]; + applyColumnWidths(); + writeState(); + }); + }); columnList?.querySelectorAll('[data-column-option]').forEach((option) => { if (option.dataset.columnKey === undefined || fixedKeys.has(option.dataset.columnKey)) return; const key = option.dataset.columnKey; @@ -453,6 +542,39 @@ tableWrap.classList.toggle('is-scrolled-x', tableWrap.scrollLeft > 0); }, { passive: true }); + const selectQuoteLog = (row) => { + const quoteId = String(row.dataset.auditQuoteId || ''); + const quoteLabel = row.dataset.auditQuoteLabel || '当前报价'; + root.querySelectorAll('[data-surcharge-row]').forEach((candidate) => { + const selected = candidate === row; + candidate.classList.toggle('is-log-selected', selected); + candidate.setAttribute('aria-selected', String(selected)); + }); + let visibleCount = 0; + auditRows.forEach((auditRow) => { + const visible = auditRow.dataset.targetType === 'quote' && auditRow.dataset.targetId === quoteId; + auditRow.hidden = !visible; + if (visible) visibleCount++; + }); + if (auditTitle) auditTitle.textContent = `操作日志 · ${quoteLabel}`; + if (auditHint) auditHint.textContent = visibleCount ? `${visibleCount} 条记录` : '暂无记录'; + if (auditPlaceholder) auditPlaceholder.hidden = true; + if (auditEmpty) auditEmpty.hidden = visibleCount > 0; + if (auditTableWrap) auditTableWrap.hidden = visibleCount === 0; + auditPanel?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }; + root.querySelectorAll('[data-surcharge-row]').forEach((row) => { + row.addEventListener('click', (event) => { + if (event.target.closest('button,a,input,select,textarea,form,label')) return; + selectQuoteLog(row); + }); + row.addEventListener('keydown', (event) => { + if (!['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + selectQuoteLog(row); + }); + }); + const modal = root.querySelector('[data-surcharge-modal]'); const typeModal = root.querySelector('[data-type-modal]'); const importModal = root.querySelector('[data-surcharge-import-modal]'); @@ -482,7 +604,7 @@ }); root.querySelectorAll('[data-surcharge-edit]').forEach((button) => button.addEventListener('click', () => { const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); form.action = row.dataset.updateUrl; methodField.value = 'PATCH'; title.textContent = '编辑报价规则'; - ['courier', 'scope', 'mode', 'standard', 'condition', 'effective_at', 'reconciler', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; }); + ['courier', 'scope', 'mode', 'standard', 'condition', 'effective_at', 'reconciler', 'supplier', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; }); const selectedTypes = Array.isArray(data.types) ? data.types : (data.type || '').split('、').map((item) => item.trim()).filter(Boolean); form.querySelectorAll('input[name="types[]"]').forEach((checkbox) => { checkbox.checked = selectedTypes.includes(checkbox.value); }); modal.classList.add('is-open'); diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index ee6dd409..133c5c77 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -25,7 +25,7 @@ class CourierSurchargePageTest extends FeatureTestCase $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $this->createQuote(); - $this->loginAs() + $response = $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'courier-surcharge', @@ -46,6 +46,8 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('代发仓') ->assertSeeText('对账人') ->assertSeeText('张会计') + ->assertSeeText('供应商') + ->assertSeeText('曹县物流供应商') ->assertSeeText('附加费类型(可多选)') ->assertSeeText('列设置') ->assertSeeText('导出当前列') @@ -56,18 +58,27 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('规则编号与操作列固定显示') ->assertSee('data-column-storage-key="caiwu:table-columns:', false) ->assertSee('data-surcharge-table', false) + ->assertSee('data-surcharge-audit-panel', false) + ->assertSee('data-surcharge-audit-placeholder', false) + ->assertSee('data-surcharge-audit-table-wrap hidden', false) ->assertSee('data-column-check', false) + ->assertSee('data-column-resize', false) + ->assertSee('aria-orientation="vertical"', false) ->assertSee('data-surcharge-export-current', false) ->assertSee('data-surcharge-export-all', false) ->assertSee('data-column-reset', false) ->assertSee('name="types[]"', false) - ->assertSeeInOrder(['data-column-key="courier"', 'data-column-key="scope"', 'data-column-key="reconciler"'], false) + ->assertSeeInOrder(['data-column-key="courier"', 'data-column-key="scope"', 'data-column-key="reconciler"', 'data-column-key="supplier"'], false) + ->assertSee('data-audit-quote-id="', false) ->assertSeeText('曹县德邦') ->assertSeeText('超长费') ->assertSeeText('按尺寸/最长边') ->assertSeeText('8 元/单') ->assertSeeText('已启用') ->assertSeeText('后续结算页面将按启用中的规则计算'); + + $this->assertSame(13, substr_count($response->getContent(), 'data-column-resize>')); + $this->assertStringContainsString('widths: state.widths', $response->getContent()); } public function test_courier_surcharge_type_can_be_created_and_duplicate_is_rejected(): void @@ -151,6 +162,7 @@ class CourierSurchargePageTest extends FeatureTestCase 'courier' => '常州松宇货运', 'scope' => '代发仓', 'reconciler' => '李会计', + 'supplier' => '常州物流供应商', 'types' => ['送货费'], 'mode' => '按体积', 'standard' => '体积 ≤ 10 m³:50 元/单', @@ -167,6 +179,7 @@ class CourierSurchargePageTest extends FeatureTestCase 'courier' => '常州松宇货运', 'scope' => '代发仓', 'reconciler' => '李会计', + 'supplier' => '常州物流供应商', 'status' => '已启用', ])) ->assertOk() @@ -174,11 +187,49 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('送货费') ->assertSeeText('代发仓') ->assertSeeText('李会计') + ->assertSeeText('常州物流供应商') ->assertSeeText('当前规则') ->assertDontSee('曹县德邦 | ', false) ->assertDontSee('霸州百世快运-雅柏 | ', false); } + public function test_courier_surcharge_page_keeps_creation_order_instead_of_effective_date_order(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $first = CourierSurchargeQuote::query()->create([ + 'company_id' => company_id(), + 'courier' => '顺序测试第一条', + 'scope' => '自有快递', + 'reconciler' => '苏木', + 'supplier' => null, + 'types' => ['送货费'], + 'mode' => '固定单价', + 'standard' => '1 元/单', + 'condition' => '第一条', + 'effective_at' => '2027-12-31', + 'status' => CourierSurchargeQuote::STATUS_ACTIVE, + ]); + $second = CourierSurchargeQuote::query()->create([ + 'company_id' => company_id(), + 'courier' => '顺序测试第二条', + 'scope' => '自有快递', + 'reconciler' => '苏木', + 'supplier' => null, + 'types' => ['进仓费'], + 'mode' => '固定单价', + 'standard' => '2 元/单', + 'condition' => '第二条', + 'effective_at' => '2026-01-01', + 'status' => CourierSurchargeQuote::STATUS_ACTIVE, + ]); + + $this->assertLessThan($second->id, $first->id); + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge'])) + ->assertOk() + ->assertSeeInOrder(['顺序测试第一条', '顺序测试第二条']); + } + public function test_courier_surcharge_store_update_and_status_are_persistent(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -188,6 +239,7 @@ class CourierSurchargePageTest extends FeatureTestCase 'courier' => '测试快递', 'scope' => '代发仓', 'reconciler' => '李会计', + 'supplier' => '', 'types' => ['拦截费', '改地址'], 'mode' => '固定单价', 'standard' => '12 元/单', @@ -203,6 +255,7 @@ class CourierSurchargePageTest extends FeatureTestCase $quote = CourierSurchargeQuote::query()->where('courier', '测试快递')->firstOrFail(); $this->assertSame(['拦截费', '改地址'], $quote->types); + $this->assertNull($quote->supplier); $this->assertSame(CourierSurchargeQuote::STATUS_ACTIVE, $quote->status); $this->assertSame((int) user_id(), (int) $quote->created_by); $this->assertSame((int) user_id(), (int) $quote->updated_by); @@ -210,11 +263,13 @@ class CourierSurchargePageTest extends FeatureTestCase $this->patch(route('business.courier-surcharge.update', ['quote' => $quote->id]), [ ...$payload, 'courier' => '测试快递(已修改)', + 'supplier' => '测试供应商', 'types' => ['保费'], ])->assertRedirect(); $quote->refresh(); $this->assertSame('测试快递(已修改)', $quote->courier); + $this->assertSame('测试供应商', $quote->supplier); $this->assertSame(['保费'], $quote->types); $this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [ @@ -233,6 +288,7 @@ class CourierSurchargePageTest extends FeatureTestCase 'courier' => '日志测试快递', 'scope' => '自有快递', 'reconciler' => '周会计', + 'supplier' => '原供应商', 'types' => ['进仓费'], 'mode' => '固定单价', 'standard' => '10 元/单', @@ -255,6 +311,7 @@ class CourierSurchargePageTest extends FeatureTestCase $this->patch(route('business.courier-surcharge.update', ['quote' => $quote->id]), [ ...$payload, + 'supplier' => '新供应商', 'standard' => '12 元/单', 'remark' => '调整后标准', ])->assertRedirect(); @@ -268,6 +325,8 @@ class CourierSurchargePageTest extends FeatureTestCase $this->assertSame('12 元/单', data_get($updated->changes, 'after.standard')); $this->assertSame('初始标准', data_get($updated->changes, 'before.remark')); $this->assertSame('调整后标准', data_get($updated->changes, 'after.remark')); + $this->assertSame('原供应商', data_get($updated->changes, 'before.supplier')); + $this->assertSame('新供应商', data_get($updated->changes, 'after.supplier')); $this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [ 'status' => CourierSurchargeQuote::STATUS_INACTIVE, @@ -289,15 +348,22 @@ class CourierSurchargePageTest extends FeatureTestCase 'action' => CourierSurchargeAuditLog::ACTION_ACTIVATED, ]); - $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge'])) + $response = $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge'])) ->assertOk() ->assertSeeText('操作日志') + ->assertSeeText('请选择上方一条报价') ->assertSeeText('新增') ->assertSeeText('更改') ->assertSeeText('停用') ->assertSeeText('启用') ->assertSeeText($this->user->name) + ->assertSeeText('供应商:原供应商 → 新供应商') ->assertSeeText('计价标准:10 元/单 → 12 元/单'); + + $response + ->assertSee('data-audit-quote-id="' . $quote->id . '"', false) + ->assertSee('data-audit-log data-target-type="quote" data-target-id="' . $quote->id . '" hidden', false) + ->assertSee('aria-selected="false"', false); } public function test_type_operations_are_recorded_and_other_company_logs_are_hidden(): void @@ -339,7 +405,7 @@ class CourierSurchargePageTest extends FeatureTestCase $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge'])) ->assertOk() - ->assertSeeText('附加费类型 · 日志专用类型') + ->assertSee('data-audit-log data-target-type="type" data-target-id="' . $type->id . '" hidden', false) ->assertDontSeeText('其他公司的私有类型'); } @@ -383,10 +449,10 @@ class CourierSurchargePageTest extends FeatureTestCase try { $this->assertSame( CourierSurchargeQuoteWorkbookImporter::HEADERS, - $spreadsheet->getSheet(0)->rangeToArray('A1:J1')[0], + $spreadsheet->getSheet(0)->rangeToArray('A1:K1')[0], ); $this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle()); - $this->assertStringContainsString('不会静默覆盖', (string) $spreadsheet->getSheet(1)->getCell('B8')->getValue()); + $this->assertStringContainsString('不会静默覆盖', (string) $spreadsheet->getSheet(1)->getCell('B9')->getValue()); } finally { $spreadsheet->disconnectWorksheets(); } @@ -397,7 +463,7 @@ class CourierSurchargePageTest extends FeatureTestCase $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $this->loginAs(); $file = $this->workbook([[ - '导入测试快递', '代发仓', '李会计', '进仓费、超长费', '固定单价', + '导入测试快递', '代发仓', '李会计', '导入供应商', '进仓费、超长费', '固定单价', '12.50 元/单', '入仓即收取', '2026-09-22', '已停用', '批量导入测试', ]]); @@ -416,6 +482,7 @@ class CourierSurchargePageTest extends FeatureTestCase $quote = CourierSurchargeQuote::query()->where('courier', '导入测试快递')->firstOrFail(); $this->assertSame(['进仓费', '超长费'], $quote->types); + $this->assertSame('导入供应商', $quote->supplier); $this->assertSame(CourierSurchargeQuote::STATUS_INACTIVE, $quote->status); $this->assertSame('批量导入测试', $quote->remark); $this->assertDatabaseHas('business_courier_surcharge_audit_logs', [ @@ -441,7 +508,7 @@ class CourierSurchargePageTest extends FeatureTestCase $previewResponse = $this->post(route('business.courier-surcharge.import.preview'), [ 'import_file' => $this->workbook([[ - '', '未知归属', '李会计', '超长费、虚构费', '未知方式', '', + '', '未知归属', '李会计', '', '超长费、虚构费', '未知方式', '', '测试条件', '2026-02-31', '未知状态', '', ]]), ]); @@ -476,7 +543,7 @@ class CourierSurchargePageTest extends FeatureTestCase ])->assertSessionHasErrors('import'); $rows = array_fill(0, CourierSurchargeQuoteWorkbookImporter::MAX_ROWS + 1, [ - '测试快递', '自有快递', '周会计', '进仓费', '固定单价', + '测试快递', '自有快递', '周会计', '', '进仓费', '固定单价', '1 元/单', '测试条件', '2026-09-22', '已启用', '', ]); $this->withExceptionHandling()->post(route('business.courier-surcharge.import.preview'), [ @@ -489,7 +556,7 @@ class CourierSurchargePageTest extends FeatureTestCase $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $this->loginAs(); $duplicateRow = [ - '文件内重复快递', '自有快递', '周会计', '进仓费', '固定单价', + '文件内重复快递', '自有快递', '周会计', '重复测试供应商', '进仓费', '固定单价', '5 元/单', '重复条件', '2026-09-22', '已启用', '', ]; $response = $this->post(route('business.courier-surcharge.import.preview'), [ @@ -503,7 +570,7 @@ class CourierSurchargePageTest extends FeatureTestCase $existing = $this->createQuote(); $existingRow = [ - $existing->courier, $existing->scope, $existing->reconciler, $existing->typeLabel(), $existing->mode, + $existing->courier, $existing->scope, $existing->reconciler, $existing->supplier, $existing->typeLabel(), $existing->mode, '新计价标准', $existing->condition, $existing->effective_at->format('Y-m-d'), '已停用', '更新后的备注', ]; $skipPreview = $this->post(route('business.courier-surcharge.import.preview'), [ @@ -534,6 +601,24 @@ class CourierSurchargePageTest extends FeatureTestCase ]); } + public function test_import_duplicate_identity_includes_optional_supplier(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->loginAs(); + $base = [ + '供应商区分快递', '自有快递', '周会计', '供应商甲', '进仓费', '固定单价', + '5 元/单', '相同条件', '2026-09-22', '已启用', '', + ]; + $otherSupplier = $base; + $otherSupplier[3] = '供应商乙'; + + $this->post(route('business.courier-surcharge.import.preview'), [ + 'import_file' => $this->workbook([$base, $otherSupplier]), + ])->assertSessionHas('courierSurchargeImportPreview', function (array $preview): bool { + return $preview['summary'] === ['total' => 2, 'valid' => 2, 'new' => 2, 'duplicate' => 0, 'invalid' => 0]; + }); + } + public function test_import_commit_rejects_expired_or_other_context_preview(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -600,6 +685,7 @@ class CourierSurchargePageTest extends FeatureTestCase 'courier' => '曹县德邦', 'scope' => '自有快递', 'reconciler' => '张会计', + 'supplier' => '曹县物流供应商', 'types' => ['超长费'], 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 160 cm:8 元/单',