diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index a2c58aca..20eb5922 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -38,6 +38,7 @@ class CourierStockQuoteEntries extends Controller 'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'], 'quote_type' => ['required', 'string', 'max:64'], 'effective_date' => ['nullable', 'date_format:Y-m-d'], + 'end_date' => ['nullable', 'date_format:Y-m-d'], 'billing_rules' => ['nullable', 'string', 'max:4000'], ]); $batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id()); @@ -77,6 +78,7 @@ class CourierStockQuoteEntries extends Controller $data = Validator::make($selection, [ 'quote_type' => ['required', 'string', 'max:64'], 'effective_date' => ['nullable', 'date_format:Y-m-d'], + 'end_date' => ['nullable', 'date_format:Y-m-d'], 'summary' => ['required', 'string', 'min:2', 'max:2000'], 'rule_groups' => ['required', 'array', 'min:1', 'max:10'], 'rule_groups.*' => ['array'], @@ -89,6 +91,7 @@ class CourierStockQuoteEntries extends Controller 'entries.*.courier_name' => ['nullable', 'string', 'max:255'], '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'], 'submit_for_review' => ['required', 'boolean'], 'confirmed' => ['accepted_if:submit_for_review,true'], ])->validate(); @@ -178,6 +181,7 @@ class CourierStockQuoteEntries extends Controller { return [ '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'], 'summary' => ['required', 'string', 'min:2', 'max:2000'], ]; @@ -198,4 +202,13 @@ class CourierStockQuoteEntries extends Controller { abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403); } + + public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse + { + abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403); + abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404); + $data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]); + $this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id()); + return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。'); + } } diff --git a/app/Models/Business/CourierQuoteChangeEvent.php b/app/Models/Business/CourierQuoteChangeEvent.php index 44a39e60..5c1692cc 100644 --- a/app/Models/Business/CourierQuoteChangeEvent.php +++ b/app/Models/Business/CourierQuoteChangeEvent.php @@ -34,6 +34,7 @@ class CourierQuoteChangeEvent extends Model 'returned' => '退回修改', 'approved' => '审核通过', 'voided' => '版本作废', + 'period_end_updated' => '设置执行截止日期', default => $this->event_type, }; } diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php index 05bd7336..764c3131 100644 --- a/app/Models/Business/CourierQuoteVersion.php +++ b/app/Models/Business/CourierQuoteVersion.php @@ -31,7 +31,7 @@ class CourierQuoteVersion extends Model /** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */ public function isTemporary(): bool { - return $this->end_date !== null; + return $this->end_date !== null && !$this->change?->isStockQuote(); } /** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */ @@ -52,7 +52,7 @@ class CourierQuoteVersion extends Model } return $this->end_date !== null - ? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)' + ? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . ($this->isTemporary() ? '(临时)' : '') : $this->effective_date->toDateString() . ' 起'; } diff --git a/app/Models/Business/CourierStockQuoteEntry.php b/app/Models/Business/CourierStockQuoteEntry.php index 8ff9e563..ea44dfee 100644 --- a/app/Models/Business/CourierStockQuoteEntry.php +++ b/app/Models/Business/CourierStockQuoteEntry.php @@ -11,10 +11,10 @@ class CourierStockQuoteEntry extends Model protected $fillable = [ 'company_id', 'courier_expcode', 'courier_name', 'courier_name_key', 'quote_type', 'source_change_id', - 'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by', + 'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by', 'effective_date', 'end_date', ]; - protected $casts = ['company_id' => 'integer', 'files' => 'array']; + protected $casts = ['company_id' => 'integer', 'files' => 'array', 'effective_date' => 'date', 'end_date' => 'date']; public function change(): BelongsTo { diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index 7f6d64e1..6aacf26b 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -35,6 +35,7 @@ final class CourierQuoteChangeService 'change_type' => $data['change_type'], 'status' => CourierQuoteChange::STATUS_DRAFT, 'effective_date' => $data['effective_date'], + 'end_date' => ($data['end_date'] ?? null) ?: null, 'is_retrospective' => $retrospective, 'recalculation_status' => $retrospective ? CourierQuoteChange::RECALCULATION_PENDING @@ -66,6 +67,7 @@ final class CourierQuoteChangeService $change->update([ 'change_type' => $data['change_type'], 'effective_date' => $data['effective_date'], + 'end_date' => $change->isStockQuote() ? (($data['end_date'] ?? null) ?: null) : $change->end_date, 'is_retrospective' => $retrospective, 'recalculation_status' => $retrospective ? CourierQuoteChange::RECALCULATION_PENDING @@ -139,6 +141,11 @@ final class CourierQuoteChangeService $source = $this->findSourceQuote($change->source_quote_id); return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange { + if ($change->isStockQuote()) { + \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); + } $hasHistoricalFiles = $change->isStockQuote() && collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files)->contains('kind', 'signed'); if ($change->isStockQuote() && $historicalDocumentsConfirmed && ! $hasHistoricalFiles) { diff --git a/app/Support/CourierStockQuoteBatchService.php b/app/Support/CourierStockQuoteBatchService.php index b26d467f..d5343f36 100644 --- a/app/Support/CourierStockQuoteBatchService.php +++ b/app/Support/CourierStockQuoteBatchService.php @@ -77,7 +77,7 @@ final class CourierStockQuoteBatchService ], true))); $courier = count($matches) === 1 ? $matches[0] : ['code' => '', 'name' => trim($sheet['sheet_name'])]; $code = $courier['code'] ?? ''; - $duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing); + $duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing, ($manifest['defaults']['effective_date'] ?? null) ?: null, ($manifest['defaults']['end_date'] ?? null) ?: null); return [...$sheet, 'courier_expcode' => $code, 'courier_name' => $courier['name'], 'duplicate' => $duplicate, 'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称对应多条资料,请确认发货名称;按名称保存' : '可手填名称,请与发货数据一致'))), @@ -110,10 +110,12 @@ final class CourierStockQuoteBatchService $rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? ''; $metadata = [ 'quote_type' => trim($data['quote_type']), '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); @@ -125,9 +127,9 @@ final class CourierStockQuoteBatchService } } if ($courier !== null) { - $reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing) ? '此快递的同类报价已录入,请取消选择;后续调价使用变价。' : null; - $reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities) ? '与本批其他工作表填写了同一家快递,请检查名称或取消选择。' : null; - $identities[] = [...$courier, 'quote_type' => $metadata['quote_type']]; + $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; diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php index 3c7f6e9e..39a32113 100644 --- a/app/Support/CourierStockQuoteEntryService.php +++ b/app/Support/CourierStockQuoteEntryService.php @@ -73,17 +73,20 @@ final class CourierStockQuoteEntryService public function existingIdentities(?int $exceptId = null): array { - return CourierStockQuoteEntry::withTrashed()->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId)) - ->get(['courier_expcode', 'courier_name', 'quote_type']) - ->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type])->all(); + 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()) + ->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(); } - public function isDuplicate(array $courier, string $quoteType, array $existing): bool + public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool { foreach ($existing as $entry) { if ($entry['quote_type'] !== $quoteType) { continue; } + if ($start !== null && isset($entry['effective_date']) + && ($start > ($entry['end_date'] ?? '9999-12-31') || ($end ?? '9999-12-31') < $entry['effective_date'])) continue; if (filled($courier['code']) && filled($entry['code'])) { if ($courier['code'] === $entry['code']) { return true; @@ -96,6 +99,18 @@ final class CourierStockQuoteEntryService return false; } + public function assertPeriod(array $courier, string $quoteType, string $start, ?string $end, ?int $exceptId = null): void + { + if ($end !== null && $end < $start) throw ValidationException::withMessages(['end_date' => '截止日期不能早于开始日期。']); + if ($this->isDuplicate($courier, $quoteType, $this->existingIdentities($exceptId), $start, $end)) $this->duplicateError(); + } + + private function lockCompany(): void + { + // 同公司导入/改期串行,覆盖空集合及无编号快递,防止并发导入重叠时期。 + \App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail(); + } + /** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */ public function readWorkbook(UploadedFile $file, ?string $sheetName): array { @@ -203,9 +218,7 @@ final class CourierStockQuoteEntryService { $data['quote_type'] = trim($data['quote_type']); $courier = $this->resolveCourier($data); - if ($this->isDuplicate($courier, $data['quote_type'], $this->existingIdentities())) { - $this->duplicateError(); - } + $this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null); $matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null); return $this->createPrepared($data, $userId, $courier, $matrix); @@ -217,19 +230,21 @@ final class CourierStockQuoteEntryService $savedPaths = []; try { return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry { + $this->lockCompany(); + $this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null); $entry = CourierStockQuoteEntry::create([ 'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'], 'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null, 'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'], 'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), - 'files' => [], 'created_by' => $userId, + 'files' => [], 'created_by' => $userId, 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, ]); $files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)]; $entry->update(['files' => $files]); $change = $this->changes->create([ 'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价', - 'effective_date' => $data['effective_date'], 'summary' => $data['summary'], 'items' => [], + 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, 'summary' => $data['summary'], 'items' => [], 'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']], 'proposed_values_json' => $entry->values_json, ], $userId); @@ -253,14 +268,13 @@ final class CourierStockQuoteEntryService $courier = ['code' => $entry->courier_expcode, 'name' => $entry->courier_name]; if (isset($data['courier_name']) && trim($data['courier_name']) !== $entry->courier_name) { $courier = $this->resolveCourier(['courier_name' => $data['courier_name']]); - if ($this->isDuplicate($courier, $entry->quote_type, $this->existingIdentities($entry->id))) { - $this->duplicateError(); - } } $matrix = isset($data['source_quote']) ? $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null) : null; $savedPaths = []; try { DB::transaction(function () use ($entry, $data, $userId, $matrix, $courier, &$savedPaths): void { + $this->lockCompany(); + $this->assertPeriod($courier, $entry->quote_type, $data['effective_date'], ($data['end_date'] ?? null) ?: null, $entry->id); $files = $entry->files; if ($matrix !== null) { foreach ($files as &$file) { @@ -279,6 +293,7 @@ final class CourierStockQuoteEntryService 'courier_name' => $courier['name'], 'courier_expcode' => $courier['code'], 'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null, 'files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)], + 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, ])->save(); $entry->change->update(['carrier_name' => $courier['name'], 'source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]); $this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId); @@ -311,6 +326,28 @@ final class CourierStockQuoteEntryService private function duplicateError(): never { - throw ValidationException::withMessages(['courier_name' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']); + throw ValidationException::withMessages(['courier_name' => '该快递的同类报价执行期间重叠,请核对起止日期;已有长期报价请先设置截止日期,不覆盖原报价。']); + } + + public function setPeriodEnd(CourierStockQuoteEntry $entry, string $end, string $reason, int $userId): void + { + abort_unless($entry->company_id === (int) company_id(), 404); + DB::transaction(function () use ($entry, $end, $reason, $userId): void { + $this->lockCompany(); + $entry->refresh()->load('change.version'); + $version = $entry->change?->version; + if (!$version?->isApprovedStockQuote()) throw ValidationException::withMessages(['end_date' => '仅已审核且未作废的存量报价可设置截止日期;草稿请从报价历史编辑。']); + $this->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $entry->effective_date->toDateString(), $end, $entry->id); + $old = $entry->end_date?->toDateString(); + $entry->update(['end_date' => $end]); + $entry->change->update(['end_date' => $end]); + $version->update(['end_date' => $end]); + \App\Models\Business\CourierQuoteChangeEvent::create([ + 'company_id' => company_id(), 'quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated', + 'from_status' => $entry->change->status, 'to_status' => $entry->change->status, 'created_by' => $userId, + 'note' => '设置执行截止日期:' . $reason, + 'details_json' => json_encode(['previous_end_date' => $old, 'end_date' => $end], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]); + }); } } diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index cbd3353e..526b7409 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -159,7 +159,13 @@ final class QuoteManagementSnapshot 'quote_type' => $version->quote_type, 'effective_date' => $version->effective_date?->toDateString(), 'end_date' => $version->end_date?->toDateString(), - 'period_label' => $version->isTemporary() ? $version->periodLabel() : null, + 'period_label' => $version->end_date !== null ? $version->periodLabel() : null, + 'group_key' => (filled($version->courier_expcode) ? 'code:' . $version->courier_expcode : 'name:' . $version->company_name) . ':' . $version->quote_type, + 'stock_entry_id' => $version->change?->isStockQuote() ? data_get($version->change->quote_payload, 'stock_quote_entry_id') : null, + 'eligible' => !$version->isVoided() && $this->isExecutable($version), + 'is_stock' => (bool) $version->change?->isStockQuote(), + 'period_end_url' => $version->isApprovedStockQuote() && in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::SUPER_ADMIN], true) + ? route('business.courier-stock-quotes.period-end', data_get($version->change->quote_payload, 'stock_quote_entry_id')) : null, 'source_range' => $version->source_range ?? '', 'row_count' => count($values), 'column_count' => $columnCount, @@ -242,6 +248,7 @@ final class QuoteManagementSnapshot if ($version->isVoided()) { return '已作废'; } + if ($version->end_date?->lt(today()) && $this->isExecutable($version)) return '已到期'; if (! $this->isExecutable($version)) { return '待生效(待签章)'; @@ -289,8 +296,8 @@ final class QuoteManagementSnapshot } continue; } - if ($version->isTemporary() && $version->end_date->lt($today)) { - $result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $version->periodLabel()]; + if ($version->end_date?->lt($today)) { + $result[$version->id] = ['status' => '已到期', 'status_note' => ($version->isTemporary() ? '临时价 ' : '执行期间 ') . $version->periodLabel()]; } elseif ($base !== null && $version->id === $base->id && $current?->isTemporary()) { $result[$version->id] = ['status' => '暂停中', 'status_note' => $current->version_code . ' 临时价执行中,' . $current->end_date->copy()->addDay()->toDateString() . ' 起恢复']; } elseif ($current !== null) { diff --git a/database/migrations/2026_10_17_150000_add_stock_quote_periods.php b/database/migrations/2026_10_17_150000_add_stock_quote_periods.php new file mode 100644 index 00000000..207189f1 --- /dev/null +++ b/database/migrations/2026_10_17_150000_add_stock_quote_periods.php @@ -0,0 +1,36 @@ +date('effective_date')->nullable(); + $table->date('end_date')->nullable(); + }); + DB::table('business_courier_stock_quote_entries')->orderBy('id')->chunkById(200, function ($entries): void { + foreach ($entries as $entry) { + $change = DB::table('business_courier_quote_changes')->where('id', $entry->source_change_id)->first(); + if ($change) DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update([ + 'effective_date' => $change->effective_date, 'end_date' => $change->end_date, + ]); + } + }); + Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void { + $table->dropUnique('courier_stock_quote_identity_unique'); + $table->dropUnique('courier_stock_quote_name_unique'); + $table->index(['company_id', 'courier_expcode', 'quote_type', 'effective_date'], 'stock_quote_code_period'); + $table->index(['company_id', 'courier_name_key', 'quote_type', 'effective_date'], 'stock_quote_name_period'); + }); + } + + public function down(): void + { + // 多时期记录不能自动缩回单期唯一约束,避免回滚时丢失历史报价。 + throw new RuntimeException('多时期报价迁移需核对已录入版本后手动回退;应用可直接回滚镜像。'); + } +}; diff --git a/docs/存量报价多时期.md b/docs/存量报价多时期.md new file mode 100644 index 00000000..b6a75561 --- /dev/null +++ b/docs/存量报价多时期.md @@ -0,0 +1,13 @@ +# 存量报价多时期 + +报价管理按快递编号(无编号时按名称)和报价类型分组,同组保留多个执行时期。列表可展开/收起历史,按查看日期判断适用版本;分页以组计数,详情可切换同组版本,各期原件独立保留。按日期查看不写入或重算历史运费。 + +单份及批量录入均支持开始和截止日期。起止日期均包含当天,截止日期留空表示长期。同组时期不得重叠,包括未审核草稿;批量输入也按相同规则校验,整批同进同退。已作废版本不再占用时期。按编号隔离同名不同网点,跨公司隔离。 + +存量报价审核后按执行期间生效,有限期间不是临时变价,不会在到期后自动恢复其他存量报价。真正的临时变价沿用原有恢复规则。未来时期显示待生效,结束时期显示已到期。 + +已有长期存量报价需要衔接下一期时,管理员可先点“设置截止日期”,填写截止日期和依据。该操作同步条目、审核单及版本的期间并记录前后日期和操作者,不修改原文、金额和原件;不得把截止日期延长到其他时期内。草稿仍从报价历史编辑。 + +迁移 `2026_10_17_150000_add_stock_quote_periods` 从原审核单回填条目日期,将单期唯一约束改为时期查询索引。公司行事务锁串行导入和改期,保存及审核均校验重叠。执行迁移前备份存量条目及关联审核单、版本和事件;应用回滚使用上一版镜像,数据库不自动恢复单期唯一约束,以免丢失新增时期。 + +验证覆盖单份/批量多时期、同日边界、长期重叠、倒序补录、历史日期选版、权限隔离、调整留痕及原件不变,并回归原有报价审核与临时变价流程。 diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 1f4bd7d2..29d3792b 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -391,7 +391,7 @@ } .quote-modal__panel > .quote-detail-table-wrap { - grid-row: 4; + grid-row: 5; } .quote-signed[hidden], @@ -490,7 +490,7 @@ .quote-modal__panel { display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); + grid-template-rows: auto auto auto auto minmax(0, 1fr); width: min(78rem, calc(100vw - 3rem)); height: 100%; background: #FFFFFF; @@ -854,6 +854,7 @@ +
版本号
快递公司
@@ -875,6 +876,16 @@ + +
@csrf +

设置存量报价截止日期

+

+ + +

仅更新执行期间并保留操作记录,不修改报价原文或签署原件。期间不得与其他已录入报价重叠。

+
+
+