From c8d600bd4f883efc71c4ea3a831fdabba072fc5f Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 16 Oct 2026 16:49:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E5=8F=91=E8=B4=A7=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E6=89=8B=E5=A1=AB=E5=BD=95=E5=85=A5=E5=8F=8A=E6=97=A0=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E6=8A=A5=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierStockQuoteEntries.php | 17 +++- .../Business/CourierStockQuoteEntry.php | 2 +- app/Support/CourierQuoteChangeService.php | 1 + app/Support/CourierStockQuoteBatchService.php | 37 ++++---- app/Support/CourierStockQuoteEntryService.php | 93 ++++++++++++++++--- app/Support/QuoteManagementSnapshot.php | 7 +- ...tock_quote_courier_names_without_codes.php | 31 +++++++ .../Repositories/ShippingOrderRepository.php | 24 +++++ .../stock-quote-courier-names.blade.php | 30 ++++++ .../partials/stock-quote-review.blade.php | 2 +- .../stock-quote-batch.blade.php | 60 ++++++------ .../stock-quote-entry.blade.php | 25 +++-- routes/admin.php | 1 + .../Business/CourierStockQuoteBatchTest.php | 48 +++++++++- .../Business/CourierStockQuoteEntryTest.php | 62 ++++++++++++- .../StockQuoteShippingNamesTest.php | 41 ++++++++ 16 files changed, 400 insertions(+), 81 deletions(-) create mode 100644 database/migrations/2026_10_16_100000_allow_stock_quote_courier_names_without_codes.php create mode 100644 resources/views/business/pages/courier-payables/partials/stock-quote-courier-names.blade.php create mode 100644 tests/Unit/EcommerceFinance/StockQuoteShippingNamesTest.php diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index b1490dc3..beb94014 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Illuminate\View\View; +use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Symfony\Component\HttpFoundation\StreamedResponse; class CourierStockQuoteEntries extends Controller @@ -85,6 +86,7 @@ class CourierStockQuoteEntries extends Controller 'entries.*' => ['array'], 'entries.*.selected' => ['required', 'boolean'], 'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'], + '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'], 'submit_for_review' => ['required', 'boolean'], @@ -103,7 +105,8 @@ class CourierStockQuoteEntries extends Controller { $data = $request->validate([ ...$this->metadataRules(), - 'courier_expcode' => ['required', 'string', 'max:64'], + '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), @@ -124,7 +127,7 @@ class CourierStockQuoteEntries extends Controller public function update(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse { $this->ensureEditable($stockQuoteEntry); - $data = $request->validate([...$this->metadataRules(), ...$this->fileRules(false), 'sheet_name' => ['nullable', 'string', 'max:255']]); + $data = $request->validate([...$this->metadataRules(), ...$this->fileRules(false), 'sheet_name' => ['nullable', 'string', 'max:255'], 'courier_name' => ['sometimes', 'required', 'string', 'max:255']]); $this->service->update($stockQuoteEntry, $data, (int) user_id()); return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。'); @@ -138,6 +141,16 @@ class CourierStockQuoteEntries extends Controller return $this->attachmentResponse($attachment, $request->boolean('preview')); } + public function courierNames(Request $request, ShippingOrderRepository $shipping): JsonResponse + { + $data = $request->validate(['q' => ['required', 'string', 'min:2', 'max:64']]); + try { + return response()->json(['items' => $shipping->recentCourierNames(trim($data['q']), 20)]); + } catch (\Throwable $exception) { + return response()->json(['items' => [], 'message' => '发货名称暂不可用,可直接手填。'], 503); + } + } + public function download(Request $request, CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse { abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404); diff --git a/app/Models/Business/CourierStockQuoteEntry.php b/app/Models/Business/CourierStockQuoteEntry.php index 6f7ad7e9..8ff9e563 100644 --- a/app/Models/Business/CourierStockQuoteEntry.php +++ b/app/Models/Business/CourierStockQuoteEntry.php @@ -10,7 +10,7 @@ class CourierStockQuoteEntry extends Model protected $table = 'business_courier_stock_quote_entries'; protected $fillable = [ - 'company_id', 'courier_expcode', 'courier_name', 'quote_type', 'source_change_id', + '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', ]; diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index 9c4898fa..cd9f578b 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -322,6 +322,7 @@ final class CourierQuoteChangeService ->map(static fn (array $file): array => \Illuminate\Support\Arr::only($file, ['kind', 'name', 'sha256']))->all(), ] : []), 'change_type' => $change->change_type, + 'carrier_name' => $change->carrier_name, 'effective_date' => $change->effective_date->toDateString(), 'end_date' => $change->end_date?->toDateString(), 'is_retrospective' => $change->isRetrospective(), diff --git a/app/Support/CourierStockQuoteBatchService.php b/app/Support/CourierStockQuoteBatchService.php index be2160cf..b26d467f 100644 --- a/app/Support/CourierStockQuoteBatchService.php +++ b/app/Support/CourierStockQuoteBatchService.php @@ -3,7 +3,6 @@ namespace App\Support; use App\Models\Business\CourierQuoteVersion; -use App\Models\Business\CourierStockQuoteEntry; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -68,8 +67,7 @@ final class CourierStockQuoteBatchService public function rows(array $manifest, array $couriers): array { - $existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type']) - ->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]); + $existing = $this->entries->existingIdentities(); return array_map(function (array $sheet) use ($manifest, $couriers, $existing): array { $key = mb_strtolower(trim($sheet['sheet_name'])); @@ -77,11 +75,12 @@ final class CourierStockQuoteBatchService mb_strtolower($courier['code']), mb_strtolower(trim($courier['name'])), mb_strtolower($courier['name'] . ' · ' . $courier['code']), ], true))); - $code = count($matches) === 1 ? $matches[0]['code'] : ''; - $duplicate = $code !== '' && isset($existing[$code . '|' . $manifest['defaults']['quote_type']]); + $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); - return [...$sheet, 'courier_expcode' => $code, 'duplicate' => $duplicate, - 'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称重复,请选编号' : '待匹配,请选编号'))), + return [...$sheet, 'courier_expcode' => $code, 'courier_name' => $courier['name'], 'duplicate' => $duplicate, + 'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称对应多条资料,请确认发货名称;按名称保存' : '可手填名称,请与发货数据一致'))), ]; }, $manifest['sheets']); } @@ -92,17 +91,22 @@ final class CourierStockQuoteBatchService if ($manifest['result'] !== null) { return $manifest['result']; } - $couriers = collect($this->entries->courierOptions())->keyBy('code'); + $couriers = $this->entries->courierOptions(); $selected = array_filter($data['entries'], static fn ($row) => (bool) ($row['selected'] ?? false)); if ($selected === []) { throw ValidationException::withMessages(['entries' => '请至少选择一家需要录入的快递。']); } $errors = $prepared = $identities = []; - $existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type']) - ->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]); + $existing = $this->entries->existingIdentities(); foreach ($selected as $index => $row) { $sheet = $manifest['sheets'][$index] ?? null; - $courier = $couriers->get(trim($row['courier_expcode'] ?? '')); + $courier = null; + $courierError = null; + try { + $courier = $this->entries->resolveCourier($row, $couriers); + } catch (ValidationException $exception) { + $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'] ?? ''), @@ -113,22 +117,23 @@ final class CourierStockQuoteBatchService 'billing_rules' => ['required', 'string', 'min:2', 'max:4000'], ], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']); $reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null); - $reason ??= $courier === null ? '请选择已有快递资料中的唯一编号。' : 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,不能带缺失附件录入。'; } } - $identity = ($courier['code'] ?? '') . '|' . $metadata['quote_type']; - $reason ??= isset($existing[$identity]) ? '此快递编号的同类报价已录入,请取消选择;后续调价使用变价。' : null; - $reason ??= isset($identities[$identity]) ? '与本批其他工作表匹配了同一快递编号,请检查或取消选择。' : null; + 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']]; + } if ($reason !== null) { $errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason; } else { $prepared[] = compact('metadata', 'courier', 'sheet'); } - $identities[$identity] = true; } if ($errors !== []) { throw ValidationException::withMessages($errors); diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php index 9e593a01..3c7f6e9e 100644 --- a/app/Support/CourierStockQuoteEntryService.php +++ b/app/Support/CourierStockQuoteEntryService.php @@ -23,28 +23,79 @@ final class CourierStockQuoteEntryService ) { } - /** 编号是唯一身份;不按品牌或名称合并不同网点、结算主体。 */ + /** 资料仅提供候选,不要求先建资料或补齐工作平台编号。 */ public function courierOptions(): array { $options = []; foreach ($this->mirror->overviewSource()['express'] ?? [] as $express) { $code = trim((string) $express->expcode); - if ($code !== '' && filled($express->expname)) { - $options[$code] = ['code' => $code, 'name' => (string) $express->expname]; + $name = trim((string) ($express->expname ?? '')); + if ($name !== '') { + $options[$code !== '' ? 'code:' . $code : 'name:' . $name] = ['code' => $code, 'name' => $name]; } } foreach (CourierOnboarding::query()->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED]) - ->whereNotNull('workplatform_expcode')->get() as $courier) { + ->get() as $courier) { $code = trim((string) $courier->workplatform_expcode); - if ($code !== '' && filled($courier->courier_name)) { - $options[$code] ??= ['code' => $code, 'name' => $courier->courier_name]; + if (filled($courier->courier_name)) { + $options[$code !== '' ? 'code:' . $code : 'name:' . $courier->courier_name] ??= ['code' => $code, 'name' => $courier->courier_name]; } } + foreach (CourierStockQuoteEntry::query()->whereNull('courier_expcode')->pluck('courier_name') as $name) { + $options['name:' . $name] ??= ['code' => '', 'name' => $name]; + } uasort($options, static fn (array $a, array $b): int => [$a['name'], $a['code']] <=> [$b['name'], $b['code']]); return array_values($options); } + public function resolveCourier(array $data, ?array $options = null): array + { + $options ??= $this->courierOptions(); + $code = trim((string) ($data['courier_expcode'] ?? '')); + $name = trim((string) ($data['courier_name'] ?? '')); + if ($code !== '') { + $courier = collect($options)->firstWhere('code', $code); + if ($courier === null || ($name !== '' && $name !== $courier['name'])) { + throw ValidationException::withMessages(['courier_expcode' => '快递名称与已有编号不对应,请重新确认名称。']); + } + + return $courier; + } + if ($name === '' || mb_strlen($name) > 255) { + throw ValidationException::withMessages(['courier_name' => '请填写与发货数据一致的完整快递名称(最多255字)。']); + } + $matches = collect($options)->where('name', $name)->filter(fn ($courier) => $courier['code'] !== '')->values(); + + // 仅完全一致且唯一时附带真实编号;重名不猜,也不新增/改写快递资料。 + return ['name' => $name, 'code' => $matches->count() === 1 ? $matches[0]['code'] : null]; + } + + 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(); + } + + public function isDuplicate(array $courier, string $quoteType, array $existing): bool + { + foreach ($existing as $entry) { + if ($entry['quote_type'] !== $quoteType) { + continue; + } + if (filled($courier['code']) && filled($entry['code'])) { + if ($courier['code'] === $entry['code']) { + return true; + } + } elseif (mb_strtolower(trim($courier['name'])) === mb_strtolower(trim($entry['name']))) { + return true; + } + } + + return false; + } + /** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */ public function readWorkbook(UploadedFile $file, ?string $sheetName): array { @@ -151,11 +202,8 @@ final class CourierStockQuoteEntryService public function create(array $data, int $userId): CourierStockQuoteEntry { $data['quote_type'] = trim($data['quote_type']); - $courier = collect($this->courierOptions())->firstWhere('code', $data['courier_expcode']); - if ($courier === null) { - throw ValidationException::withMessages(['courier_expcode' => '请选择已有快递资料中的唯一编号;没有编号时请先完善快递资料。']); - } - if (CourierStockQuoteEntry::withTrashed()->where('courier_expcode', $courier['code'])->where('quote_type', $data['quote_type'])->exists()) { + $courier = $this->resolveCourier($data); + if ($this->isDuplicate($courier, $data['quote_type'], $this->existingIdentities())) { $this->duplicateError(); } $matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null); @@ -171,6 +219,7 @@ final class CourierStockQuoteEntryService return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry { $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), @@ -201,10 +250,17 @@ final class CourierStockQuoteEntryService public function update(CourierStockQuoteEntry $entry, array $data, int $userId): void { + $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, &$savedPaths): void { + DB::transaction(function () use ($entry, $data, $userId, $matrix, $courier, &$savedPaths): void { $files = $entry->files; if ($matrix !== null) { foreach ($files as &$file) { @@ -219,12 +275,19 @@ final class CourierStockQuoteEntryService 'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ]); } - $entry->fill(['files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)]])->save(); - $entry->change->update(['source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]); + $entry->fill([ + '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)], + ])->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); }); } catch (\Throwable $exception) { Storage::disk('uploads')->delete($savedPaths); + if ($exception instanceof UniqueConstraintViolationException) { + $this->duplicateError(); + } throw $exception; } } @@ -248,6 +311,6 @@ final class CourierStockQuoteEntryService private function duplicateError(): never { - throw ValidationException::withMessages(['courier_expcode' => '该快递编号的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']); + throw ValidationException::withMessages(['courier_name' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']); } } diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index bac4c112..9a1090cb 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -99,7 +99,8 @@ final class QuoteManagementSnapshot $quote['quote_type'] = CourierRequoteService::quoteTypeFor($changeType); } $onboardingId = $onboarded[$version->company_name] ?? null; - $quote['requote_url'] = $onboardingId && blank($version->courier_expcode) ? route('business.courier-onboardings.requote', $onboardingId) : null; + $quote['requote_url'] = $onboardingId && blank($version->courier_expcode) && blank($version->billing_rules) + && ! $version->change?->isStockQuote() ? route('business.courier-onboardings.requote', $onboardingId) : null; return $quote; }) @@ -255,7 +256,9 @@ final class QuoteManagementSnapshot $today = today(); foreach ($versions->groupBy(static fn (CourierQuoteVersion $version): string => filled($version->courier_expcode) ? 'code:' . $version->courier_expcode . ':' . $version->quote_type - : 'name:' . $version->company_name) as $group) { + : ($version->change?->isStockQuote() || filled($version->billing_rules) + ? 'stock-name:' . $version->company_name . ':' . $version->quote_type + : 'name:' . $version->company_name)) as $group) { $current = CourierRequoteService::resolveAt($group, $today); $base = CourierRequoteService::resolveAt($group, $today, false); $started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version) diff --git a/database/migrations/2026_10_16_100000_allow_stock_quote_courier_names_without_codes.php b/database/migrations/2026_10_16_100000_allow_stock_quote_courier_names_without_codes.php new file mode 100644 index 00000000..fcb9d3af --- /dev/null +++ b/database/migrations/2026_10_16_100000_allow_stock_quote_courier_names_without_codes.php @@ -0,0 +1,31 @@ +string('courier_expcode', 64)->nullable()->change(); + // 无编号时独立按确认后的名称去重;不伪造工作平台快递编号。 + $table->string('courier_name_key', 64)->nullable(); + $table->unique(['company_id', 'courier_name_key', 'quote_type'], 'courier_stock_quote_name_unique'); + }); + } + + public function down(): void + { + if (DB::table('business_courier_stock_quote_entries')->whereNull('courier_expcode')->exists()) { + throw new RuntimeException('存在按名称录入的存量报价,请保留兼容结构,不可通过回滚删除业务记录。'); + } + Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void { + $table->dropUnique('courier_stock_quote_name_unique'); + $table->dropColumn('courier_name_key'); + $table->string('courier_expcode', 64)->nullable(false)->change(); + }); + } +}; diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php index c3bdc35a..3082afce 100644 --- a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Cache; /** * 发货明细只读仓库。读取扁平表 jst_oms_shipping(对齐工作平台发货数据 47 列), @@ -297,6 +298,29 @@ class ShippingOrderRepository return array_slice($values, 0, $limit); } + /** 报价录入的近期发货名称候选;只读固定数量的新明细,不在每次输入时扫描整张发货表。 */ + public function recentCourierNames(string $keyword, int $limit = 20): array + { + $key = 'shipping:recent-couriers:' . hash('sha256', $this->db->getName() . '|' . $this->db->getDatabaseName()); + $names = Cache::remember($key, now()->addMinutes(5), function (): array { + $names = []; + foreach ($this->db->table('jst_oms_shipping')->orderByDesc('id')->limit(10000)->get(['express_name1', 'express_name2']) as $row) { + foreach ([$row->express_name1, $row->express_name2] as $value) { + $name = trim((string) $value); + if ($name !== '' && $name !== '0') { + $names[$name] = true; + } + } + } + $names = array_keys($names); + sort($names, SORT_STRING); + + return $names; + }); + + return array_slice(array_values(array_filter($names, static fn (string $name): bool => mb_stripos($name, $keyword) !== false)), 0, min(max($limit, 1), 20)); + } + /** 同步状态(供页面显示同步中/最近完成)。 */ public function syncStatus(): array { diff --git a/resources/views/business/pages/courier-payables/partials/stock-quote-courier-names.blade.php b/resources/views/business/pages/courier-payables/partials/stock-quote-courier-names.blade.php new file mode 100644 index 00000000..e704b07f --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/stock-quote-courier-names.blade.php @@ -0,0 +1,30 @@ +@foreach(collect($couriers)->pluck('name')->unique() as $name)@endforeach + diff --git a/resources/views/business/pages/courier-payables/partials/stock-quote-review.blade.php b/resources/views/business/pages/courier-payables/partials/stock-quote-review.blade.php index f01ae4ea..9cb8e9db 100644 --- a/resources/views/business/pages/courier-payables/partials/stock-quote-review.blade.php +++ b/resources/views/business/pages/courier-payables/partials/stock-quote-review.blade.php @@ -2,7 +2,7 @@
存量报价原文与核对
-

快递编号:{{ $stockEntry->courier_expcode }} · 工作表:{{ $stockEntry->sheet_name }} · 范围:{{ $stockEntry->source_range }}

+

快递名称:{{ $stockEntry->courier_name }}@if(filled($stockEntry->courier_expcode)) · 已有编号:{{ $stockEntry->courier_expcode }}@else · 按名称录入@endif · 原工作表:{{ $stockEntry->sheet_name }} · 范围:{{ $stockEntry->source_range }}

计费规则
{{ data_get($change->quote_payload, 'billing_rules') }}

请逐项核对报价金额、重量段、计费方式和执行日期。公式保留原表达式及文件中的缓存值;原文件可下载复核。

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 3a94d68d..df70fc00 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 @@ -62,7 +62,7 @@
  1. 1 上传整本报价
  2. -
  3. 2 匹配快递 · 套用规则
  4. +
  5. 2 确认快递名称 · 计费规则
  6. 3 确认批量录入
@if($errors->any()) @@ -85,7 +85,7 @@

支持 xlsx / xls,最大 20 MB、200 张工作表;保留原文件、原公式和报价金额。

xlsx 工作表中的内嵌图片会按原始文件独立留存,无需逐家另传。多张图片全部保留,存档不代表已核对一致;外链图片不会自动下载。

-

工作表名称与快递编号或唯一名称一致时自动匹配;说明页可在下一步取消选择。

+

按工作表名称带入快递名称,可选择发货数据中的名称或直接手填,不要求先补快递资料及编号;说明页可在下一步取消选择。

@@ -108,13 +108,13 @@

{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}已提取的原件图片随各家报价保留,可在“查看报价”中查看或下载;缺图时再补齐,核对一致后才能按历史签署资料归档生效。

本批录入结果

-
- @foreach($result['created'] as $created)@endforeach +
原工作表快递及编号查看
{{ $created['sheet_name'] }}{{ $created['courier_name'] }} · {{ $created['courier_expcode'] }}查看报价
+ @foreach($result['created'] as $created)@endforeach
原工作表确认的快递名称查看
{{ $created['sheet_name'] }}{{ $created['courier_name'] }}查看报价
@else -

{{ $manifest['source_file']['name'] }} · 识别到 {{ count($rows) }} 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。

+

{{ $manifest['source_file']['name'] }} · 识别到 {{ count($rows) }} 张工作表。请确认快递名称与发货数据一致;可选择已有名称或手填,无需快递编号。重复报价会提示,不覆盖已有报价。

@csrf
@@ -136,26 +136,29 @@
-

逐表匹配与集中核对

+

逐表确认名称与集中核对

-
+

-

沿用已有快递资料,不重复核验网点或签署主体。说明页、重复报价可取消勾选。各表图片已分别留存,需核对是否为完整签署原件以及金额、计费条件和执行日期是否一致;缺图的可先存草稿,补齐后再核验。

+

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

- @foreach($couriers as $courier)@endforeach + @include('business.pages.courier-payables.partials.stock-quote-courier-names')
- + @foreach($rows as $index => $row) - @php($choice = $previous['entries'][$index] ?? []) - + @php + $choice = $previous['entries'][$index] ?? []; + $choiceName = $choice['courier_name'] ?? (filled($choice['courier_expcode'] ?? null) ? (collect($couriers)->firstWhere('code', $choice['courier_expcode'])['name'] ?? $row['courier_name']) : $row['courier_name']); + @endphp + - +
录入原工作表关联快递编号计费规则执行日期签署原件图片报价原文
录入原工作表快递名称(与发货数据一致)计费规则执行日期签署原件图片报价原文
{{ $row['sheet_name'] }}
{{ $row['source_range'] ?? '空表' }}
@error('entries.' . $index)
{{ $message }}
@enderror
{{ $row['match_status'] }}
{{ $row['match_status'] }}
留空沿用共用日期 @@ -203,37 +206,40 @@ }); const form = root.querySelector('[data-batch-review]'); if (!form) return; - const couriers = new Map(@json($couriers).map(courier => [courier.code, courier.name])); + const couriers = @json($couriers); + const knownNames = new Set(couriers.map(courier => courier.name)); const rows = [...form.querySelectorAll('[data-batch-row]')]; const groups = form.querySelector('[data-rule-groups]'); const commonDate = form.querySelector('[data-common-date]'); - const isExisting = row => row.dataset.duplicate === '1' && row.querySelector('[data-row-courier]').value.trim() === row.dataset.initialCode && form.querySelector('[data-common-type]').value.trim() === @json($defaults['quote_type'] ?? ''); + const courierName = row => row.querySelector('[data-row-courier]').value.trim(); + const courierCode = row => courierName(row) === row.dataset.initialName ? row.dataset.initialCode : ''; + const identity = row => courierCode(row) ? 'code:' + courierCode(row) : 'name:' + courierName(row).toLowerCase(); + const isExisting = row => row.dataset.duplicate === '1' && courierName(row) === row.dataset.initialName && form.querySelector('[data-common-type]').value.trim() === @json($defaults['quote_type'] ?? ''); 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 code = row.querySelector('[data-row-courier]').value.trim(); - if (row.querySelector('[data-row-selected]').checked && code) identities.set(code, (identities.get(code) || 0) + 1); + const key = identity(row); + if (row.querySelector('[data-row-selected]').checked && courierName(row)) identities.set(key, (identities.get(key) || 0) + 1); }); rows.forEach(row => { - const code = row.querySelector('[data-row-courier]').value.trim(); + const name = courierName(row); const checked = row.querySelector('[data-row-selected]').checked; - const known = couriers.has(code); + const known = knownNames.has(name); const duplicate = isExisting(row); const invalid = row.dataset.invalid === '1'; - const repeated = checked && (identities.get(code) || 0) > 1; - row.querySelector('[data-courier-name]').textContent = couriers.get(code) || ''; + const repeated = checked && (identities.get(identity(row)) || 0) > 1; const status = row.querySelector('[data-row-status]'); - status.textContent = invalid ? '空表,已跳过' : duplicate ? '已有报价,请核对或跳过' : repeated ? '与本批其他表编号重复' : known ? '已匹配' : !code ? row.dataset.initialStatus : '编号不存在,请从列表选择'; - status.classList.toggle('sb-error', !invalid && (!known || repeated)); + 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; - row.hidden = !(row.dataset.sheetName + ' ' + code + ' ' + (couriers.get(code) || '')).toLowerCase().includes(search) || (onlyUnmatched && !needsAttention); + row.hidden = !(row.dataset.sheetName + ' ' + name).toLowerCase().includes(search) || (onlyUnmatched && !needsAttention); if (checked) { selected++; if (needsAttention) pending++; } }); - form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 家 · 其中 ${pending} 家需处理匹配 · 跳过 ${rows.length - selected} 张工作表`; + form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 家 · 其中 ${pending} 家需确认名称或处理重复 · 跳过 ${rows.length - selected} 张工作表`; }; const updateGroups = () => { const names = [...groups.querySelectorAll('[data-rule-name]')].map((input, index) => input.value || `规则分组 ${index + 1}`); @@ -256,7 +262,7 @@ groups.addEventListener('input', updateGroups); form.addEventListener('input', update); form.addEventListener('change', update); form.querySelector('[data-select-matched]').addEventListener('click', () => { - rows.forEach(row => row.querySelector('[data-row-selected]').checked = couriers.has(row.querySelector('[data-row-courier]').value.trim()) && !isExisting(row) && row.dataset.invalid !== '1'); update(); + rows.forEach(row => row.querySelector('[data-row-selected]').checked = knownNames.has(courierName(row)) && !isExisting(row) && row.dataset.invalid !== '1'); update(); }); form.querySelector('[data-select-visible]').addEventListener('click', () => { const visible = rows.filter(row => !row.hidden && row.dataset.invalid !== '1'); @@ -274,7 +280,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_expcode: row.querySelector('[data-row-courier]').value.trim(), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').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 })) }; 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 d24fcd0d..f36cb4d2 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 @@ -24,7 +24,7 @@
返回报价管理 -

选择已有快递资料,保存原报价并核对后提交审核。后续调价沿用报价版本、审核和签署流程。

+

选择或手填与发货数据一致的快递名称,保存原报价并核对后提交审核,无需先补快递资料和编号。后续调价沿用报价版本、审核和签署流程。

@if($errors->any())