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 +
多段日期分别建报价,共用同一原件与价格;空档不执行本报价。最多 20 段。
+{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}已提取的原件图片随各家报价保留,可在“查看报价”中查看或下载;原件可后续补齐;存量报价审核后按执行期间生效。
| 原工作表 | 确认的快递名称 | 查看 | |
|---|---|---|---|
| {{ $created['sheet_name'] }} | {{ $created['courier_name'] }} | 查看报价 |
| 原工作表 | 确认的快递名称 | 执行区间 | 查看 |
|---|---|---|---|
| {{ $created['sheet_name'] }} | {{ $created['courier_name'] }} | {{ $created['effective_date'] ?? '见报价详情' }} 至 {{ $created['end_date'] ?? '长期' }} | 查看报价 |
输入两字以上可搜索近期发货名称;找不到也可手填,不要求先建快递资料。请使用发货数据中的完整名称,不要只填品牌;说明页、重复报价可取消勾选。各表图片分别留存,存档不代表已核对一致。
+同一份报价有多段执行日期时,点击“添加执行区间”逐段填写,无需拆表或重复上传。请核对原件日期,不会自动识别图片中的日期。
@include('business.pages.courier-payables.partials.stock-quote-courier-names')同一家快递、同一类型只录入一次;后续调整请从对应报价发起变价。
+同一家快递、同一类型可录入多个不重叠的执行区间;后续调整请从对应报价发起变价。
@endif @@ -77,8 +77,14 @@请按原报价填写真实执行日期,历史报价不以录入日期代替。
留空表示长期执行;同一快递、同类报价的执行期间不得重叠,截止日期当天仍适用。
本次将报价接入版本管理;运费自动核算仍以已实现且经核验的计算规则为准。