From 52c02127191830cf71de970f7a16d69a8b25ec9b Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sat, 24 Oct 2026 16:27:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=94=AF=E6=8C=81=E5=A4=9A=E4=B8=AA=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=8C=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierStockQuoteEntries.php | 23 +++- app/Support/CourierStockQuoteBatchService.php | 63 +++++----- app/Support/CourierStockQuoteEntryService.php | 31 +++++ .../partials/stock-quote-periods.blade.php | 53 +++++++++ .../stock-quote-batch.blade.php | 62 ++++++---- .../stock-quote-entry.blade.php | 8 +- .../Business/CourierStockQuoteBatchTest.php | 109 ++++++++++++++++-- .../Business/CourierStockQuoteEntryTest.php | 36 +++++- 8 files changed, 323 insertions(+), 62 deletions(-) create mode 100644 resources/views/business/pages/courier-payables/partials/stock-quote-periods.blade.php diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index 20eb5922..351f8d5d 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -92,6 +92,10 @@ class CourierStockQuoteEntries extends Controller 'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'], 'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'], 'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'], + 'entries.*.periods' => ['sometimes', 'array', 'min:1', 'max:20'], + 'entries.*.periods.*' => ['array'], + 'entries.*.periods.*.effective_date' => ['required', 'date_format:Y-m-d'], + 'entries.*.periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:entries.*.periods.*.effective_date'], 'submit_for_review' => ['required', 'boolean'], 'confirmed' => ['accepted_if:submit_for_review,true'], ])->validate(); @@ -107,14 +111,19 @@ class CourierStockQuoteEntries extends Controller public function store(Request $request): RedirectResponse { $data = $request->validate([ - ...$this->metadataRules(), + ...$this->metadataRules(true), 'courier_expcode' => ['nullable', 'string', 'max:64'], 'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'], 'quote_type' => ['required', 'string', 'max:64'], 'sheet_name' => ['nullable', 'string', 'max:255'], ...$this->fileRules(true), ]); - $entry = $this->service->create($data, (int) user_id()); + $entries = $this->service->createPeriods($data, (int) user_id()); + if (count($entries) > 1) { + return redirect()->route('business.courier-quote-changes.index') + ->with('success', '已按 ' . count($entries) . ' 个执行区间保存报价草稿,共用原报价与附件;请分别核对并提交审核。'); + } + $entry = $entries[0]; return redirect()->route('business.courier-quote-changes.show', $entry->change) ->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。'); @@ -177,10 +186,16 @@ class CourierStockQuoteEntries extends Controller return $disk->download($attachment['path'], $attachment['name'], $headers); } - private function metadataRules(): array + private function metadataRules(bool $multiplePeriods = false): array { return [ - 'effective_date' => ['required', 'date_format:Y-m-d'], + ...($multiplePeriods ? [ + 'periods' => ['sometimes', 'array', 'min:1', 'max:20'], + 'periods.*' => ['array'], + 'periods.*.effective_date' => ['required', 'date_format:Y-m-d'], + 'periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:periods.*.effective_date'], + ] : []), + 'effective_date' => [$multiplePeriods ? 'required_without:periods' : 'required', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'], 'billing_rules' => ['required', 'string', 'min:2', 'max:4000'], 'summary' => ['required', 'string', 'min:2', 'max:2000'], diff --git a/app/Support/CourierStockQuoteBatchService.php b/app/Support/CourierStockQuoteBatchService.php index d5343f36..d2569bf7 100644 --- a/app/Support/CourierStockQuoteBatchService.php +++ b/app/Support/CourierStockQuoteBatchService.php @@ -108,33 +108,39 @@ final class CourierStockQuoteBatchService $courierError = $exception->validator->errors()->first(); } $rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? ''; - $metadata = [ - 'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''), + $periods = $row['periods'] ?? [[ + 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''), 'end_date' => ($row['end_date'] ?? null) ?: (($data['end_date'] ?? null) ?: null), - 'billing_rules' => trim($rules), 'summary' => $data['summary'], - ]; - $validation = Validator::make($metadata, [ - 'effective_date' => ['required', 'date_format:Y-m-d'], - 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'], - 'billing_rules' => ['required', 'string', 'min:2', 'max:4000'], - ], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']); - $reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null); - $reason ??= $courierError; - $reason ??= $validation->fails() ? $validation->errors()->first() : null; - foreach ($sheet['signed_images'] ?? [] as $image) { - if (! Storage::disk('uploads')->exists($image['path'])) { - $reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。'; + ]]; + foreach ($periods as $periodIndex => $period) { + $metadata = [ + 'quote_type' => trim($data['quote_type']), 'effective_date' => $period['effective_date'], + 'end_date' => ($period['end_date'] ?? null) ?: null, + 'billing_rules' => trim($rules), 'summary' => $data['summary'], + ]; + $validation = Validator::make($metadata, [ + 'effective_date' => ['required', 'date_format:Y-m-d'], + 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'], + 'billing_rules' => ['required', 'string', 'min:2', 'max:4000'], + ], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']); + $reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null); + $reason ??= $courierError; + $reason ??= $validation->fails() ? $validation->errors()->first() : null; + foreach ($sheet['signed_images'] ?? [] as $image) { + if (! Storage::disk('uploads')->exists($image['path'])) { + $reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。'; + } + } + if ($courier !== null) { + $reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null; + $reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null; + $identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']]; + } + if ($reason !== null) { + $errors['entries.' . $index . (isset($row['periods']) ? '.periods.' . $periodIndex : '')] = '「' . ($sheet['sheet_name'] ?? $index) . '」第 ' . (array_search($periodIndex, array_keys($periods), true) + 1) . ' 段:' . $reason; + } else { + $prepared[] = compact('metadata', 'courier', 'sheet'); } - } - if ($courier !== null) { - $reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null; - $reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null; - $identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']]; - } - if ($reason !== null) { - $errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason; - } else { - $prepared[] = compact('metadata', 'courier', 'sheet'); } } if ($errors !== []) { @@ -142,17 +148,18 @@ final class CourierStockQuoteBatchService } abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。'); // 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。 - return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array { + return DB::transaction(function () use ($prepared, $selected, $manifest, $userId, $data, $batch): array { $created = []; foreach ($prepared as $item) { $entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file'], ...($item['sheet']['signed_images'] ?? [])]); if ($data['submit_for_review'] ?? false) { $this->changes->submit($entry->change, $userId); } - $created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode]; + $created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode, + 'effective_date' => $entry->effective_date->toDateString(), 'end_date' => $entry->end_date?->toDateString()]; } - $result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)]; + $result = ['created' => $created, 'selected_sheets' => count($selected), 'skipped' => count($manifest['sheets']) - count($selected), 'submitted' => (bool) ($data['submit_for_review'] ?? false)]; $manifest['result'] = $result; $this->write($batch, $userId, $manifest); diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php index 39a32113..f6cdafc9 100644 --- a/app/Support/CourierStockQuoteEntryService.php +++ b/app/Support/CourierStockQuoteEntryService.php @@ -224,6 +224,37 @@ final class CourierStockQuoteEntryService return $this->createPrepared($data, $userId, $courier, $matrix); } + /** 同一份原报价按不连续期间分别建草稿,复用原件;任一区间失败整组回滚。 */ + public function createPeriods(array $data, int $userId): array + { + if (! isset($data['periods'])) { + return [$this->create($data, $userId)]; + } + $courier = $this->resolveCourier($data); + $matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null); + $savedFiles = []; + try { + return DB::transaction(function () use ($data, $userId, $courier, $matrix, &$savedFiles): array { + $created = []; + foreach ($data['periods'] as $period) { + $metadata = [...$data, 'effective_date' => $period['effective_date'], + 'end_date' => ($period['end_date'] ?? null) ?: null, 'quote_type' => trim($data['quote_type'])]; + if ($created !== []) { + unset($metadata['source_quote'], $metadata['confirmation_files'], $metadata['signed_files']); + } + $entry = $this->createPrepared($metadata, $userId, $courier, $matrix, $savedFiles); + $savedFiles = $entry->files; + $created[] = $entry; + } + + return $created; + }); + } catch (\Throwable $exception) { + Storage::disk('uploads')->delete(array_column($savedFiles, 'path')); + throw $exception; + } + } + /** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */ public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry { diff --git a/resources/views/business/pages/courier-payables/partials/stock-quote-periods.blade.php b/resources/views/business/pages/courier-payables/partials/stock-quote-periods.blade.php new file mode 100644 index 00000000..dad67298 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/stock-quote-periods.blade.php @@ -0,0 +1,53 @@ +@once + + +@endonce +
+
+ @foreach($periods as $periodIndex => $period) +
+ + + +
+ @endforeach +
+
+

多段日期分别建报价,共用同一原件与价格;空档不执行本报价。最多 20 段。

+
diff --git a/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php index 770ddb28..e6b4df5c 100644 --- a/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php +++ b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php @@ -104,12 +104,12 @@
先预览匹配结果,不会直接生成正式报价。
@elseif($result) -
已录入 {{ count($result['created']) }} 家报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},跳过 {{ $result['skipped'] }} 张工作表。原 Excel 已留存,各家报价独立管理。
+
已录入 {{ count($result['created']) }} 个执行区间报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},来自 {{ $result['selected_sheets'] ?? count($result['created']) }} 张工作表,跳过 {{ $result['skipped'] }} 张工作表。原 Excel 已留存,各区间独立管理。

{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}已提取的原件图片随各家报价保留,可在“查看报价”中查看或下载;原件可后续补齐;存量报价审核后按执行期间生效。

本批录入结果

-
- @foreach($result['created'] as $created)@endforeach +
原工作表确认的快递名称查看
{{ $created['sheet_name'] }}{{ $created['courier_name'] }}查看报价
+ @foreach($result['created'] as $created)@endforeach
原工作表确认的快递名称执行区间查看
{{ $created['sheet_name'] }}{{ $created['courier_name'] }}{{ $created['effective_date'] ?? '见报价详情' }} 至 {{ $created['end_date'] ?? '长期' }}查看报价
打开报价历史继续导入下一批
@@ -144,6 +144,7 @@

输入两字以上可搜索近期发货名称;找不到也可手填,不要求先建快递资料。请使用发货数据中的完整名称,不要只填品牌;说明页、重复报价可取消勾选。各表图片分别留存,存档不代表已核对一致。

+

同一份报价有多段执行日期时,点击“添加执行区间”逐段填写,无需拆表或重复上传。请核对原件日期,不会自动识别图片中的日期。

@include('business.pages.courier-payables.partials.stock-quote-courier-names')
@@ -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')]]);