feat(存量报价): 支持按发货名称手填录入及无编号报价

This commit is contained in:
limeng 2026-10-16 16:49:30 +08:00
parent f3df42f46b
commit c8d600bd4f
16 changed files with 400 additions and 81 deletions

View File

@ -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);

View File

@ -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',
];

View File

@ -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(),

View File

@ -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);

View File

@ -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' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
}
}

View File

@ -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)

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void {
$table->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();
});
}
};

View File

@ -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
{

View File

@ -0,0 +1,30 @@
<datalist id="stock-courier-names">@foreach(collect($couriers)->pluck('name')->unique() as $name)<option value="{{ $name }}">已有报价或快递资料</option>@endforeach</datalist>
<script>
(() => {
const list = document.getElementById('stock-courier-names');
const known = [...list.options].map(option => option.value);
const endpoint = @json(route('business.courier-stock-quotes.courier-names'));
let timer, controller;
document.addEventListener('input', event => {
if (!event.target.matches('[data-courier-name-input]')) return;
clearTimeout(timer);
controller?.abort();
const keyword = event.target.value.trim();
if (keyword.length < 2) return;
timer = setTimeout(async () => {
controller = new AbortController();
try {
const response = await fetch(endpoint + '?q=' + encodeURIComponent(keyword.slice(0, 64)), { signal: controller.signal, headers: { Accept: 'application/json' } });
if (!response.ok) return;
const result = await response.json();
const shipping = new Set(result.items || []);
list.replaceChildren(...[...new Set([...shipping, ...known])].map(name => {
const option = document.createElement('option'); option.value = name;
option.label = shipping.has(name) ? '近期发货数据中的名称' : '已有报价或快递资料';
return option;
}));
} catch (error) { /* 联想失败不阻止手填。 */ }
}, 350);
});
})();
</script>

View File

@ -2,7 +2,7 @@
<section class="quote-review__panel" aria-labelledby="stock-source-title">
<header class="quote-review__header"><div class="quote-review__title" id="stock-source-title"><span class="material-icons-outlined" aria-hidden="true">inventory_2</span><span>存量报价原文与核对</span></div></header>
<div class="quote-review__body">
<p>快递编号:<strong>{{ $stockEntry->courier_expcode }}</strong> · 工作表:{{ $stockEntry->sheet_name }} · 范围:{{ $stockEntry->source_range }}</p>
<p>快递名称:<strong>{{ $stockEntry->courier_name }}</strong>@if(filled($stockEntry->courier_expcode)) · 已有编号:{{ $stockEntry->courier_expcode }}@else · 按名称录入@endif · 原工作表:{{ $stockEntry->sheet_name }} · 范围:{{ $stockEntry->source_range }}</p>
<p style="white-space:pre-wrap"><strong>计费规则</strong><br>{{ data_get($change->quote_payload, 'billing_rules') }}</p>
<p class="quote-review__hint">请逐项核对报价金额、重量段、计费方式和执行日期。公式保留原表达式及文件中的缓存值;原文件可下载复核。</p>
<div class="quote-review__files">

View File

@ -62,7 +62,7 @@
</div>
<ol class="sb-steps" aria-label="批量录入步骤">
<li @if(!$batch) aria-current="step" @endif>1 上传整本报价</li>
<li @if($batch && !$result) aria-current="step" @endif>2 匹配快递 · 套用规则</li>
<li @if($batch && !$result) aria-current="step" @endif>2 确认快递名称 · 计费规则</li>
<li @if($result) aria-current="step" @endif>3 确认批量录入</li>
</ol>
@if($errors->any())
@ -85,7 +85,7 @@
<input id="source_quote" name="source_quote" type="file" accept=".xlsx,.xls" required aria-label="整本报价 Excel">
<p class="sb-hint">支持 xlsx / xls,最大 20 MB、200 张工作表;保留原文件、原公式和报价金额。</p>
<p class="sb-hint">xlsx 工作表中的内嵌图片会按原始文件独立留存,无需逐家另传。多张图片全部保留,存档不代表已核对一致;外链图片不会自动下载。</p>
<p class="sb-hint">工作表名称与快递编号或唯一名称一致时自动匹配;说明页可在下一步取消选择。</p>
<p class="sb-hint">按工作表名称带入快递名称,可选择发货数据中的名称或直接手填,不要求先补快递资料及编号;说明页可在下一步取消选择。</p>
</div>
<div class="sb-grid">
<div class="sb-field"><label for="quote_type">本批报价类型</label><input id="quote_type" name="quote_type" class="sb-control" value="{{ old('quote_type', '标准快递报价') }}" maxlength="64" required></div>
@ -108,13 +108,13 @@
<p class="sb-hint" style="margin-top:12px">{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}已提取的原件图片随各家报价保留,可在“查看报价”中查看或下载;缺图时再补齐,核对一致后才能按历史签署资料归档生效。</p>
<section class="sb-panel">
<h2 class="sb-header">本批录入结果</h2>
<div class="sb-table-wrap"><table class="sb-table"><thead><tr><th>原工作表</th><th>快递及编号</th><th>查看</th></tr></thead><tbody>
@foreach($result['created'] as $created)<tr><td>{{ $created['sheet_name'] }}</td><td>{{ $created['courier_name'] }} · {{ $created['courier_expcode'] }}</td><td><a href="{{ route('business.courier-quote-changes.show', $created['change_id']) }}">查看报价</a></td></tr>@endforeach
<div class="sb-table-wrap"><table class="sb-table"><thead><tr><th>原工作表</th><th>确认的快递名称</th><th>查看</th></tr></thead><tbody>
@foreach($result['created'] as $created)<tr><td>{{ $created['sheet_name'] }}</td><td>{{ $created['courier_name'] }}</td><td><a href="{{ route('business.courier-quote-changes.show', $created['change_id']) }}">查看报价</a></td></tr>@endforeach
</tbody></table></div>
</section>
<div class="sb-actions"><a class="sb-button sb-button--secondary" href="{{ route('business.courier-quote-changes.index') }}">打开报价历史</a><a class="sb-button" href="{{ route('business.courier-stock-quotes.create') }}">继续导入下一批</a></div>
@else
<p class="sb-notice">{{ $manifest['source_file']['name'] }} · 识别到 <strong>{{ count($rows) }}</strong> 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。</p>
<p class="sb-notice">{{ $manifest['source_file']['name'] }} · 识别到 <strong>{{ count($rows) }}</strong> 张工作表。请确认快递名称与发货数据一致;可选择已有名称或手填,无需快递编号。重复报价会提示,不覆盖已有报价。</p>
<form action="{{ route('business.courier-stock-quotes.batch-save', $batch) }}" method="POST" data-batch-review>
@csrf
<section class="sb-panel">
@ -136,26 +136,29 @@
</div>
</section>
<section class="sb-panel">
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">fact_check</span>逐表匹配与集中核对</h2>
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">fact_check</span>逐表确认名称与集中核对</h2>
<div class="sb-body">
<div class="sb-toolbar">
<div class="sb-toolbar"><input class="sb-control sb-search" type="search" data-batch-search aria-label="搜索工作表或快递" placeholder="搜索工作表、快递名称或编号"><label><input type="checkbox" data-only-unmatched> 只看待处理</label></div>
<div class="sb-toolbar"><input class="sb-control sb-search" type="search" data-batch-search aria-label="搜索工作表或快递" placeholder="搜索工作表或快递名称"><label><input type="checkbox" data-only-unmatched> 只看待处理</label></div>
<div class="sb-toolbar"><button class="sb-button sb-button--secondary" type="button" data-select-matched>只选已匹配</button><button class="sb-button sb-button--secondary" type="button" data-select-visible>全选当前可见</button></div>
</div>
<p class="sb-hint" data-batch-count aria-live="polite"></p>
<p class="sb-hint">沿用已有快递资料,不重复核验网点或签署主体。说明页、重复报价可取消勾选。各表图片已分别留存,需核对是否为完整签署原件以及金额、计费条件和执行日期是否一致;缺图的可先存草稿,补齐后再核验。</p>
<p class="sb-hint">输入两字以上可搜索近期发货名称;找不到也可手填,不要求先建快递资料。请使用发货数据中的完整名称,不要只填品牌;说明页、重复报价可取消勾选。各表图片分别留存,存档不代表已核对一致。</p>
</div>
<datalist id="batch-couriers">@foreach($couriers as $courier)<option value="{{ $courier['code'] }}">{{ $courier['name'] }} · {{ $courier['code'] }}</option>@endforeach</datalist>
@include('business.pages.courier-payables.partials.stock-quote-courier-names')
<div class="sb-table-wrap" tabindex="0" aria-label="工作表匹配结果">
<table class="sb-table" data-table-key="stock-quote-batch-matching">
<thead><tr><th>录入</th><th>原工作表</th><th>关联快递编号</th><th>计费规则</th><th>执行日期</th><th>签署原件图片</th><th>报价原文</th></tr></thead>
<thead><tr><th>录入</th><th>原工作表</th><th>快递名称(与发货数据一致)</th><th>计费规则</th><th>执行日期</th><th>签署原件图片</th><th>报价原文</th></tr></thead>
<tbody>
@foreach($rows as $index => $row)
@php($choice = $previous['entries'][$index] ?? [])
<tr data-batch-row="{{ $index }}" data-sheet-name="{{ $row['sheet_name'] }}" data-invalid="{{ $row['error'] ? '1' : '0' }}" data-duplicate="{{ $row['duplicate'] ? '1' : '0' }}" data-initial-code="{{ $row['courier_expcode'] }}" data-initial-status="{{ $row['match_status'] }}">
@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
<tr data-batch-row="{{ $index }}" data-sheet-name="{{ $row['sheet_name'] }}" data-invalid="{{ $row['error'] ? '1' : '0' }}" data-duplicate="{{ $row['duplicate'] ? '1' : '0' }}" data-initial-code="{{ $row['courier_expcode'] }}" data-initial-name="{{ $row['courier_name'] }}" data-initial-status="{{ $row['match_status'] }}">
<td><input type="checkbox" data-row-selected aria-label="录入{{ $row['sheet_name'] }}" @checked($choice['selected'] ?? (!$row['error'] && !$row['duplicate'])) @disabled($row['error'])></td>
<td class="sb-sheet"><strong>{{ $row['sheet_name'] }}</strong><div class="sb-hint">{{ $row['source_range'] ?? '空表' }}</div>@error('entries.' . $index)<div class="sb-error">{{ $message }}</div>@enderror</td>
<td class="sb-match"><input class="sb-control" list="batch-couriers" data-row-courier value="{{ $choice['courier_expcode'] ?? $row['courier_expcode'] }}" maxlength="64" placeholder="选择或输入快递编号" aria-label="{{ $row['sheet_name'] }}的快递编号" @disabled($row['error'])><div class="sb-hint" data-courier-name></div><div class="sb-hint" data-row-status>{{ $row['match_status'] }}</div></td>
<td class="sb-match"><input class="sb-control" list="stock-courier-names" data-row-courier data-courier-name-input value="{{ $choiceName }}" maxlength="255" placeholder="选择或手填完整快递名称" aria-label="{{ $row['sheet_name'] }}的快递名称" @disabled($row['error'])><div class="sb-hint" data-row-status>{{ $row['match_status'] }}</div></td>
<td><select class="sb-control" data-row-rule aria-label="{{ $row['sheet_name'] }}的计费规则">@foreach($groups as $groupIndex => $group)<option value="{{ $groupIndex }}" @selected(($choice['rule_group'] ?? 0) == $groupIndex)>{{ $group['name'] }}</option>@endforeach</select></td>
<td><input class="sb-control" type="date" data-row-date value="{{ $choice['effective_date'] ?? '' }}" aria-label="{{ $row['sheet_name'] }}的实际执行日期"><span class="sb-hint" data-row-date-hint>留空沿用共用日期</span></td>
<td>
@ -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;

View File

@ -24,7 +24,7 @@
</style>
<main class="stock-quote">
<a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">返回报价管理</a>
<p class="stock-quote__notice">选择已有快递资料,保存原报价并核对后提交审核。后续调价沿用报价版本、审核和签署流程。</p>
<p class="stock-quote__notice">选择或手填与发货数据一致的快递名称,保存原报价并核对后提交审核,无需先补快递资料和编号。后续调价沿用报价版本、审核和签署流程。</p>
@if($errors->any())
<div class="stock-quote__errors" role="alert">
@foreach($errors->all() as $error)<div>{{ $error }}</div>@endforeach
@ -35,27 +35,24 @@
@csrf
@if($entry) @method('PATCH') @endif
<section class="stock-quote__panel">
<h2 class="stock-quote__header"><span class="material-icons-outlined" aria-hidden="true">local_shipping</span>第一步:关联已有快递</h2>
<h2 class="stock-quote__header"><span class="material-icons-outlined" aria-hidden="true">local_shipping</span>第一步:确认快递名称</h2>
<div class="stock-quote__body">
@include('business.pages.courier-payables.partials.stock-quote-courier-names')
<div class="stock-quote__field">
<label for="courier_name">快递名称(与发货数据一致)</label>
<input class="stock-quote__control" id="courier_name" name="courier_name" list="stock-courier-names" data-courier-name-input value="{{ old('courier_name', $entry?->courier_name) }}" maxlength="255" required placeholder="选择或手填完整快递名称">
<p class="stock-quote__hint">输入两字以上可搜索近期发货名称;找不到也可手填,不会新增或改写快递资料。请使用完整名称,不要只填品牌。</p>
</div>
@if($entry)
<p>{{ $entry->courier_name }} · 快递编号 {{ $entry->courier_expcode }} · {{ $entry->quote_type }}</p>
<p class="stock-quote__hint">此草稿已关联该快递编号及报价类型。</p>
<p>报价类型:{{ $entry->quote_type }} · 原工作表:{{ $entry->sheet_name }}</p>
<p class="stock-quote__hint">草稿阶段可以更正名称,正式版本生效后通过后续变价管理,不覆盖历史版本。</p>
@else
<div class="stock-quote__grid">
<div class="stock-quote__field">
<label for="courier_expcode">已有快递资料(名称 / 唯一编号)</label>
<select class="stock-quote__control" id="courier_expcode" name="courier_expcode" required>
<option value="">请选择快递及网点</option>
@foreach($couriers as $courier)<option value="{{ $courier['code'] }}" @selected(old('courier_expcode') === $courier['code'])>{{ $courier['name'] }} · {{ $courier['code'] }}</option>@endforeach
</select>
<p class="stock-quote__hint">同品牌的不同网点、结算主体请按各自编号分别录入。</p>
@if(!$couriers)<p role="status">尚无可选择的快递编号,请先在快递资料中完善资料,或确认资料同步状态后重试。</p>@endif
</div>
<div class="stock-quote__field">
<label for="quote_type">报价类型</label>
<input class="stock-quote__control" id="quote_type" name="quote_type" list="stock-quote-types" value="{{ old('quote_type') }}" maxlength="64" required placeholder="例如:标准快递报价、超长件报价">
<datalist id="stock-quote-types"><option value="标准快递报价"><option value="超长件报价"><option value="大件快递报价"></datalist>
<p class="stock-quote__hint">同一快递编号、同一类型只录入一次;后续调整请从对应报价发起变价。</p>
<p class="stock-quote__hint">同一家快递、同一类型只录入一次;后续调整请从对应报价发起变价。</p>
</div>
</div>
@endif

View File

@ -130,6 +130,7 @@ Route::prefix('workspace/courier-payables/quote-management/stock-entries')
->group(function () {
Route::get('/', [CourierStockQuoteEntries::class, 'create'])->name('create');
Route::get('single', [CourierStockQuoteEntries::class, 'single'])->name('single');
Route::get('courier-names', [CourierStockQuoteEntries::class, 'courierNames'])->name('courier-names');
Route::post('batch-preview', [CourierStockQuoteEntries::class, 'previewBatch'])->name('batch-preview');
Route::get('batches/{batch}', [CourierStockQuoteEntries::class, 'showBatch'])->name('batch');
Route::get('batches/{batch}/images/{sheet}/{file}', [CourierStockQuoteEntries::class, 'batchImage'])->whereNumber(['sheet', 'file'])->name('batch-image');

View File

@ -51,7 +51,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
->assertSeeText('共用计费规则')->assertSeeText('只补录一家');
$batch = $this->preview(['EXP-001', '同名网点', '第三网点', '说明']);
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()
->assertSeeText('名称重复,请选编号')->assertSeeText('保存并提交审核');
->assertSeeText('名称对应多条资料')->assertSeeText('保存并提交审核');
$service = app(CourierStockQuoteBatchService::class);
$manifest = $service->load($batch, (int) $this->user->id);
$rows = $service->rows($manifest, app(\App\Support\CourierStockQuoteEntryService::class)->courierOptions());
@ -138,6 +138,52 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
}
public function test_unknown_names_and_couriers_without_codes_can_be_imported_without_creating_directory_records(): void
{
$this->mirror([['code' => '', 'name' => '资料缺编号快递']]);
$batch = $this->preview(['老表名称', '资料缺编号快递']);
$selection = $this->selection(['', '']);
$selection['entries'][0]['courier_name'] = '发货数据中的完整快递名称';
$selection['entries'][1]['courier_name'] = '资料缺编号快递';
$url = route('business.courier-stock-quotes.batch-save', $batch);
config(['firewall.middleware.xss.enabled' => true]);
$this->loginAs()->postJson($url, $selection)->assertOk();
$entries = CourierStockQuoteEntry::orderBy('id')->get();
$this->assertSame(['发货数据中的完整快递名称', '资料缺编号快递'], $entries->pluck('courier_name')->all());
$this->assertSame([null, null], $entries->pluck('courier_expcode')->all());
$this->assertSame('老表名称', $entries[0]->sheet_name);
$this->assertNotSame($entries[0]->courier_name_key, $entries[1]->courier_name_key);
$this->assertEquals(5, $entries[0]->sourceQuote()['values'][1][1]);
$this->assertSame('=B2/2', $entries[0]->sourceQuote()['formulas'][1][2]);
$this->assertDatabaseCount('business_courier_onboardings', 0);
$this->postJson($url, $selection)->assertOk();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
$duplicate = $this->preview(['发货数据中的完整快递名称']);
$this->get(route('business.courier-stock-quotes.batch', $duplicate))->assertSeeText('已录入,默认跳过');
$payload = $this->selection(['']);
$payload['entries'][0]['courier_name'] = '发货数据中的完整快递名称';
$this->withExceptionHandling()->postJson(route('business.courier-stock-quotes.batch-save', $duplicate), $payload)
->assertUnprocessable()->assertJsonValidationErrors('entries.0');
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
}
public function test_name_input_does_not_silently_merge_same_named_codes_and_rejects_repeated_manual_names(): void
{
$batch = $this->preview(['原表一', '原表二']);
$selection = $this->selection(['', '']);
foreach ($selection['entries'] as &$row) {
$row['courier_name'] = '未建档快递';
}
unset($row);
$url = route('business.courier-stock-quotes.batch-save', $batch);
$this->withExceptionHandling()->loginAs()->postJson($url, $selection)->assertUnprocessable()->assertJsonValidationErrors('entries.1');
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
$selection['entries'][1]['courier_name'] = '同名网点';
$this->postJson($url, $selection)->assertOk();
$this->assertSame([null, null], CourierStockQuoteEntry::pluck('courier_expcode')->all());
$this->assertSame(['未建档快递', '同名网点'], CourierStockQuoteEntry::pluck('courier_name')->all());
}
public function test_row_errors_are_atomic_keep_preview_and_do_not_override_existing_quotes(): void
{
$batch = $this->preview(['EXP-001', 'EXP-002']);

View File

@ -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()
->assertSeeText('EXP-001')->assertSeeText('EXP-002')->assertSeeText('实际开始执行日期');
->assertSee('value="同名快递网点"', false)->assertSeeText('实际开始执行日期')->assertDontSee('name="courier_expcode"', false);
$data = $this->payload();
$originalHash = hash_file('sha256', $data['source_quote']->getRealPath());
$this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect();
@ -114,7 +114,7 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
{
$this->createEntry();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), $this->payload())
->assertSessionHasErrors('courier_expcode');
->assertSessionHasErrors('courier_name');
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'EXP-002'])->assertRedirect();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'UNREGISTERED'])
@ -130,6 +130,64 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
->assertRedirect();
}
public function test_manual_name_can_be_corrected_in_draft_and_follows_review_and_later_price_change(): void
{
$entry = $this->createEntry(['courier_expcode' => null, 'courier_name' => '手填名称待修正', 'signed_files' => [UploadedFile::fake()->create('签署原件.pdf', 1, 'application/pdf')]]);
$files = $entry->files;
$this->assertNull($entry->courier_expcode);
$this->loginAs()->patch(route('business.courier-stock-quotes.update', $entry), [
'courier_name' => '发货数据完整快递名称', 'effective_date' => '2025-12-01', 'billing_rules' => '首重1kg,续重按原报价', 'summary' => '更正为发货名称',
])->assertRedirect();
$entry->refresh();
$this->assertSame('发货数据完整快递名称', $entry->courier_name);
$this->assertSame('发货数据完整快递名称', $entry->change->carrier_name);
$this->assertSame('报价', $entry->sheet_name);
$this->assertSame($files, $entry->files);
$savedDetails = json_decode($entry->change->events()->where('event_type', 'saved')->latest('id')->firstOrFail()->details_json, true);
$this->assertSame('发货数据完整快递名称', $savedDetails['carrier_name']);
$service = app(CourierQuoteChangeService::class);
$reviewer = (int) $this->reviewer()->id;
$service->submit($entry->change, (int) $this->user->id);
$service->approve($entry->change->fresh(), $reviewer, true);
$original = CourierQuoteVersion::firstOrFail();
$this->assertNull($original->courier_expcode);
$this->assertSame('发货数据完整快递名称', $original->company_name);
$this->assertSame('生效中', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']);
$change = $service->create([
'source_quote_id' => $original->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-01-01', 'summary' => '后续变价',
'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 7]],
], (int) $this->user->id);
$service->submit($change, (int) $this->user->id);
$changed = $service->approve($change->fresh(), $reviewer);
$this->assertSame('发货数据完整快递名称', $changed->version->company_name);
$this->assertNull($changed->version->courier_expcode);
$this->assertEquals(5, json_decode($original->fresh()->values_json, true)[1][1]);
$this->assertEquals(7, json_decode($changed->version->values_json, true)[1][1]);
$this->assertSame($files, $entry->fresh()->files);
}
public function test_shipping_names_can_be_suggested_without_a_courier_directory_record(): void
{
$shipping = \Mockery::mock(\Modules\EcommerceFinance\Repositories\ShippingOrderRepository::class);
$shipping->shouldReceive('recentCourierNames')->once()->with('缺资料', 20)->andReturn(['缺资料发货快递']);
app()->instance(\Modules\EcommerceFinance\Repositories\ShippingOrderRepository::class, $shipping);
$this->loginAs()->getJson(route('business.courier-stock-quotes.courier-names', ['q' => '缺资料']))->assertOk()->assertJsonPath('items.0', '缺资料发货快递');
$this->assertDatabaseCount('business_courier_onboardings', 0);
}
public function test_name_only_versions_of_different_quote_types_do_not_supersede_each_other(): void
{
$service = app(CourierQuoteChangeService::class);
$reviewer = (int) $this->reviewer()->id;
foreach (['标准快递报价', '大件快递报价'] as $type) {
$entry = $this->createEntry(['courier_expcode' => null, 'courier_name' => '同家无编号快递', 'quote_type' => $type,
'signed_files' => [UploadedFile::fake()->create('签署原件.pdf', 1, 'application/pdf')]]);
$service->submit($entry->change, (int) $this->user->id);
$service->approve($entry->change->fresh(), $reviewer, true);
}
$this->assertSame(['生效中', '生效中'], array_column(app(QuoteManagementSnapshot::class)->load()['quotes'], 'status'));
}
public function test_draft_replacement_retains_previous_original_and_updates_preview_and_audit(): void
{
$entry = $this->createEntry();

View File

@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Tests\TestCase;
class StockQuoteShippingNamesTest extends TestCase
{
public function test_candidates_preserve_shipping_names_without_scanning_history_or_requerying_each_keyword(): void
{
config(['jushuitan.connection' => 'sqlite']);
Cache::flush();
if (! Schema::hasTable('jst_oms_shipping')) {
Schema::create('jst_oms_shipping', function (Blueprint $table): void {
$table->increments('id');
$table->string('express_name1')->nullable();
$table->string('express_name2')->nullable();
});
}
DB::table('jst_oms_shipping')->insert(['id' => 1, 'express_name1' => '仅历史出现的快递', 'express_name2' => null]);
foreach (array_chunk(range(2, 10001), 500) as $ids) {
DB::table('jst_oms_shipping')->insert(array_map(fn ($id) => [
'id' => $id, 'express_name1' => '发货名称甲', 'express_name2' => $id === 10001 ? '发货名称乙' : '0',
], $ids));
}
$repository = new ShippingOrderRepository();
DB::enableQueryLog();
DB::flushQueryLog();
$this->assertSame(['发货名称乙', '发货名称甲'], $repository->recentCourierNames('发货名称'));
$this->assertSame([], $repository->recentCourierNames('仅历史'));
$this->assertCount(1, DB::getQueryLog());
DB::disableQueryLog();
$this->assertSame('仅历史出现的快递', DB::table('jst_oms_shipping')->where('id', 1)->value('express_name1'));
$this->assertSame(10001, DB::table('jst_oms_shipping')->count());
}
}