输入两字以上可搜索近期发货名称;找不到也可手填,不要求先建快递资料。请使用发货数据中的完整名称,不要只填品牌;说明页、重复报价可取消勾选。各表图片分别留存,存档不代表已核对一致。
+
@@ -160,7 +161,10 @@
{{ $row['sheet_name'] }} {{ $row['source_range'] ?? '空表' }} @error('entries.' . $index){{ $message }} @enderror |
{{ $row['match_status'] }} |
|
-
留空沿用共用开始日期留空沿用共用截止日期;均空为长期 |
+
+ @include('business.pages.courier-payables.partials.stock-quote-periods', ['named' => false, 'periods' => $choice['periods'] ?? [['effective_date' => $choice['effective_date'] ?? '', 'end_date' => $choice['end_date'] ?? '']]])
+ 单段留空沿用共用日期;多段请逐段填写
+ |
@if(count($row['signed_images'] ?? []))
已留存 {{ count($row['signed_images']) }} 张 · 待核对
@@ -215,37 +219,41 @@
const courierCode = row => courierName(row) === row.dataset.initialName ? row.dataset.initialCode : '';
const identity = row => courierCode(row) ? 'code:' + courierCode(row) : 'name:' + courierName(row).toLowerCase();
const existingPeriods = @json(app(\App\Support\CourierStockQuoteEntryService::class)->existingIdentities(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
- const period = row => ({start: row.querySelector('[data-row-date]').value || commonDate.value, end: row.querySelector('[data-row-end]').value || form.querySelector('[data-common-end]').value || '9999-12-31'});
+ const periods = row => {
+ const inputs = [...row.querySelectorAll('[data-period]')];
+ return inputs.map((period, index) => ({
+ start: period.querySelector('[data-row-date]').value || (index === 0 ? commonDate.value : ''),
+ end: period.querySelector('[data-row-end]').value || (inputs.length === 1 ? form.querySelector('[data-common-end]').value : '') || '9999-12-31'
+ }));
+ };
const overlaps = (a,b) => !a.start || !b.start || (a.start <= b.end && b.start <= a.end);
const isExisting = row => existingPeriods.some(entry => {
const same = courierCode(row) && entry.code ? courierCode(row) === entry.code : courierName(row).toLowerCase() === entry.name.trim().toLowerCase();
- return same && entry.quote_type === form.querySelector('[data-common-type]').value.trim() && overlaps(period(row), {start:entry.effective_date,end:entry.end_date || '9999-12-31'});
+ return same && entry.quote_type === form.querySelector('[data-common-type]').value.trim() && periods(row).some(period => overlaps(period, {start:entry.effective_date,end:entry.end_date || '9999-12-31'}));
});
const update = () => {
const search = form.querySelector('[data-batch-search]').value.trim().toLowerCase();
const onlyUnmatched = form.querySelector('[data-only-unmatched]').checked;
- let selected = 0, pending = 0;
- const identities = new Map();
- rows.forEach(row => {
- const key = identity(row);
- if (row.querySelector('[data-row-selected]').checked && courierName(row)) identities.set(key, (identities.get(key) || 0) + 1);
- });
+ let selected = 0, pending = 0, periodCount = 0;
rows.forEach(row => {
const name = courierName(row);
const checked = row.querySelector('[data-row-selected]').checked;
const known = knownNames.has(name);
const duplicate = isExisting(row);
const invalid = row.dataset.invalid === '1';
- const repeated = checked && rows.some(other => other !== row && other.querySelector('[data-row-selected]').checked && identity(other) === identity(row) && overlaps(period(row), period(other)));
+ const dates = periods(row);
+ const internalOverlap = dates.some((period, index) => dates.slice(index + 1).some(other => overlaps(period, other)));
+ const invalidDate = dates.some(period => !period.start || period.end < period.start);
+ const repeated = checked && rows.some(other => other !== row && other.querySelector('[data-row-selected]').checked && identity(other) === identity(row) && dates.some(period => periods(other).some(otherPeriod => overlaps(period, otherPeriod))));
const status = row.querySelector('[data-row-status]');
- status.textContent = invalid ? '空表,已跳过' : duplicate ? '与已有报价期间重叠,请调整日期' : repeated ? '与本批同一快递的期间重叠' : !name ? '请填写完整快递名称' : courierName(row) === row.dataset.initialName && row.dataset.initialStatus.includes('多条资料') ? row.dataset.initialStatus : known ? '名称已匹配,期间无重叠' : '按名称录入,请确认与发货数据一致';
- status.classList.toggle('sb-error', !invalid && (!name || repeated));
- row.querySelector('[data-row-date-hint]').textContent = row.querySelector('[data-row-date]').value ? '单独执行日期' : commonDate.value ? '沿用 ' + commonDate.value : '请填写共用或单独日期';
- const needsAttention = !known || repeated || duplicate || invalid;
+ status.textContent = invalid ? '空表,已跳过' : invalidDate ? '请补全开始日期,截止不能早于开始' : internalOverlap ? '本表执行区间重叠,请调整日期' : duplicate ? '与已有报价期间重叠,请调整日期' : repeated ? '与本批同一快递的期间重叠' : !name ? '请填写完整快递名称' : courierName(row) === row.dataset.initialName && row.dataset.initialStatus.includes('多条资料') ? row.dataset.initialStatus : known ? '名称已匹配,期间无重叠' : '按名称录入,请确认与发货数据一致';
+ status.classList.toggle('sb-error', !invalid && (!name || repeated || duplicate || internalOverlap || invalidDate));
+ row.querySelector('[data-row-date-hint]').textContent = dates.length > 1 ? '多段截止留空表示长期,不沿用共用截止;区间不得重叠' : row.querySelector('[data-row-date]').value ? '单独执行日期;截止留空沿用共用截止' : commonDate.value ? '沿用 ' + commonDate.value + ';截止留空沿用共用截止' : '请填写共用或单独日期';
+ const needsAttention = !known || repeated || duplicate || invalid || internalOverlap || invalidDate;
row.hidden = !(row.dataset.sheetName + ' ' + name).toLowerCase().includes(search) || (onlyUnmatched && !needsAttention);
- if (checked) { selected++; if (needsAttention) pending++; }
+ if (checked) { selected++; periodCount += dates.length; if (needsAttention) pending++; }
});
- form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 家 · 其中 ${pending} 家需确认名称或处理重复 · 跳过 ${rows.length - selected} 张工作表`;
+ form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 张工作表 · ${periodCount} 个执行区间 · 其中 ${pending} 张需处理 · 跳过 ${rows.length - selected} 张工作表`;
};
const updateGroups = () => {
const names = [...groups.querySelectorAll('[data-rule-name]')].map((input, index) => input.value || `规则分组 ${index + 1}`);
@@ -276,6 +284,20 @@
visible.forEach(row => row.querySelector('[data-row-selected]').checked = select); update();
});
let saving = false;
+ const serializeRow = row => {
+ const selected = row.querySelector('[data-row-selected]').checked;
+ const dates = periods(row);
+ return {
+ selected, courier_name: courierName(row), courier_expcode: courierCode(row),
+ rule_group: Number(row.querySelector('[data-row-rule]').value),
+ ...(selected && dates.length > 1 ? {
+ periods: dates.map(period => ({effective_date: period.start, end_date: period.end === '9999-12-31' ? null : period.end}))
+ } : {
+ effective_date: row.querySelector('[data-row-date]').value,
+ end_date: row.querySelector('[data-row-end]').value
+ })
+ };
+ };
form.addEventListener('submit', async event => {
event.preventDefault();
if (saving) return;
@@ -286,7 +308,7 @@
confirmed: form.querySelector('[data-review-confirmed]').checked,
submit_for_review: event.submitter?.dataset.saveMode === 'review',
rule_groups: [...groups.querySelectorAll('[data-rule-group]')].map(group => ({ name: group.querySelector('[data-rule-name]').value, rules: group.querySelector('[data-rule-text]').value })),
- entries: rows.map(row => ({ selected: row.querySelector('[data-row-selected]').checked, courier_name: courierName(row), courier_expcode: courierCode(row), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').value, end_date: row.querySelector('[data-row-end]').value }))
+ entries: rows.map(serializeRow)
};
const errors = root.querySelector('[data-save-errors]');
errors.hidden = true;
diff --git a/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php b/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php
index 6e78e7c8..bba5d141 100644
--- a/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php
+++ b/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php
@@ -53,7 +53,7 @@
- 同一家快递、同一类型只录入一次;后续调整请从对应报价发起变价。
+ 同一家快递、同一类型可录入多个不重叠的执行区间;后续调整请从对应报价发起变价。
@endif
@@ -77,8 +77,14 @@
+ @if(!$entry)
+ 实际开始执行日期 · 可添加多个执行区间
+ @include('business.pages.courier-payables.partials.stock-quote-periods', ['named' => true, 'periods' => old('periods', [['effective_date' => old('effective_date', ''), 'end_date' => old('end_date', '')]])])
+
+ @else
+ @endif
本次将报价接入版本管理;运费自动核算仍以已实现且经核验的计算规则为准。
diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php
index d26005c5..741569fe 100644
--- a/tests/Feature/Business/CourierStockQuoteBatchTest.php
+++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php
@@ -109,6 +109,85 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
}
+ public function test_one_sheet_with_three_periods_reuses_originals_and_only_executes_within_each_period(): void
+ {
+ $batch = $this->preview(['EXP-001', '说明'], [1]);
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('添加执行区间');
+ $selection = $this->selection(['EXP-001', '']);
+ $selection['entries'][1]['selected'] = false;
+ $periods = [
+ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
+ ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
+ ['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
+ ];
+ $selection['entries'][0]['periods'] = $periods;
+ $selection['submit_for_review'] = $selection['confirmed'] = true;
+ $url = route('business.courier-stock-quotes.batch-save', $batch);
+ $this->postJson($url, $selection)->assertOk();
+ $entries = CourierStockQuoteEntry::with('change')->orderBy('id')->get();
+ $this->assertCount(3, $entries);
+ $this->assertCount(1, $entries->pluck('values_json')->unique());
+ $this->assertCount(1, $entries->pluck('formulas_json')->unique());
+ $this->assertCount(1, $entries->pluck('files')->map(fn ($files) => json_encode($files))->unique());
+ $result = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id)['result'];
+ $this->assertSame(1, $result['selected_sheets']);
+ $this->assertSame(1, $result['skipped']);
+ $this->assertSame($periods, array_map(fn ($row) => \Illuminate\Support\Arr::only($row, ['effective_date', 'end_date']), $result['created']));
+ $reviewer = user_model_class()::create([
+ 'name' => '区间审核员', 'email' => 'period-reviewer@example.com', 'password' => 'local-test-password',
+ 'locale' => 'zh-CN', 'enabled' => 1, 'landing_page' => 'dashboard', 'created_from' => 'core::test',
+ ]);
+ $reviewer->companies()->attach($this->company->id);
+ $versions = $entries->map(fn ($entry) => app(CourierQuoteChangeService::class)->approve($entry->change, (int) $reviewer->id)->version);
+ foreach ($periods as $index => $period) {
+ foreach ($period as $date) {
+ $this->assertSame($versions[$index]->id, \App\Support\CourierRequoteService::resolveAt($versions, \Carbon\Carbon::parse($date))?->id);
+ }
+ }
+ foreach (['2026-10-20', '2026-10-25', '2026-11-05', '2026-11-10', '2026-11-15'] as $date) {
+ $this->assertNull(\App\Support\CourierRequoteService::resolveAt($versions, \Carbon\Carbon::parse($date)));
+ }
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('3')->assertSeeText('2026-11-14');
+ $this->postJson($url, $selection)->assertOk();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 3);
+ }
+
+ public function test_multi_period_validation_rejects_overlaps_missing_dates_and_bad_ranges_without_partial_imports(): void
+ {
+ $batch = $this->preview(['EXP-001', 'EXP-003']);
+ $selection = $this->selection(['EXP-001', 'EXP-003']);
+ $url = route('business.courier-stock-quotes.batch-save', $batch);
+ $this->withExceptionHandling();
+ foreach ([
+ [],
+ [['effective_date' => '', 'end_date' => '2026-11-04']],
+ [['effective_date' => '2026-02-30', 'end_date' => null]],
+ [['effective_date' => '2026-11-04', 'end_date' => '2026-11-01']],
+ array_fill(0, 21, ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04']),
+ [['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ['effective_date' => '2026-11-04', 'end_date' => '2026-11-08']],
+ [['effective_date' => '2026-11-01', 'end_date' => null], ['effective_date' => '2026-11-11', 'end_date' => null]],
+ ] as $periods) {
+ $selection['entries'][0]['periods'] = $periods;
+ $this->postJson($url, $selection)->assertUnprocessable();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $this->assertDatabaseCount('business_courier_quote_changes', 0);
+ }
+ $selection['entries'][0]['periods'] = [['effective_date' => '2026-11-01', 'end_date' => '2026-11-01']];
+ $selection['entries'][1]['periods'] = [['effective_date' => '2026-11-11', 'end_date' => null]];
+ $this->postJson($url, $selection)->assertOk();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ // 新批次任意一段与已有区间重叠时,所有新段都不写入。
+ $nextBatch = $this->preview(['EXP-001']);
+ $next = $this->selection(['EXP-001']);
+ $next['entries'][0]['periods'] = [
+ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
+ ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
+ ];
+ $this->postJson(route('business.courier-stock-quotes.batch-save', $nextBatch), $next)->assertUnprocessable()
+ ->assertJsonValidationErrors('entries.0.periods.1');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ }
+
public function test_bulk_submit_requires_confirmation_and_does_not_approve_or_fake_signatures(): void
{
$batch = $this->preview(['EXP-001', 'EXP-002']);
@@ -254,7 +333,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
}
- public function test_one_hundred_twenty_sheets_preview_and_save_as_one_batch(): void
+ public function test_one_hundred_twenty_sheets_with_three_periods_preview_and_save_as_one_batch(): void
{
$couriers = $names = [];
for ($index = 1; $index <= 120; $index++) {
@@ -262,12 +341,21 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$couriers[] = ['code' => $code, 'name' => '批量网点' . $index];
}
$this->mirror($couriers);
- $batch = $this->preview($names, array_fill(0, 120, 1));
+ $batch = $this->preview($names, array_fill(0, 120, 1), 32);
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120');
config(['firewall.middleware.xss.enabled' => true]);
- $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $this->selection($names))->assertOk();
- $this->assertDatabaseCount('business_courier_stock_quote_entries', 120);
- $this->assertDatabaseCount('business_courier_quote_changes', 120);
+ $selection = $this->selection($names);
+ foreach ($selection['entries'] as &$row) {
+ $row['periods'] = [
+ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
+ ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
+ ['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
+ ];
+ }
+ unset($row);
+ $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection)->assertOk();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 360);
+ $this->assertDatabaseCount('business_courier_quote_changes', 360);
$this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique());
$this->assertCount(120, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[1]['path'])->unique());
}
@@ -415,10 +503,10 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('无法无损提取');
}
- private function preview(array $names, array $imageCounts = []): string
+ private function preview(array $names, array $imageCounts = [], int $regionRows = 1): string
{
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
- 'source_quote' => $this->workbook($names, $imageCounts), 'quote_type' => '标准快递报价',
+ 'source_quote' => $this->workbook($names, $imageCounts, 'Xlsx', $regionRows), 'quote_type' => '标准快递报价',
'effective_date' => '2025-12-01', 'billing_rules' => '按原表重量段计费,面单另计',
])->assertRedirect();
@@ -434,7 +522,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
];
}
- private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx'): UploadedFile
+ private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx', int $regionRows = 1): UploadedFile
{
$book = new Spreadsheet();
foreach ($names as $index => $name) {
@@ -444,6 +532,11 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
}
$sheet->fromArray([['省份', '首重', '续重'], ['江苏省', 5 + $index, null]]);
$sheet->setCellValue('C2', '=B2/2');
+ for ($region = 2; $region <= $regionRows; $region++) {
+ $sheet->setCellValue('A' . ($region + 1), '测试地区' . $region);
+ $sheet->setCellValue('B' . ($region + 1), 5 + $index);
+ $sheet->setCellValue('C' . ($region + 1), '=B' . ($region + 1) . '/2');
+ }
for ($imageIndex = 0; $imageIndex < ($imageCounts[$index] ?? 0); $imageIndex++) {
$image = imagecreatetruecolor(24 + $index, 32 + $imageIndex);
imagefill($image, 0, 0, imagecolorallocate($image, (80 + $index * 20) % 256, 50, (30 + $imageIndex * 20) % 256));
diff --git a/tests/Feature/Business/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php
index bef3a5f3..80fff17d 100644
--- a/tests/Feature/Business/CourierStockQuoteEntryTest.php
+++ b/tests/Feature/Business/CourierStockQuoteEntryTest.php
@@ -47,7 +47,7 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
public function test_entry_uses_existing_courier_and_retains_original_file_formula_and_real_date(): void
{
$this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk()
- ->assertSee('value="同名快递网点"', false)->assertSeeText('实际开始执行日期')->assertDontSee('name="courier_expcode"', false);
+ ->assertSee('value="同名快递网点"', false)->assertSeeText('实际开始执行日期')->assertSee('data-stock-courier-code', false);
$data = $this->payload();
$originalHash = hash_file('sha256', $data['source_quote']->getRealPath());
$this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect();
@@ -89,6 +89,40 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
$this->assertSame('存量报价,审核生效(无需签章)', $snapshot['signature_status']);
}
+ public function test_single_upload_can_create_disjoint_periods_and_cleans_up_files_if_any_period_conflicts(): void
+ {
+ $this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk()->assertSeeText('添加执行区间');
+ $data = $this->payload();
+ unset($data['effective_date']);
+ $data['periods'] = [
+ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
+ ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
+ ['effective_date' => '2026-11-11', 'end_date' => null],
+ ];
+ $this->post(route('business.courier-stock-quotes.store'), $data)->assertSessionHasNoErrors()
+ ->assertRedirect(route('business.courier-quote-changes.index'));
+ $entries = CourierStockQuoteEntry::orderBy('id')->get();
+ $this->assertCount(3, $entries);
+ $this->assertCount(1, $entries->pluck('values_json')->unique());
+ $this->assertCount(1, $entries->pluck('files')->map(fn ($files) => $files[0]['path'])->unique());
+ $this->assertSame('2026-11-04', $entries[1]->change->end_date->toDateString());
+ $this->assertNull($entries[2]->end_date);
+ $files = Storage::disk('uploads')->allFiles();
+ $this->assertCount(1, $files);
+ $conflict = $this->payload();
+ $conflict['periods'] = [
+ ['effective_date' => '2026-10-01', 'end_date' => '2026-10-02'],
+ ['effective_date' => '2026-10-24', 'end_date' => '2026-10-25'],
+ ];
+ $this->withExceptionHandling()->post(route('business.courier-stock-quotes.store'), $conflict)->assertSessionHasErrors('courier_name');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 3);
+ $this->assertSame($files, Storage::disk('uploads')->allFiles());
+ $conflict['source_quote'] = $this->workbook();
+ $conflict['periods'][1] = ['effective_date' => '2026-11-04', 'end_date' => '2026-11-01'];
+ $this->post(route('business.courier-stock-quotes.store'), $conflict)->assertSessionHasErrors('periods.1.end_date');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 3);
+ }
+
public function test_historical_documents_require_reviewer_verification_and_remain_distinct_from_esign(): void
{
$entry = $this->createEntry(['signed_files' => [UploadedFile::fake()->create('历史合同.pdf', 5, 'application/pdf')]]);
--
2.47.2
From 807ac6878ca83ffe1c4b9d5b925fed505648019b Mon Sep 17 00:00:00 2001
From: limeng <1131177064@qq.com>
Date: Sat, 24 Oct 2026 17:23:28 +0800
Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=B2=E9=80=80?=
=?UTF-8?q?=E5=9B=9E=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7=E4=BB=8D=E9=98=BB?=
=?UTF-8?q?=E6=AD=A2=E9=87=8D=E6=96=B0=E5=BD=95=E5=85=A5=E6=89=A7=E8=A1=8C?=
=?UTF-8?q?=E5=8C=BA=E9=97=B4=20(#503)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/Support/CourierQuoteChangeService.php | 6 +++
app/Support/CourierStockQuoteEntryService.php | 6 ++-
.../Business/CourierStockQuoteBatchTest.php | 32 +++++++++++++++
.../Business/CourierStockQuoteEntryTest.php | 39 +++++++++++++++++++
4 files changed, 81 insertions(+), 2 deletions(-)
diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php
index b30c0970..30fcbfb6 100644
--- a/app/Support/CourierQuoteChangeService.php
+++ b/app/Support/CourierQuoteChangeService.php
@@ -95,6 +95,12 @@ final class CourierQuoteChangeService
}
return DB::transaction(function () use ($change, $userId): CourierQuoteChange {
+ if ($change->isStockQuote() && $change->status === CourierQuoteChange::STATUS_RETURNED) {
+ // 退回期间可能已重新录入,恢复待审核前重新取得日期占用。
+ \App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
+ $entry = CourierStockQuoteEntry::findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'));
+ app(CourierStockQuoteEntryService::class)->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $change->effective_date->toDateString(), $change->end_date?->toDateString(), $entry->id);
+ }
$from = $change->status;
$retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date);
$change->update([
diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php
index f6cdafc9..68c1e6cd 100644
--- a/app/Support/CourierStockQuoteEntryService.php
+++ b/app/Support/CourierStockQuoteEntryService.php
@@ -3,6 +3,7 @@
namespace App\Support;
use App\Models\Business\CourierOnboarding;
+use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierStockQuoteEntry;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\UploadedFile;
@@ -73,8 +74,9 @@ final class CourierStockQuoteEntryService
public function existingIdentities(?int $exceptId = null): array
{
- return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
- ->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])->filter(fn ($entry) => !$entry->change?->version?->isVoided())
+ return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id,status', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
+ ->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])
+ ->filter(fn ($entry) => $entry->change?->status !== CourierQuoteChange::STATUS_RETURNED && !$entry->change?->version?->isVoided())
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
}
diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php
index 741569fe..15178853 100644
--- a/tests/Feature/Business/CourierStockQuoteBatchTest.php
+++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php
@@ -83,6 +83,38 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->assertSame('2026-10-01', $entries[1]->change->effective_date->toDateString());
}
+ public function test_returned_open_ended_quote_does_not_block_four_new_batch_periods(): void
+ {
+ $oldBatch = $this->preview(['EXP-001']);
+ $this->postJson(route('business.courier-stock-quotes.batch-save', $oldBatch), $this->selection(['EXP-001']))->assertOk();
+ $old = CourierStockQuoteEntry::firstOrFail();
+ $files = $old->files;
+ $changes = app(CourierQuoteChangeService::class);
+ $changes->submit($old->change, (int) $this->user->id);
+ $changes->returnForChanges($old->change->fresh(), '退回后按实际区间重新录入', (int) $this->user->id);
+
+ $batch = $this->preview(['EXP-001']);
+ $service = app(CourierStockQuoteBatchService::class);
+ $rows = $service->rows($service->load($batch, (int) $this->user->id), app(\App\Support\CourierStockQuoteEntryService::class)->courierOptions());
+ $this->assertFalse($rows[0]['duplicate']);
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertDontSeeText('已录入,默认跳过');
+ $selection = $this->selection(['EXP-001']);
+ $selection['submit_for_review'] = $selection['confirmed'] = true;
+ $selection['entries'][0]['periods'] = [
+ ['effective_date' => '2026-10-01', 'end_date' => '2026-10-04'],
+ ['effective_date' => '2026-10-11', 'end_date' => '2026-10-14'],
+ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
+ ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
+ ];
+ $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection)->assertOk();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 5);
+ $this->assertSame(4, CourierQuoteChange::where('status', CourierQuoteChange::STATUS_PENDING_REVIEW)->count());
+ $this->assertSame(CourierQuoteChange::STATUS_RETURNED, $old->change->fresh()->status);
+ $this->assertSame($files, $old->fresh()->files);
+ Storage::disk('uploads')->assertExists($files[0]['path']);
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ }
+
public function test_bulk_save_reuses_rule_groups_overrides_dates_and_preserves_separate_prices(): void
{
$batch = $this->preview(['EXP-001', 'EXP-002', '说明']);
diff --git a/tests/Feature/Business/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php
index 80fff17d..bc5e383f 100644
--- a/tests/Feature/Business/CourierStockQuoteEntryTest.php
+++ b/tests/Feature/Business/CourierStockQuoteEntryTest.php
@@ -185,6 +185,45 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
->assertSessionHasErrors('courier_expcode');
}
+ public function test_returned_quote_releases_period_and_replacement_preserves_review_overlap_protection(): void
+ {
+ $old = $this->createEntry();
+ $files = $old->files;
+ $changes = app(CourierQuoteChangeService::class);
+ $changes->submit($old->change, (int) $this->user->id);
+ $reviewer = $this->reviewer();
+ $changes->returnForChanges($old->change->fresh(), '原报价执行日期需重新录入', (int) $reviewer->id);
+
+ $replacement = $this->createEntry();
+ $this->assertNotSame($old->id, $replacement->id);
+ $this->assertSame(CourierQuoteChange::STATUS_RETURNED, $old->change->fresh()->status);
+ $this->assertSame($files, $old->fresh()->files);
+ Storage::disk('uploads')->assertExists($files[0]['path']);
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.submit', $old->change))
+ ->assertSessionHasErrors('courier_name');
+ $this->assertSame(CourierQuoteChange::STATUS_RETURNED, $old->change->fresh()->status);
+
+ $changes->submit($replacement->change, (int) $this->user->id);
+ $this->post(route('business.courier-stock-quotes.store'), $this->payload())->assertSessionHasErrors('courier_name');
+ $changes->approve($replacement->change->fresh(), (int) $reviewer->id);
+ $this->post(route('business.courier-stock-quotes.store'), $this->payload())->assertSessionHasErrors('courier_name');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ $this->assertDatabaseCount('business_courier_quote_versions', 1);
+ }
+
+ public function test_returned_quote_can_be_resubmitted_when_its_period_is_still_available(): void
+ {
+ $entry = $this->createEntry();
+ $changes = app(CourierQuoteChangeService::class);
+ $changes->submit($entry->change, (int) $this->user->id);
+ $changes->returnForChanges($entry->change->fresh(), '重新核对后提交', (int) $this->reviewer()->id);
+ $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change->fresh()))
+ ->assertSessionHasNoErrors()->assertRedirect();
+ $this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $entry->change->fresh()->status);
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 1);
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ }
+
public function test_multi_sheet_upload_requires_exact_sheet_and_invalid_workbooks_leave_no_record(): void
{
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true)])
--
2.47.2
From 9d2f9a52dde96030889d3ae0c972f1ee51a04821 Mon Sep 17 00:00:00 2001
From: limeng <1131177064@qq.com>
Date: Sat, 24 Oct 2026 17:32:13 +0800
Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=87=E6=BB=A4?=
=?UTF-8?q?=E9=80=80=E5=9B=9E=E6=8A=A5=E4=BB=B7=E5=90=8E=E9=87=8D=E5=8F=A0?=
=?UTF-8?q?=E6=8F=90=E7=A4=BA=E5=88=97=E8=A1=A8=E7=9A=84=E6=A0=BC=E5=BC=8F?=
=?UTF-8?q?=20(#505)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/Support/CourierStockQuoteEntryService.php | 2 +-
tests/Feature/Business/CourierStockQuoteEntryTest.php | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php
index 68c1e6cd..1eaa3509 100644
--- a/app/Support/CourierStockQuoteEntryService.php
+++ b/app/Support/CourierStockQuoteEntryService.php
@@ -78,7 +78,7 @@ final class CourierStockQuoteEntryService
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])
->filter(fn ($entry) => $entry->change?->status !== CourierQuoteChange::STATUS_RETURNED && !$entry->change?->version?->isVoided())
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
- 'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
+ 'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->values()->all();
}
public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool
diff --git a/tests/Feature/Business/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php
index bc5e383f..4f5f50a0 100644
--- a/tests/Feature/Business/CourierStockQuoteEntryTest.php
+++ b/tests/Feature/Business/CourierStockQuoteEntryTest.php
@@ -196,6 +196,9 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
$replacement = $this->createEntry();
$this->assertNotSame($old->id, $replacement->id);
+ $identities = app(\App\Support\CourierStockQuoteEntryService::class)->existingIdentities();
+ $this->assertSame([0], array_keys($identities));
+ $this->assertStringStartsWith('[', json_encode($identities, JSON_THROW_ON_ERROR));
$this->assertSame(CourierQuoteChange::STATUS_RETURNED, $old->change->fresh()->status);
$this->assertSame($files, $old->fresh()->files);
Storage::disk('uploads')->assertExists($files[0]['path']);
--
2.47.2
|