From 6bf8d909924a209daa92028f0bdb6860d720b0b5 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 16 Oct 2026 11:00:17 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(=E6=8A=A5=E4=BB=B7=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E5=A2=9E=E5=8A=A0=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E5=BD=95=E5=85=A5=E3=80=81=E5=8E=9F=E4=BB=B6=E7=95=99=E5=AD=98?= =?UTF-8?q?=E5=92=8C=E5=AE=A1=E6=A0=B8=E7=89=88=E6=9C=AC=E8=A1=94=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierQuoteChanges.php | 26 +- .../Business/CourierStockQuoteEntries.php | 89 +++++++ app/Models/Business/CourierQuoteChange.php | 10 + app/Models/Business/CourierQuoteVersion.php | 2 +- .../Business/CourierStockQuoteEntry.php | 38 +++ app/Support/CourierQuoteChangeService.php | 54 +++- app/Support/CourierQuoteConfirmation.php | 6 +- app/Support/CourierQuotePdf.php | 1 + app/Support/CourierRequoteService.php | 6 +- app/Support/CourierStockQuoteEntryService.php | 197 +++++++++++++++ app/Support/QuoteManagementSnapshot.php | 19 +- ...000_create_courier_stock_quote_entries.php | 42 +++ .../partials/stock-quote-review.blade.php | 33 +++ .../quote-change-show.blade.php | 40 ++- .../quote-management.blade.php | 15 +- .../stock-quote-entry.blade.php | 101 ++++++++ .../business/pdf/courier-quote.blade.php | 1 + routes/admin.php | 12 + .../Business/CourierStockQuoteEntryTest.php | 239 ++++++++++++++++++ 19 files changed, 898 insertions(+), 33 deletions(-) create mode 100644 app/Http/Controllers/Business/CourierStockQuoteEntries.php create mode 100644 app/Models/Business/CourierStockQuoteEntry.php create mode 100644 app/Support/CourierStockQuoteEntryService.php create mode 100644 database/migrations/2026_10_16_000000_create_courier_stock_quote_entries.php create mode 100644 resources/views/business/pages/courier-payables/partials/stock-quote-review.blade.php create mode 100644 resources/views/business/pages/courier-payables/stock-quote-entry.blade.php create mode 100644 tests/Feature/Business/CourierStockQuoteEntryTest.php diff --git a/app/Http/Controllers/Business/CourierQuoteChanges.php b/app/Http/Controllers/Business/CourierQuoteChanges.php index 2a5b936e..b73144bd 100644 --- a/app/Http/Controllers/Business/CourierQuoteChanges.php +++ b/app/Http/Controllers/Business/CourierQuoteChanges.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Business; use App\Models\Business\CourierQuoteChange; +use App\Models\Business\CourierStockQuoteEntry; use App\Services\Esign\EsignClient; use App\Support\CourierQuoteChangeService; use App\Support\CourierOnboardingService; @@ -47,7 +48,7 @@ class CourierQuoteChanges extends Controller $source = $this->service->findSourceQuote($selectedId); // 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。 - if ($request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) { + if (empty($source['courier_expcode']) && $request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) { return redirect()->route('business.courier-onboardings.requote', $onboarding); } @@ -66,16 +67,18 @@ class CourierQuoteChanges extends Controller { $this->refreshSignatures(); $quoteChange->load(['items', 'events.actor', 'version.signature']); - $onboarding = $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null; + $stockEntry = $quoteChange->isStockQuote() ? CourierStockQuoteEntry::query()->where('source_change_id', $quoteChange->id)->firstOrFail() : null; + $onboarding = $stockEntry === null && $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null; $requoteDiff = $onboarding !== null ? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload) : null; return view('business.pages.courier-payables.quote-change-show', [ 'change' => $quoteChange, + 'stockEntry' => $stockEntry, 'onboarding' => $onboarding, 'requoteDiff' => $requoteDiff, - 'reviewWarnings' => $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED + 'reviewWarnings' => $stockEntry === null && $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED ? $this->requotes->reviewWarnings($quoteChange, $requoteDiff) : [], 'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null, @@ -89,6 +92,9 @@ class CourierQuoteChanges extends Controller public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse { abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403); + if ($quoteChange->isStockQuote()) { + return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id')); + } if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) { return redirect()->route('business.courier-onboardings.requote', $onboarding); } @@ -101,6 +107,7 @@ class CourierQuoteChanges extends Controller public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { + abort_if($quoteChange->isStockQuote(), 422, '请使用存量报价草稿入口修改。'); $change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id()); return redirect()->route('business.courier-quote-changes.edit', $change) @@ -115,13 +122,21 @@ class CourierQuoteChanges extends Controller ->with('success', "{$change->number} 已提交审核。"); } - public function approve(CourierQuoteChange $quoteChange): RedirectResponse + public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); - $change = $this->service->approve($quoteChange, user_id()); + if ($quoteChange->isStockQuote()) { + $request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']); + } + $change = $this->service->approve($quoteChange, user_id(), $request->boolean('stock_signature_verified')); $message = "审核完成,已生成新报价版本 {$change->version->version_code}。"; + if ($change->isStockQuote()) { + return redirect()->route('business.courier-quote-changes.show', $change) + ->with('success', $message . ' ' . $change->version->signature_status . '。原报价已保留,未自动发起电子签署或发送提醒。'); + } + try { $this->signatureService->prepareVersion($change->version, user_id()); $message .= ' 签署 PDF 已生成并固定校验值。'; @@ -154,6 +169,7 @@ class CourierQuoteChanges extends Controller public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。'); $onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name); abort_if($onboarding === null || $quoteChange->version === null, 404); app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id()); diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php new file mode 100644 index 00000000..5b72fff2 --- /dev/null +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -0,0 +1,89 @@ + null, 'couriers' => $this->service->courierOptions()]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + ...$this->metadataRules(), + 'courier_expcode' => ['required', 'string', 'max:64'], + 'quote_type' => ['required', 'string', 'max:64'], + 'sheet_name' => ['nullable', 'string', 'max:255'], + ...$this->fileRules(true), + ]); + $entry = $this->service->create($data, (int) user_id()); + + return redirect()->route('business.courier-quote-changes.show', $entry->change) + ->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。'); + } + + public function edit(CourierStockQuoteEntry $stockQuoteEntry): View + { + $this->ensureEditable($stockQuoteEntry); + + return view('business.pages.courier-payables.stock-quote-entry', ['entry' => $stockQuoteEntry, 'couriers' => []]); + } + + 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']]); + $this->service->update($stockQuoteEntry, $data, (int) user_id()); + + return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。'); + } + + public function download(CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse + { + abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404); + $attachment = $stockQuoteEntry->files[$file] ?? null; + abort_if($attachment === null || ! Storage::disk('uploads')->exists($attachment['path']), 404, '附件不存在。'); + + return Storage::disk('uploads')->download($attachment['path'], $attachment['name']); + } + + private function metadataRules(): array + { + return [ + 'effective_date' => ['required', 'date_format:Y-m-d'], + 'billing_rules' => ['required', 'string', 'min:2', 'max:4000'], + 'summary' => ['required', 'string', 'min:2', 'max:2000'], + ]; + } + + private function fileRules(bool $requiresSource): array + { + return [ + 'source_quote' => [$requiresSource ? 'required' : 'nullable', 'file', 'mimes:xlsx,xls', 'max:20480'], + 'confirmation_files' => ['nullable', 'array', 'max:10'], + 'confirmation_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png,xlsx,xls,eml,msg', 'max:20480'], + 'signed_files' => ['nullable', 'array', 'max:10'], + 'signed_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png', 'max:20480'], + ]; + } + + private function ensureEditable(CourierStockQuoteEntry $entry): void + { + abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403); + } +} diff --git a/app/Models/Business/CourierQuoteChange.php b/app/Models/Business/CourierQuoteChange.php index 726a7eef..b77367e0 100644 --- a/app/Models/Business/CourierQuoteChange.php +++ b/app/Models/Business/CourierQuoteChange.php @@ -51,6 +51,11 @@ class CourierQuoteChange extends Model return $this->hasOne(CourierQuoteVersion::class, 'source_change_id'); } + public function stockEntry(): HasOne + { + return $this->hasOne(CourierStockQuoteEntry::class, 'source_change_id'); + } + /** 按完整报价结构提交的变价(快递入驻后的后台变价 / 重新报价)。 */ /** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */ public function periodLabel(): string @@ -65,6 +70,11 @@ class CourierQuoteChange extends Model return is_array($this->quote_payload) && $this->quote_payload !== []; } + public function isStockQuote(): bool + { + return $this->change_type === '存量报价' && is_numeric(data_get($this->quote_payload, 'stock_quote_entry_id')); + } + public function isEditable(): bool { return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true); diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php index 5d572ed6..74916822 100644 --- a/app/Models/Business/CourierQuoteVersion.php +++ b/app/Models/Business/CourierQuoteVersion.php @@ -12,7 +12,7 @@ class CourierQuoteVersion extends Model protected $fillable = [ 'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name', - 'carrier_name', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json', + 'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json', 'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by', ]; diff --git a/app/Models/Business/CourierStockQuoteEntry.php b/app/Models/Business/CourierStockQuoteEntry.php new file mode 100644 index 00000000..6f7ad7e9 --- /dev/null +++ b/app/Models/Business/CourierStockQuoteEntry.php @@ -0,0 +1,38 @@ + 'integer', 'files' => 'array']; + + public function change(): BelongsTo + { + return $this->belongsTo(CourierQuoteChange::class, 'source_change_id'); + } + + public function sourceQuote(): array + { + return [ + 'id' => 'STOCK-' . $this->id, + 'courier_expcode' => $this->courier_expcode, + 'billing_rules' => data_get($this->change?->quote_payload, 'billing_rules'), + 'company_name' => $this->courier_name, + 'sheet_name' => $this->sheet_name, + 'quote_type' => $this->quote_type, + 'source_range' => $this->source_range, + 'values' => json_decode($this->values_json, true, 512, JSON_THROW_ON_ERROR), + 'formulas' => json_decode($this->formulas_json, true, 512, JSON_THROW_ON_ERROR), + ]; + } +} diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index e3a3b28f..9c4898fa 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -5,6 +5,7 @@ namespace App\Support; use App\Models\Business\CourierQuoteChange; use App\Models\Business\CourierQuoteChangeEvent; use App\Models\Business\CourierQuoteVersion; +use App\Models\Business\CourierStockQuoteEntry; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -17,10 +18,13 @@ final class CourierQuoteChangeService public function create(array $data, int $userId): CourierQuoteChange { + if (str_starts_with($data['source_quote_id'], 'STOCK-') && ($data['change_type'] ?? '') !== '存量报价') { + throw ValidationException::withMessages(['source_quote_id' => '存量报价请先完成首版审核,再从正式报价版本发起变价。']); + } $source = $this->findSourceQuote($data['source_quote_id']); return DB::transaction(function () use ($data, $userId, $source): CourierQuoteChange { - $retrospective = $this->isRetrospectiveDate($data['effective_date']); + $retrospective = ($data['change_type'] ?? '') !== '存量报价' && $this->isRetrospectiveDate($data['effective_date']); $change = CourierQuoteChange::create([ 'company_id' => company_id(), 'number' => $this->nextChangeNumber(), @@ -38,9 +42,13 @@ final class CourierQuoteChangeService 'recalculation_note' => null, 'summary' => $data['summary'], 'created_by' => $userId, + 'quote_payload' => $data['quote_payload'] ?? null, + 'proposed_values_json' => $data['proposed_values_json'] ?? null, ]); - $this->replaceItems($change, $data['items'], $source); + if (! $change->isStockQuote()) { + $this->replaceItems($change, $data['items'], $source); + } $this->recordEvent($change, 'created', null, CourierQuoteChange::STATUS_DRAFT, '创建报价变更草稿。', $userId, $this->changeDetails($change)); return $change->load('items', 'events'); @@ -54,7 +62,7 @@ final class CourierQuoteChangeService return DB::transaction(function () use ($change, $data, $userId, $source): CourierQuoteChange { $from = $change->status; - $retrospective = $this->isRetrospectiveDate($data['effective_date']); + $retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($data['effective_date']); $change->update([ 'change_type' => $data['change_type'], 'effective_date' => $data['effective_date'], @@ -65,8 +73,11 @@ final class CourierQuoteChangeService 'recalculation_note' => null, 'summary' => $data['summary'], 'status' => CourierQuoteChange::STATUS_DRAFT, + 'quote_payload' => $change->isStockQuote() ? [...$change->quote_payload, 'billing_rules' => $data['billing_rules']] : $change->quote_payload, ]); - $this->replaceItems($change, $data['items'], $source); + if (! $change->isStockQuote()) { + $this->replaceItems($change, $data['items'], $source); + } $this->recordEvent($change, 'saved', $from, CourierQuoteChange::STATUS_DRAFT, '保存报价变更草稿。', $userId, $this->changeDetails($change)); return $change->fresh(['items', 'events']); @@ -77,13 +88,13 @@ final class CourierQuoteChangeService { $this->ensureEditableBy($change, $userId); - if ($change->items()->count() === 0) { + if (! ($change->isStockQuote() && filled($change->proposed_values_json)) && $change->items()->count() === 0) { throw ValidationException::withMessages(['items' => '至少需要一条发生变化的报价明细。']); } return DB::transaction(function () use ($change, $userId): CourierQuoteChange { $from = $change->status; - $retrospective = $this->isRetrospectiveDate($change->effective_date); + $retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date); $change->update([ 'status' => CourierQuoteChange::STATUS_PENDING_REVIEW, 'is_retrospective' => $retrospective, @@ -114,7 +125,7 @@ final class CourierQuoteChangeService }); } - public function approve(CourierQuoteChange $change, int $userId): CourierQuoteChange + public function approve(CourierQuoteChange $change, int $userId, bool $historicalDocumentsConfirmed = false): CourierQuoteChange { if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']); @@ -127,14 +138,20 @@ final class CourierQuoteChangeService $source = $this->findSourceQuote($change->source_quote_id); - return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange { + return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange { + $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) { + throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或保留待确认/待补签状态。']); + } + $historicalSigned = $hasHistoricalFiles && $historicalDocumentsConfirmed; $values = $source['values']; $formulas = $source['formulas']; // 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。 if ($change->hasQuotePayload() && filled($change->proposed_values_json)) { $values = json_decode((string) $change->proposed_values_json, true, 512, JSON_THROW_ON_ERROR); - $formulas = []; + $formulas = $change->isStockQuote() ? $source['formulas'] : []; } foreach ($change->hasQuotePayload() ? [] : $change->items as $item) { @@ -156,6 +173,8 @@ final class CourierQuoteChangeService 'sheet_name' => $source['sheet_name'], 'company_name' => $source['company_name'], 'carrier_name' => $source['company_name'], + 'courier_expcode' => $source['courier_expcode'] ?? null, + 'billing_rules' => $change->isStockQuote() ? data_get($change->quote_payload, 'billing_rules') : ($source['billing_rules'] ?? null), 'quote_type' => $source['quote_type'], 'effective_date' => $change->effective_date, 'end_date' => $change->end_date, @@ -163,7 +182,7 @@ final class CourierQuoteChangeService 'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'status' => 'approved', - 'signature_status' => '未签署', + 'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '待确认/待补签') : '未签署', 'created_by' => $change->created_by, 'approved_by' => $userId, ]); @@ -171,7 +190,7 @@ final class CourierQuoteChangeService 'version_code' => sprintf('QB-%03d', $importedVersionCount + $version->id), ]); - $retrospective = $this->isRetrospectiveDate($change->effective_date); + $retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date); $recalculationNote = $retrospective ? $this->retrospectiveBlockReason() : null; $change->update([ 'status' => CourierQuoteChange::STATUS_APPROVED, @@ -193,6 +212,7 @@ final class CourierQuoteChangeService $this->recordEvent($change, 'approved', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_APPROVED, $eventNote, $userId, [ ...$this->changeDetails($change), 'version_code' => $version->version_code, + 'historical_documents_confirmed' => $historicalSigned, ]); return $change->fresh(['items', 'events', 'version']); @@ -201,6 +221,12 @@ final class CourierQuoteChangeService public function findSourceQuote(string $id): array { + if (preg_match('/^STOCK-([1-9][0-9]*)$/', $id, $match)) { + $entry = CourierStockQuoteEntry::query()->where('company_id', company_id())->find($match[1]); + if ($entry !== null) { + return $entry->sourceQuote(); + } + } $quote = collect($this->snapshot->load()['quotes'] ?? [])->firstWhere('id', $id); if (! is_array($quote)) { @@ -289,6 +315,12 @@ final class CourierQuoteChangeService private function changeDetails(CourierQuoteChange $change): array { return [ + ...($change->isStockQuote() ? [ + 'stock_quote_entry_id' => data_get($change->quote_payload, 'stock_quote_entry_id'), + 'billing_rules' => data_get($change->quote_payload, 'billing_rules'), + 'files' => collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files) + ->map(static fn (array $file): array => \Illuminate\Support\Arr::only($file, ['kind', 'name', 'sha256']))->all(), + ] : []), 'change_type' => $change->change_type, 'effective_date' => $change->effective_date->toDateString(), 'end_date' => $change->end_date?->toDateString(), diff --git a/app/Support/CourierQuoteConfirmation.php b/app/Support/CourierQuoteConfirmation.php index 6c663b16..86fac088 100644 --- a/app/Support/CourierQuoteConfirmation.php +++ b/app/Support/CourierQuoteConfirmation.php @@ -13,6 +13,7 @@ final class CourierQuoteConfirmation $nextVersion = CourierQuoteVersion::query() ->where('carrier_name', $version->carrier_name) ->where('quote_type', $version->quote_type) + ->where('courier_expcode', $version->courier_expcode) ->where('status', 'approved') ->where('effective_date', '>', $version->effective_date) ->whereNull('end_date') @@ -21,7 +22,10 @@ final class CourierQuoteConfirmation // 短期变价到期后恢复的长期报价(打印在确认单上,让快递知道到期后按哪份执行)。 $restore = $version->isTemporary() ? \App\Support\CourierRequoteService::resolveAt( - CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)->where('status', 'approved')->where('id', '!=', $version->id)->get(), + CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name) + ->where('courier_expcode', $version->courier_expcode) + ->when(filled($version->courier_expcode), fn ($query) => $query->where('quote_type', $version->quote_type)) + ->where('status', 'approved')->where('id', '!=', $version->id)->get(), $version->end_date->copy()->addDay(), false, ) diff --git a/app/Support/CourierQuotePdf.php b/app/Support/CourierQuotePdf.php index 5c01977b..70112681 100644 --- a/app/Support/CourierQuotePdf.php +++ b/app/Support/CourierQuotePdf.php @@ -24,6 +24,7 @@ final class CourierQuotePdf 'partyA' => $data['party_a'], 'expiryDate' => $data['expiry_date'], 'temporaryNote' => $data['temporary_note'] ?? null, + 'billingRules' => $version->billing_rules, 'faceFee' => $data['face_fee'], 'bubbleCoefficient' => $data['bubble_coefficient'], 'signatureDate' => $signatureDate, diff --git a/app/Support/CourierRequoteService.php b/app/Support/CourierRequoteService.php index 1c85a09e..c2ff6478 100644 --- a/app/Support/CourierRequoteService.php +++ b/app/Support/CourierRequoteService.php @@ -69,6 +69,7 @@ final class CourierRequoteService return CourierQuoteVersion::query() ->with('signature') ->where('company_name', $onboarding->courier_name) + ->whereNull('courier_expcode') ->where('status', 'approved') ->latest('id') ->first(); @@ -80,6 +81,7 @@ final class CourierRequoteService return CourierQuoteVersion::query() ->with('signature') ->where('company_name', $onboarding->courier_name) + ->whereNull('courier_expcode') ->where('status', 'approved') ->latest('id') ->get(); @@ -92,6 +94,7 @@ final class CourierRequoteService ->where('carrier_name', $onboarding->courier_name) ->whereIn('status', [CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_DRAFT]) ->whereNotNull('quote_payload') + ->where('change_type', '!=', '存量报价') ->latest('id') ->first(); } @@ -352,6 +355,7 @@ final class CourierRequoteService $versions = CourierQuoteVersion::query() ->with('signature') ->where('company_name', $courierName) + ->whereNull('courier_expcode') ->where('status', CourierQuoteVersion::STATUS_APPROVED) ->get(); @@ -378,7 +382,7 @@ final class CourierRequoteService public static function isSignedVersion(CourierQuoteVersion $version): bool { - return $version->signature_status === '已签署并归档' + return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true) || $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED; } diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php new file mode 100644 index 00000000..16d4f580 --- /dev/null +++ b/app/Support/CourierStockQuoteEntryService.php @@ -0,0 +1,197 @@ +mirror->overviewSource()['express'] ?? [] as $express) { + $code = trim((string) $express->expcode); + if ($code !== '' && filled($express->expname)) { + $options[$code] = ['code' => $code, 'name' => (string) $express->expname]; + } + } + foreach (CourierOnboarding::query()->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED]) + ->whereNotNull('workplatform_expcode')->get() as $courier) { + $code = trim((string) $courier->workplatform_expcode); + if ($code !== '' && filled($courier->courier_name)) { + $options[$code] ??= ['code' => $code, 'name' => $courier->courier_name]; + } + } + uasort($options, static fn (array $a, array $b): int => [$a['name'], $a['code']] <=> [$b['name'], $b['code']]); + + return array_values($options); + } + + /** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */ + public function readWorkbook(UploadedFile $file, ?string $sheetName): array + { + $book = null; + try { + $reader = IOFactory::createReaderForFile($file->getRealPath()); + $sheets = $reader->listWorksheetNames($file->getRealPath()); + if (blank($sheetName) && count($sheets) !== 1) { + throw ValidationException::withMessages(['sheet_name' => '文件有多个工作表,请填写本次录入的准确工作表名称:' . implode('、', $sheets)]); + } + $sheetName = filled($sheetName) ? $sheetName : $sheets[0]; + if (! in_array($sheetName, $sheets, true)) { + throw ValidationException::withMessages(['sheet_name' => '工作表不存在,可选:' . implode('、', $sheets)]); + } + foreach ($reader->listWorksheetInfo($file->getRealPath()) as $info) { + if ($info['worksheetName'] === $sheetName && ($info['totalRows'] > 2000 || $info['totalColumns'] > 100 || $info['totalRows'] * $info['totalColumns'] > 50000)) { + throw ValidationException::withMessages(['source_quote' => '单份报价最多 2000 行、100 列、50000 个单元格,请按快递及报价类型拆分。']); + } + } + $reader->setReadDataOnly(false); + $reader->setLoadSheetsOnly([$sheetName]); + $book = $reader->load($file->getRealPath()); + $sheet = $book->getSheetByName($sheetName); + $lastRow = $sheet->getHighestDataRow(); + $lastColumn = $sheet->getHighestDataColumn(); + $columns = Coordinate::columnIndexFromString($lastColumn); + $values = $formulas = []; + $hasContent = false; + for ($row = 1; $row <= $lastRow; $row++) { + $valueRow = $formulaRow = []; + for ($column = 1; $column <= $columns; $column++) { + $cell = $sheet->getCell([$column, $row]); + $formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null; + $value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue(); + $valueRow[] = is_object($value) ? (string) $value : $value; + $formulaRow[] = $formula; + $hasContent = $hasContent || ($value !== null && $value !== ''); + } + $values[] = $valueRow; + $formulas[] = $formulaRow; + } + if (! $hasContent) { + throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']); + } + + return ['sheet_name' => $sheetName, 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas]; + } catch (ValidationException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']); + } finally { + $book?->disconnectWorksheets(); + } + } + + 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()) { + $this->duplicateError(); + } + $matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null); + $savedPaths = []; + try { + return DB::transaction(function () use ($data, $userId, $courier, $matrix, &$savedPaths): CourierStockQuoteEntry { + $entry = CourierStockQuoteEntry::create([ + 'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'], + '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 = $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' => [], + 'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']], + 'proposed_values_json' => $entry->values_json, + ], $userId); + $entry->update(['source_change_id' => $change->id]); + + return $entry->load('change'); + }); + } catch (\Throwable $exception) { + if ($savedPaths !== []) { + Storage::disk('uploads')->delete($savedPaths); + } + if ($exception instanceof UniqueConstraintViolationException) { + $this->duplicateError(); + } + throw $exception; + } + } + + public function update(CourierStockQuoteEntry $entry, array $data, int $userId): void + { + $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 { + $files = $entry->files; + if ($matrix !== null) { + foreach ($files as &$file) { + if ($file['kind'] === 'source_quote') { + $file['kind'] = 'previous_source_quote'; + } + } + unset($file); + $entry->fill([ + '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), + ]); + } + $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]); + $this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId); + }); + } catch (\Throwable $exception) { + Storage::disk('uploads')->delete($savedPaths); + throw $exception; + } + } + + private function storeFiles(CourierStockQuoteEntry $entry, array $data, array &$savedPaths): array + { + $files = []; + foreach (['source_quote' => isset($data['source_quote']) ? [$data['source_quote']] : [], 'confirmation' => $data['confirmation_files'] ?? [], 'signed' => $data['signed_files'] ?? []] as $kind => $uploads) { + foreach ($uploads as $upload) { + $path = Storage::disk('uploads')->putFile('courier-stock-quotes/' . company_id() . "/{$entry->id}/{$kind}", $upload, 'private'); + if ($path === false) { + throw ValidationException::withMessages(['source_quote' => '附件保存失败,请重试;本次录入未保存。']); + } + $savedPaths[] = $path; + $files[] = ['kind' => $kind, 'path' => $path, 'name' => $upload->getClientOriginalName(), 'sha256' => hash_file('sha256', $upload->getRealPath())]; + } + } + + return $files; + } + + private function duplicateError(): never + { + throw ValidationException::withMessages(['courier_expcode' => '该快递编号的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']); + } +} diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index db9620d0..bac4c112 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -80,7 +80,7 @@ final class QuoteManagementSnapshot ? app(CourierRequoteService::class)->onboardedCourierIds() : []; - $versions = CourierQuoteVersion::query()->with('signature')->orderBy('id')->get(); + $versions = CourierQuoteVersion::query()->with(['signature', 'change.stockEntry'])->orderBy('id')->get(); $superseded = $this->supersededVersions($versions); $changeTypes = \App\Models\Business\CourierQuoteChange::query() ->whereIn('id', $versions->pluck('source_change_id')->filter()->unique()->all()) @@ -99,7 +99,7 @@ final class QuoteManagementSnapshot $quote['quote_type'] = CourierRequoteService::quoteTypeFor($changeType); } $onboardingId = $onboarded[$version->company_name] ?? null; - $quote['requote_url'] = $onboardingId ? route('business.courier-onboardings.requote', $onboardingId) : null; + $quote['requote_url'] = $onboardingId && blank($version->courier_expcode) ? route('business.courier-onboardings.requote', $onboardingId) : null; return $quote; }) @@ -152,6 +152,8 @@ final class QuoteManagementSnapshot 'sheet_index' => null, 'sheet_name' => $version->sheet_name, 'company_name' => $version->company_name, + 'courier_expcode' => $version->courier_expcode, + 'billing_rules' => $version->billing_rules, 'carrier' => $version->carrier_name, 'quote_type' => $version->quote_type, 'effective_date' => $version->effective_date?->toDateString(), @@ -179,6 +181,13 @@ final class QuoteManagementSnapshot */ private function signedFiles(CourierQuoteVersion $version): array { + if ($version->signature_status === '历史签署资料已归档' && ($entry = $version->change?->stockEntry)) { + foreach ($entry->files as $index => $file) { + if ($file['kind'] === 'signed') { + return ['signed' => route('business.courier-stock-quotes.download', [$entry, $index])]; + } + } + } $signature = $version->signature; if ($signature === null || $signature->status !== CourierQuoteSignature::STATUS_ARCHIVED || blank($signature->signed_pdf_path)) { return []; @@ -228,7 +237,7 @@ final class QuoteManagementSnapshot /** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */ private function isSigned(CourierQuoteVersion $version): bool { - return $version->signature_status === '已签署并归档' + return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true) || $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED; } @@ -244,7 +253,9 @@ final class QuoteManagementSnapshot { $result = []; $today = today(); - foreach ($versions->groupBy('company_name') as $group) { + 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) { $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_000000_create_courier_stock_quote_entries.php b/database/migrations/2026_10_16_000000_create_courier_stock_quote_entries.php new file mode 100644 index 00000000..eee60932 --- /dev/null +++ b/database/migrations/2026_10_16_000000_create_courier_stock_quote_entries.php @@ -0,0 +1,42 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('courier_expcode', 64); + $table->string('courier_name'); + $table->string('quote_type', 64); + $table->unsignedInteger('source_change_id')->nullable(); + $table->string('sheet_name'); + $table->string('source_range', 32); + $table->longText('values_json'); + $table->longText('formulas_json'); + $table->longText('files'); + $table->unsignedInteger('created_by'); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['company_id', 'courier_expcode', 'quote_type'], 'courier_stock_quote_identity_unique'); + }); + + Schema::table('business_courier_quote_versions', function (Blueprint $table): void { + $table->string('courier_expcode', 64)->nullable()->index(); + $table->text('billing_rules')->nullable(); + }); + } + + public function down(): void + { + Schema::table('business_courier_quote_versions', function (Blueprint $table): void { + $table->dropColumn(['courier_expcode', 'billing_rules']); + }); + Schema::dropIfExists('business_courier_stock_quote_entries'); + } +}; 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 new file mode 100644 index 00000000..1fa80e88 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/stock-quote-review.blade.php @@ -0,0 +1,33 @@ +@php($stockSource = $stockEntry->sourceQuote()) +
+
存量报价原文与核对
+
+

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

+

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

+

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

+ + @if($canReview && $change->status === 'pending_review') +
+ + @if(collect($stockEntry->files)->contains('kind', 'signed')) +

+ @endif +

未核验签署依据时,审核后仍为“待确认/待补签”。历史资料归档与本系统电子签署分别记录。

+
+ @endif +
+
+ + @for($column = 1; $column <= count($stockSource['values'][0] ?? []); $column++)@endfor + + @foreach($stockSource['values'] as $rowIndex => $row) + @foreach($row as $columnIndex => $value)@endforeach + @endforeach + +
行号{{ \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($column) }}
{{ $rowIndex + 1 }} is_numeric($value)]) title="{{ $stockSource['formulas'][$rowIndex][$columnIndex] ?? '' }}" style="white-space:pre-wrap">{{ $value ?? '' }}@if(filled($stockSource['formulas'][$rowIndex][$columnIndex] ?? null)){{ $stockSource['formulas'][$rowIndex][$columnIndex] }}@endif
+
+
diff --git a/resources/views/business/pages/courier-payables/quote-change-show.blade.php b/resources/views/business/pages/courier-payables/quote-change-show.blade.php index fc444e73..3cfe18ba 100644 --- a/resources/views/business/pages/courier-payables/quote-change-show.blade.php +++ b/resources/views/business/pages/courier-payables/quote-change-show.blade.php @@ -59,13 +59,43 @@ @media(max-width:800px){.quote-review{margin:16px}.quote-review__toolbar{align-items:stretch;flex-direction:column}.quote-review__meta,.quote-review__signature-meta,.quote-review__form-grid{grid-template-columns:1fr}.quote-review__event{grid-template-columns:1fr}.quote-review__review-form,.quote-review__form-actions{align-items:stretch;flex-direction:column}}
-
返回报价历史
@if($change->isEditable() && $change->created_by===user_id())编辑草稿
@csrf
@endif @if($canReview && $change->status==='pending_review')
@csrf
@endif
+
+ 返回报价历史 +
+ @if($change->isEditable() && $change->created_by===user_id()) + 编辑草稿 +
@csrf
+ @endif + @if($canReview && $change->status==='pending_review') +
@csrf
+ @endif +
+
@if(is_string(session('success')))
{{ session('success') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @if($reviewWarnings)@endif @if($change->isRetrospective())
追溯变价 · {{ $change->recalculationStatusLabel() }}
自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}
@endif -
{{ $change->number }} · {{ $change->statusLabel() }}
原报价版本
{{ $change->source_quote_id }}
快递公司
{{ $change->carrier_name }}
报价类型
{{ $change->quote_type }}
变更类型
{{ $change->change_type }}
执行期间
{{ $change->periodLabel() }}
生效方式
{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}
追溯核算
{{ $change->recalculationStatusLabel() }}
变价明细
{{ $requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项' }}
新报价版本
{{ $change->version?->version_code ?? '尚未生成' }}
签署状态
{{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
- @if($requoteDiff) +
+
{{ $change->number }} · {{ $change->statusLabel() }}
+
+
+
原报价版本
{{ $change->source_quote_id }}
+
快递公司
{{ $change->carrier_name }}
+
报价类型
{{ $change->quote_type }}
+
变更类型
{{ $change->change_type }}
+
执行期间
{{ $change->periodLabel() }}
+
生效方式
{{ $stockEntry ? '存量报价补录' : ($change->isRetrospective() ? '追溯变价' : '正常生效') }}
+
追溯核算
{{ $change->recalculationStatusLabel() }}
+
{{ $stockEntry ? '录入范围' : '变价明细' }}
{{ $stockEntry ? $stockEntry->source_range : ($requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项') }}
+
新报价版本
{{ $change->version?->version_code ?? '尚未生成' }}
+
签署状态
{{ $change->version?->signature_status ?? '未进入签署' }}
+
+
变更依据与说明
{{ $change->summary }}
+
+
+ @if($stockEntry) + @include('business.pages.courier-payables.partials.stock-quote-review') + @elseif($requoteDiff) @php($fmt = static fn ($v) => $v === null ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'))
新旧报价对比 · {{ $change->change_type }}
@@ -121,7 +151,9 @@ @endif - @if(!$quoteSignature) + @if(!$quoteSignature && $change->version->signature_status === '历史签署资料已归档') +

历史签署资料已核验归档,可从上方附件下载。尚无本系统电子签署记录。

+ @elseif(!$quoteSignature)
该历史报价版本尚未生成签署原文。
@if($canManageSignature)
@csrf
日期将直接写入待签原文。
@endif @else 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 384b107b..a40cea8c 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -762,6 +762,9 @@
+ + 录入存量报价 + @@ -986,7 +989,7 @@ var row = create('tr'); var nameCell = appendCell(row, ''); nameCell.appendChild(create('span', 'quote-table__name', quote.id)); - appendCell(row, quote.company_name); + appendCell(row, quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : '')); appendCell(row, quote.quote_type); appendCell(row, text(quote.source_range, '-')); appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列'); @@ -994,9 +997,9 @@ var signCell = appendCell(row, quote.signature_status, 'quote-table__secondary'); if (quote.signed_files && quote.signed_files.signed) { signCell.appendChild(document.createElement('br')); - var signedLink = create('a', 'quote-action', '回签单'); + var signedLink = create('a', 'quote-action', quote.signature_status === '历史签署资料已归档' ? '历史签署资料' : '回签单'); signedLink.href = quote.signed_files.signed; - signedLink.title = '下载 ' + quote.id + ' 已签署的回签单 PDF' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : ''); + signedLink.title = '下载 ' + quote.id + ' 签署资料' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : ''); signedLink.setAttribute('aria-label', '下载回签单 ' + quote.id); signCell.appendChild(signedLink); } @@ -1046,7 +1049,7 @@ root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本'; var exportLink = root.querySelector('[data-quote-export]'); if (exportLink) { - var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.signed; }) + var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.export; }) .map(function (quote) { return quote.id; }); exportLink.href = exportBase + (signedCodes.length ? '?codes=' + encodeURIComponent(signedCodes.join(',')) : ''); exportLink.setAttribute('aria-disabled', signedCodes.length ? 'false' : 'true'); @@ -1068,7 +1071,7 @@ var keyword = fields.keyword.value.trim().toLocaleLowerCase('zh-CN'); filteredQuotes = quotes.filter(function (quote) { - var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier) + var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier + ' ' + (quote.courier_expcode || '')) .toLocaleLowerCase('zh-CN') .includes(keyword); @@ -1106,7 +1109,7 @@ lastFocusedElement = trigger; setDetail('title', quote.sheet_name); setDetail('id', quote.id); - setDetail('carrier', quote.company_name); + setDetail('carrier', quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : '')); setDetail('type', quote.quote_type); setDetail('range', text(quote.source_range, '-')); setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注')); 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 new file mode 100644 index 00000000..d24fcd0d --- /dev/null +++ b/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php @@ -0,0 +1,101 @@ + + {{ $entry ? '编辑存量报价' : '录入存量报价' }} + + +
+ 返回报价管理 +

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

+ @if($errors->any()) + + @endif +
+ @csrf + @if($entry) @method('PATCH') @endif +
+

第一步:关联已有快递

+
+ @if($entry) +

{{ $entry->courier_name }} · 快递编号 {{ $entry->courier_expcode }} · {{ $entry->quote_type }}

+

此草稿已关联该快递编号及报价类型。

+ @else +
+
+ + +

同品牌的不同网点、结算主体请按各自编号分别录入。

+ @if(!$couriers)

尚无可选择的快递编号,请先在快递资料中完善资料,或确认资料同步状态后重试。

@endif +
+
+ + + +

同一快递编号、同一类型只录入一次;后续调整请从对应报价发起变价。

+
+
+ @endif +
+
+
+

第二步:保存原报价与依据

+
+ @if($entry) +

已保存原报价及附件。草稿阶段可重新上传报价或补充附件,历次原文件均会保留。

+
@foreach($entry->files as $index => $file)@endforeach
+ @endif +
+

支持 xlsx、xls,单文件不超过 20 MB。原始内容和公式会保留。

+

多工作表文件请填写本次报价所在的准确工作表名称。

+

最多 10 个,每个不超过 20 MB。

+

上传资料后由审核人核验;缺少有效签署依据时标记为待确认/待补签。

+
+
+
+
+

第三步:核对计费规则与执行日期

+
+

请按原报价填写真实执行日期,历史报价不以录入日期代替。

+

本次将报价接入版本管理;运费自动核算仍以已实现且经核验的计算规则为准。

+
+
+
+
下一步预览报价逐项核对,再提交审核。
+
+
+ +
+
diff --git a/resources/views/business/pdf/courier-quote.blade.php b/resources/views/business/pdf/courier-quote.blade.php index 61498f3b..82bd66ec 100644 --- a/resources/views/business/pdf/courier-quote.blade.php +++ b/resources/views/business/pdf/courier-quote.blade.php @@ -117,6 +117,7 @@ @endif

完整报价规则

+@if(! empty($billingRules))

{!! $mixedText($billingRules) !!}

@endif @forelse($values as $rowIndex => $row) diff --git a/routes/admin.php b/routes/admin.php index bacdbd28..6da12e89 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Business\AccountMappingActions; use App\Http\Controllers\Business\CourierProfiles; use App\Http\Controllers\Business\CourierQuoteChanges; +use App\Http\Controllers\Business\CourierStockQuoteEntries; use App\Http\Controllers\Business\CourierQuoteSignatures; use App\Http\Controllers\Business\CourierAgreementSignatures; use App\Http\Controllers\Business\CourierOnboardings; @@ -123,6 +124,17 @@ Route::prefix('workspace-data/supplier-payables') * @see \modules\OfflinePayments\Routes\admin.php for module example */ +Route::prefix('workspace/courier-payables/quote-management/stock-entries') + ->name('business.courier-stock-quotes.') + ->middleware('business.page:courier-payables,quote-management') + ->group(function () { + Route::get('/', [CourierStockQuoteEntries::class, 'create'])->name('create'); + Route::post('/', [CourierStockQuoteEntries::class, 'store'])->name('store'); + Route::get('{stockQuoteEntry}/edit', [CourierStockQuoteEntries::class, 'edit'])->name('edit'); + Route::patch('{stockQuoteEntry}', [CourierStockQuoteEntries::class, 'update'])->name('update'); + Route::get('{stockQuoteEntry}/files/{file}', [CourierStockQuoteEntries::class, 'download'])->whereNumber('file')->name('download'); + }); + Route::prefix('workspace/courier-payables/quote-management/changes') ->name('business.courier-quote-changes.') ->middleware('business.page:courier-payables,quote-management') diff --git a/tests/Feature/Business/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php new file mode 100644 index 00000000..06e605a1 --- /dev/null +++ b/tests/Feature/Business/CourierStockQuoteEntryTest.php @@ -0,0 +1,239 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $mirror = \Mockery::mock(CourierInformationMirrorRepository::class); + $mirror->shouldReceive('overviewSource')->andReturn(['express' => [ + (object) ['expcode' => 'EXP-001', 'expname' => '同名快递网点'], + (object) ['expcode' => 'EXP-002', 'expname' => '同名快递网点'], + ]]); + app()->instance(CourierInformationMirrorRepository::class, $mirror); + app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-stock-quote-fixture.json')); + } + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $path) { + if (is_file($path)) { + unlink($path); + } + } + parent::tearDown(); + } + + public function test_entry_uses_existing_courier_and_retains_original_file_formula_and_real_date(): void + { + $this->loginAs()->get(route('business.courier-stock-quotes.create'))->assertOk() + ->assertSeeText('EXP-001')->assertSeeText('EXP-002')->assertSeeText('实际开始执行日期'); + $data = $this->payload(); + $originalHash = hash_file('sha256', $data['source_quote']->getRealPath()); + $this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect(); + $entry = CourierStockQuoteEntry::firstOrFail(); + $this->assertSame('EXP-001', $entry->courier_expcode); + $this->assertSame($originalHash, $entry->files[0]['sha256']); + $this->assertSame('原始报价.xlsx', $entry->files[0]['name']); + $this->assertSame('=B2/2', $entry->sourceQuote()['formulas'][1][2]); + $this->assertSame('2025-12-01', $entry->change->effective_date->toDateString()); + $this->assertFalse($entry->change->isRetrospective()); + Storage::disk('uploads')->assertExists($entry->files[0]['path']); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + $this->assertDatabaseCount('business_courier_onboardings', 0); + $this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk() + ->assertSeeText('存量报价原文与核对')->assertSeeText('=B2/2')->assertSeeText('首重1kg'); + } + + public function test_review_requires_confirmation_and_never_claims_unsigned_quote_is_signed(): void + { + $entry = $this->createEntry(); + $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change))->assertRedirect(); + $reviewer = $this->reviewer(); + $this->withExceptionHandling()->loginAs($reviewer, $this->company) + ->post(route('business.courier-quote-changes.approve', $entry->change))->assertSessionHasErrors('stock_rules_confirmed'); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + $this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1'])->assertRedirect(); + $version = CourierQuoteVersion::firstOrFail(); + $this->assertSame('待确认/待补签', $version->signature_status); + $this->assertSame('EXP-001', $version->courier_expcode); + $this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $entry->change->fresh()->recalculation_status); + $this->assertSame('2025-12-01', $version->effective_date->toDateString()); + $this->assertDatabaseCount('business_courier_quote_signatures', 0); + $this->assertSame('待生效(待签章)', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']); + } + + public function test_historical_documents_require_reviewer_verification_and_remain_distinct_from_esign(): void + { + $entry = $this->createEntry(['signed_files' => [UploadedFile::fake()->create('历史合同.pdf', 5, 'application/pdf')]]); + $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change)); + $this->loginAs($this->reviewer(), $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), [ + 'stock_rules_confirmed' => '1', 'stock_signature_verified' => '1', + ])->assertRedirect(); + $version = CourierQuoteVersion::firstOrFail(); + $this->assertSame('历史签署资料已归档', $version->signature_status); + $this->assertDatabaseCount('business_courier_quote_signatures', 0); + $snapshot = app(QuoteManagementSnapshot::class)->load()['quotes'][0]; + $this->assertSame('生效中', $snapshot['status']); + $this->assertArrayHasKey('signed', $snapshot['signed_files']); + $this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk() + ->assertSeeText('尚无本系统电子签署记录')->assertDontSeeText('生成签署 PDF'); + } + + public function test_claiming_verified_history_without_documents_is_rejected(): void + { + $entry = $this->createEntry(); + $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change)); + $this->withExceptionHandling()->loginAs($this->reviewer(), $this->company) + ->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1', 'stock_signature_verified' => '1']) + ->assertSessionHasErrors('stock_signature_verified'); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + } + + public function test_same_code_and_type_cannot_be_imported_twice_but_same_names_with_different_codes_can(): void + { + $this->createEntry(); + $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), $this->payload()) + ->assertSessionHasErrors('courier_expcode'); + $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']) + ->assertSessionHasErrors('courier_expcode'); + } + + public function test_multi_sheet_upload_requires_exact_sheet_and_invalid_workbooks_leave_no_record(): void + { + $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true)]) + ->assertSessionHasErrors('sheet_name'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true), 'sheet_name' => '报价']) + ->assertRedirect(); + } + + public function test_draft_replacement_retains_previous_original_and_updates_preview_and_audit(): void + { + $entry = $this->createEntry(); + $originalPath = $entry->files[0]['path']; + $this->loginAs()->patch(route('business.courier-stock-quotes.update', $entry), [ + 'effective_date' => '2025-11-01', 'billing_rules' => '修正后首重1kg', 'summary' => '更正原报价附件', + 'source_quote' => $this->workbook(false, 8), + ])->assertRedirect(); + $entry->refresh(); + $this->assertCount(2, $entry->files); + $this->assertSame('previous_source_quote', $entry->files[0]['kind']); + Storage::disk('uploads')->assertExists($originalPath); + $this->assertEquals(8, $entry->sourceQuote()['values'][1][1]); + $this->assertEquals(8, json_decode($entry->change->proposed_values_json, true)[1][1]); + $details = json_decode($entry->change->events()->where('event_type', 'saved')->firstOrFail()->details_json, true); + $this->assertSame('修正后首重1kg', $details['billing_rules']); + $this->assertCount(2, $details['files']); + } + + public function test_followup_price_change_reuses_version_and_keeps_exact_courier_identity(): void + { + $entry = $this->createEntry(); + app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id); + app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $this->reviewer()->id); + $version = CourierQuoteVersion::firstOrFail(); + $this->loginAs()->get(route('business.courier-quote-changes.start', ['source_quote_id' => $version->version_code])) + ->assertOk()->assertSeeText('编辑新价格'); + $change = app(CourierQuoteChangeService::class)->create([ + 'source_quote_id' => $version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16', + 'summary' => '存量快递后续变价', 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]], + ], (int) $this->user->id); + app(CourierQuoteChangeService::class)->submit($change, (int) $this->user->id); + $approved = app(CourierQuoteChangeService::class)->approve($change->fresh(), (int) $this->reviewer()->id); + $this->assertSame('EXP-001', $approved->version->courier_expcode); + $this->assertSame('首重1kg,续重按原报价', $approved->version->billing_rules); + $this->assertEquals(6, json_decode($approved->version->values_json, true)[1][1]); + $this->assertEquals(5, json_decode($version->fresh()->values_json, true)[1][1]); + } + + public function test_other_company_cannot_download_or_edit_stock_quote(): void + { + $entry = $this->createEntry(); + \DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]); + $this->withExceptionHandling()->loginAs() + ->get(route('business.courier-stock-quotes.download', [$entry, 0]))->assertNotFound(); + $this->loginAs()->get(route('business.courier-stock-quotes.edit', $entry))->assertNotFound(); + $this->assertNull(CourierStockQuoteEntry::find($entry->id)); + } + + public function test_same_name_couriers_keep_separate_effective_versions(): void + { + $reviewer = $this->reviewer(); + foreach (['EXP-001', 'EXP-002'] as $code) { + $entry = $this->createEntry(['courier_expcode' => $code, 'signed_files' => [UploadedFile::fake()->create('回签.pdf', 1, 'application/pdf')]]); + app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id); + app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $reviewer->id, true); + } + $quotes = app(QuoteManagementSnapshot::class)->load()['quotes']; + $this->assertCount(2, $quotes); + $this->assertSame(['生效中', '生效中'], array_column($quotes, 'status')); + $this->assertSame(['EXP-001', 'EXP-002'], array_column($quotes, 'courier_expcode')); + } + + private function createEntry(array $overrides = []): CourierStockQuoteEntry + { + $this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), ...$overrides])->assertRedirect(); + + return CourierStockQuoteEntry::latest('id')->firstOrFail(); + } + + private function payload(): array + { + return ['courier_expcode' => 'EXP-001', 'quote_type' => '标准快递报价', 'source_quote' => $this->workbook(), + 'effective_date' => '2025-12-01', 'billing_rules' => '首重1kg,续重按原报价', 'summary' => '补录历史合作报价']; + } + + private function workbook(bool $multipleSheets = false, int $price = 5): UploadedFile + { + $book = new Spreadsheet(); + $sheet = $book->getActiveSheet()->setTitle('报价'); + $sheet->fromArray([['省份', '首重', '续重'], ['江苏省', $price, null]]); + $sheet->setCellValue('C2', '=B2/2'); + if ($multipleSheets) { + $book->createSheet()->setTitle('其他网点')->setCellValue('A1', '其他报价'); + } + $path = tempnam(sys_get_temp_dir(), 'stock-quote-'); + $this->temporaryFiles[] = $path; + IOFactory::createWriter($book, 'Xlsx')->save($path); + $book->disconnectWorksheets(); + + return new UploadedFile($path, '原始报价.xlsx', null, null, true); + } + + private function reviewer() + { + $user = user_model_class()::create([ + 'name' => '报价审核员', 'email' => 'stock-reviewer-' . uniqid() . '@example.com', + 'password' => 'local-test-password', 'locale' => 'zh-CN', 'enabled' => 1, + 'landing_page' => 'dashboard', 'created_from' => 'core::test', + ]); + $user->companies()->attach($this->company->id); + $user->attachRole(Role::where('name', 'admin')->firstOrFail()); + $user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + return $user; + } +} -- 2.47.2 From 03126cbead86a532cb22ae9420b7737172ad0f56 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 16 Oct 2026 13:33:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(=E6=8A=A5=E4=BB=B7=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E6=95=B4=E6=9C=ACExcel=E6=89=B9=E9=87=8F=E5=BD=95=E5=85=A5?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7=E5=B9=B6=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierStockQuoteEntries.php | 69 ++++- app/Support/CourierStockQuoteBatchService.php | 154 ++++++++++ app/Support/CourierStockQuoteEntryService.php | 104 +++++-- .../stock-quote-batch.blade.php | 266 ++++++++++++++++++ routes/admin.php | 4 + .../Business/CourierStockQuoteBatchTest.php | 227 +++++++++++++++ .../Business/CourierStockQuoteEntryTest.php | 2 +- 7 files changed, 799 insertions(+), 27 deletions(-) create mode 100644 app/Support/CourierStockQuoteBatchService.php create mode 100644 resources/views/business/pages/courier-payables/stock-quote-batch.blade.php create mode 100644 tests/Feature/Business/CourierStockQuoteBatchTest.php diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index 5b72fff2..ccdc33fa 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -3,25 +3,92 @@ namespace App\Http\Controllers\Business; use App\Models\Business\CourierStockQuoteEntry; +use App\Support\CourierStockQuoteBatchService; use App\Support\CourierStockQuoteEntryService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; use Symfony\Component\HttpFoundation\StreamedResponse; class CourierStockQuoteEntries extends Controller { - public function __construct(private readonly CourierStockQuoteEntryService $service) + public function __construct(private readonly CourierStockQuoteEntryService $service, private readonly CourierStockQuoteBatchService $batches) { } public function create(): View + { + return view('business.pages.courier-payables.stock-quote-batch', ['batch' => null, 'manifest' => null, 'rows' => [], 'couriers' => [], 'templates' => $this->batches->ruleTemplates()]); + } + + public function single(): View { return view('business.pages.courier-payables.stock-quote-entry', ['entry' => null, 'couriers' => $this->service->courierOptions()]); } + public function previewBatch(Request $request): RedirectResponse + { + $data = $request->validate([ + 'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'], + 'quote_type' => ['required', 'string', 'max:64'], + 'effective_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()); + + return redirect()->route('business.courier-stock-quotes.batch', $batch); + } + + public function showBatch(string $batch): View + { + $manifest = $this->batches->load($batch, (int) user_id()); + $couriers = $manifest['result'] === null ? $this->service->courierOptions() : []; + + return view('business.pages.courier-payables.stock-quote-batch', [ + 'batch' => $batch, 'manifest' => $manifest, 'couriers' => $couriers, + 'rows' => $manifest['result'] === null ? $this->batches->rows($manifest, $couriers) : [], 'templates' => [], + ]); + } + + public function saveBatch(Request $request, string $batch): RedirectResponse + { + // 单个 JSON 字段避免百家工作表触及 PHP 表单字段数量限制。 + $request->validate(['selection_json' => ['required', 'json', 'max:200000']]); + $selection = json_decode($request->input('selection_json'), true); + if (! is_array($selection)) { + throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']); + } + foreach (['quote_type', 'summary'] as $field) { + if (is_string($selection[$field] ?? null)) { + $selection[$field] = trim($selection[$field]); + } + } + $data = Validator::make($selection, [ + 'quote_type' => ['required', 'string', 'max:64'], + 'effective_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'], + 'rule_groups.*.name' => ['required', 'string', 'max:100'], + 'rule_groups.*.rules' => ['nullable', 'string', 'max:4000'], + 'entries' => ['required', 'array', 'max:200'], + 'entries.*' => ['array'], + 'entries.*.selected' => ['required', 'boolean'], + 'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'], + 'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'], + 'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'], + 'submit_for_review' => ['required', 'boolean'], + 'confirmed' => ['accepted_if:submit_for_review,true'], + ])->validate(); + $this->batches->save($batch, (int) user_id(), $data); + + return redirect()->route('business.courier-stock-quotes.batch', $batch); + } + public function store(Request $request): RedirectResponse { $data = $request->validate([ diff --git a/app/Support/CourierStockQuoteBatchService.php b/app/Support/CourierStockQuoteBatchService.php new file mode 100644 index 00000000..057a4c06 --- /dev/null +++ b/app/Support/CourierStockQuoteBatchService.php @@ -0,0 +1,154 @@ +whereNotNull('billing_rules')->latest('id')->limit(100)->get() + ->filter(fn ($version) => filled($version->billing_rules)) + ->unique('billing_rules')->map(fn ($version) => [ + 'name' => $version->carrier_name . ' · ' . $version->quote_type, + 'rules' => $version->billing_rules, + ])->values()->all(); + } + + public function preview(UploadedFile $file, array $defaults, int $userId): string + { + $sheets = $this->entries->readBatchWorkbook($file); + $batch = (string) Str::uuid(); + $disk = Storage::disk('uploads'); + $originalPath = $disk->putFileAs($this->directory($batch, $userId), $file, 'original.' . $file->getClientOriginalExtension(), 'private'); + if ($originalPath === false) { + throw ValidationException::withMessages(['source_quote' => '报价原文件保存失败,请重试。']); + } + $manifest = [ + 'company_id' => (int) company_id(), 'user_id' => $userId, 'created_at' => now()->toIso8601String(), + 'defaults' => $defaults, 'sheets' => $sheets, 'result' => null, + 'source_file' => ['kind' => 'source_quote', 'path' => $originalPath, 'name' => $file->getClientOriginalName(), 'sha256' => hash_file('sha256', $file->getRealPath())], + ]; + $this->write($batch, $userId, $manifest); + + return $batch; + } + + public function load(string $batch, int $userId): array + { + abort_unless(Str::isUuid($batch), 404); + $disk = Storage::disk('uploads'); + $path = $this->directory($batch, $userId) . '/manifest.json'; + abort_unless($disk->exists($path), 404, '导入预览不存在,请重新上传报价。'); + $manifest = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR); + abort_unless($manifest['company_id'] === (int) company_id() && $manifest['user_id'] === $userId, 404); + + return $manifest; + } + + 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]); + + return array_map(function (array $sheet) use ($manifest, $couriers, $existing): array { + $key = mb_strtolower(trim($sheet['sheet_name'])); + $matches = array_values(array_filter($couriers, static fn ($courier) => in_array($key, [ + 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']]); + + return [...$sheet, 'courier_expcode' => $code, 'duplicate' => $duplicate, + 'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称重复,请选编号' : '待匹配,请选编号'))), + ]; + }, $manifest['sheets']); + } + + public function save(string $batch, int $userId, array $data): array + { + $manifest = $this->load($batch, $userId); + if ($manifest['result'] !== null) { + return $manifest['result']; + } + $couriers = collect($this->entries->courierOptions())->keyBy('code'); + $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]); + foreach ($selected as $index => $row) { + $sheet = $manifest['sheets'][$index] ?? null; + $courier = $couriers->get(trim($row['courier_expcode'] ?? '')); + $rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? ''; + $metadata = [ + 'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''), + 'billing_rules' => trim($rules), 'summary' => $data['summary'], + ]; + $validation = Validator::make($metadata, [ + 'effective_date' => ['required', 'date_format:Y-m-d'], + '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 ??= $validation->fails() ? $validation->errors()->first() : null; + $identity = ($courier['code'] ?? '') . '|' . $metadata['quote_type']; + $reason ??= isset($existing[$identity]) ? '此快递编号的同类报价已录入,请取消选择;后续调价使用变价。' : null; + $reason ??= isset($identities[$identity]) ? '与本批其他工作表匹配了同一快递编号,请检查或取消选择。' : null; + 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); + } + abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。'); + // 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。 + return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array { + $created = []; + foreach ($prepared as $item) { + $entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file']]); + if ($data['submit_for_review'] ?? false) { + $this->changes->submit($entry->change, $userId); + } + $created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode]; + } + + $result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)]; + $manifest['result'] = $result; + $this->write($batch, $userId, $manifest); + + return $result; + }); + } + + private function directory(string $batch, int $userId): string + { + return 'courier-stock-quote-batches/' . company_id() . '/' . $userId . '/' . $batch; + } + + private function write(string $batch, int $userId, array $manifest): void + { + if (! Storage::disk('uploads')->put($this->directory($batch, $userId) . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'private')) { + throw ValidationException::withMessages(['source_quote' => '导入预览保存失败,请重试。']); + } + } +} diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php index 16d4f580..a38acfc0 100644 --- a/app/Support/CourierStockQuoteEntryService.php +++ b/app/Support/CourierStockQuoteEntryService.php @@ -13,6 +13,7 @@ use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; final class CourierStockQuoteEntryService { @@ -67,29 +68,7 @@ final class CourierStockQuoteEntryService $reader->setLoadSheetsOnly([$sheetName]); $book = $reader->load($file->getRealPath()); $sheet = $book->getSheetByName($sheetName); - $lastRow = $sheet->getHighestDataRow(); - $lastColumn = $sheet->getHighestDataColumn(); - $columns = Coordinate::columnIndexFromString($lastColumn); - $values = $formulas = []; - $hasContent = false; - for ($row = 1; $row <= $lastRow; $row++) { - $valueRow = $formulaRow = []; - for ($column = 1; $column <= $columns; $column++) { - $cell = $sheet->getCell([$column, $row]); - $formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null; - $value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue(); - $valueRow[] = is_object($value) ? (string) $value : $value; - $formulaRow[] = $formula; - $hasContent = $hasContent || ($value !== null && $value !== ''); - } - $values[] = $valueRow; - $formulas[] = $formulaRow; - } - if (! $hasContent) { - throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']); - } - - return ['sheet_name' => $sheetName, 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas]; + return $this->readSheet($sheet); } catch (ValidationException $exception) { throw $exception; } catch (\Throwable $exception) { @@ -99,6 +78,74 @@ final class CourierStockQuoteEntryService } } + /** 一次加载整本文件;各工作表保持独立,不计算公式。 */ + public function readBatchWorkbook(UploadedFile $file): array + { + $book = null; + try { + $reader = IOFactory::createReaderForFile($file->getRealPath()); + $info = $reader->listWorksheetInfo($file->getRealPath()); + if (count($info) > 200) { + throw ValidationException::withMessages(['source_quote' => '一次最多导入 200 张工作表,请分批上传。']); + } + $cells = 0; + foreach ($info as $sheet) { + $cells += $sheet['totalRows'] * $sheet['totalColumns']; + if ($sheet['totalRows'] > 2000 || $sheet['totalColumns'] > 100 || $sheet['totalRows'] * $sheet['totalColumns'] > 50000) { + throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet['worksheetName'] . '」超过 2000 行、100 列或 50000 格,请缩小范围后上传。']); + } + } + if ($cells > 500000) { + throw ValidationException::withMessages(['source_quote' => '整本报价最多 500000 个单元格,请分批上传。']); + } + $reader->setReadDataOnly(false); + $book = $reader->load($file->getRealPath()); + $sheets = []; + foreach ($book->getWorksheetIterator() as $sheet) { + try { + $sheets[] = [...$this->readSheet($sheet), 'error' => null]; + } catch (ValidationException $exception) { + $sheets[] = ['sheet_name' => $sheet->getTitle(), 'error' => '空工作表,请取消选择。']; + } + } + + return $sheets; + } catch (ValidationException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']); + } finally { + $book?->disconnectWorksheets(); + } + } + + private function readSheet(Worksheet $sheet): array + { + $lastRow = $sheet->getHighestDataRow(); + $lastColumn = $sheet->getHighestDataColumn(); + $columns = Coordinate::columnIndexFromString($lastColumn); + $values = $formulas = []; + $hasContent = false; + for ($row = 1; $row <= $lastRow; $row++) { + $valueRow = $formulaRow = []; + for ($column = 1; $column <= $columns; $column++) { + $cell = $sheet->getCell([$column, $row]); + $formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null; + $value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue(); + $valueRow[] = is_object($value) ? (string) $value : $value; + $formulaRow[] = $formula; + $hasContent = $hasContent || ($value !== null && $value !== ''); + } + $values[] = $valueRow; + $formulas[] = $formulaRow; + } + if (! $hasContent) { + throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']); + } + + return ['sheet_name' => $sheet->getTitle(), 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas]; + } + public function create(array $data, int $userId): CourierStockQuoteEntry { $data['quote_type'] = trim($data['quote_type']); @@ -110,9 +157,16 @@ final class CourierStockQuoteEntryService $this->duplicateError(); } $matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null); + + return $this->createPrepared($data, $userId, $courier, $matrix); + } + + /** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */ + public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry + { $savedPaths = []; try { - return DB::transaction(function () use ($data, $userId, $courier, $matrix, &$savedPaths): CourierStockQuoteEntry { + 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'], 'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'], @@ -120,7 +174,7 @@ final class CourierStockQuoteEntryService 'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'files' => [], 'created_by' => $userId, ]); - $files = $this->storeFiles($entry, $data, $savedPaths); + $files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)]; $entry->update(['files' => $files]); $change = $this->changes->create([ 'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价', 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 new file mode 100644 index 00000000..e0d95395 --- /dev/null +++ b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php @@ -0,0 +1,266 @@ + + 批量录入存量报价 + + + @php + $result = $manifest['result'] ?? null; + $previous = json_decode(old('selection_json', '{}'), true) ?: []; + $defaults = $manifest['defaults'] ?? []; + $groups = $previous['rule_groups'] ?? [['name' => '共用计费规则', 'rules' => $defaults['billing_rules'] ?? '']]; + @endphp +
+ +
    +
  1. 1 上传整本报价
  2. +
  3. 2 匹配快递 · 套用规则
  4. +
  5. 3 确认批量录入
  6. +
+ @if($errors->any()) + + @endif + @if(!$batch) +

一个 Excel,每家一张工作表。整本上传一次,共用计费规则只填一次,各家的报价金额仍以各自原表为准。

+
+ @csrf +
+

上传整本存量报价

+
+
+ 选择包含全部快递报价的 Excel + +

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

+

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

+
+
+
+

日期不一致的快递可逐家调整,不以录入日期代替。

+
+
+ + @if($templates) + + @endif + +

无需重复填写 100 多遍。下一步可新增规则分组,只调整有差异的快递。

+
+
+
+
先预览匹配结果,不会直接生成正式报价。
+ + @elseif($result) +
已录入 {{ count($result['created']) }} 家报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},跳过 {{ $result['skipped'] }} 张工作表。原 Excel 已留存,各家报价独立管理。
+

{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}历史签署资料仍须按家补充、核验。

+
+

本批录入结果

+
+ @foreach($result['created'] as $created)@endforeach +
原工作表快递及编号查看
{{ $created['sheet_name'] }}{{ $created['courier_name'] }} · {{ $created['courier_expcode'] }}查看报价
+
+
打开报价历史继续导入下一批
+ @else +

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

+
+ @csrf + +
+

共用设置与计费规则

+
+
+
+

只填一次;下表单独填写的日期优先。

+
+
+ @foreach($groups as $index => $group) +
+
+ +
+ @endforeach +
+
大多数沿用第一组,有差异的只切换分组。
+
+
+
+

逐表匹配与集中核对

+
+
+
+
+
+

+

输入快递编号即可选择;名称相同的网点按各自编号区分。说明页、重复报价可取消勾选。展开“原文”可查看各表前 8 行、前 10 列。

+
+ @foreach($couriers as $courier)@endforeach +
+ + + + @foreach($rows as $index => $row) + @php($choice = $previous['entries'][$index] ?? []) + + + + + + + + + @endforeach + +
录入原工作表关联快递编号计费规则执行日期报价原文
{{ $row['sheet_name'] }}
{{ $row['source_range'] ?? '空表' }}
@error('entries.' . $index)
{{ $message }}
@enderror
{{ $row['match_status'] }}
留空沿用共用日期@if(!$row['error'])
原文@foreach(array_slice($row['values'], 0, 8) as $valueRow)@foreach(array_slice($valueRow, 0, 10) as $value)@endforeach@endforeach
{{ $value }}
@else{{ $row['error'] }}@endif
+
+
+
+
+ +

可先保存草稿,也可集中提交审核。审核和历史签署资料核验仍按各家独立处理。

+
+
重新选择 Excel
+
+ @endif +
+ + + diff --git a/routes/admin.php b/routes/admin.php index 6da12e89..a00e9d91 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -129,6 +129,10 @@ Route::prefix('workspace/courier-payables/quote-management/stock-entries') ->middleware('business.page:courier-payables,quote-management') ->group(function () { Route::get('/', [CourierStockQuoteEntries::class, 'create'])->name('create'); + Route::get('single', [CourierStockQuoteEntries::class, 'single'])->name('single'); + Route::post('batch-preview', [CourierStockQuoteEntries::class, 'previewBatch'])->name('batch-preview'); + Route::get('batches/{batch}', [CourierStockQuoteEntries::class, 'showBatch'])->name('batch'); + Route::post('batches/{batch}', [CourierStockQuoteEntries::class, 'saveBatch'])->name('batch-save'); Route::post('/', [CourierStockQuoteEntries::class, 'store'])->name('store'); Route::get('{stockQuoteEntry}/edit', [CourierStockQuoteEntries::class, 'edit'])->name('edit'); Route::patch('{stockQuoteEntry}', [CourierStockQuoteEntries::class, 'update'])->name('update'); diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php new file mode 100644 index 00000000..f77a9ca1 --- /dev/null +++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php @@ -0,0 +1,227 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->mirror([ + ['code' => 'EXP-001', 'name' => '同名网点'], ['code' => 'EXP-002', 'name' => '同名网点'], + ['code' => 'EXP-003', 'name' => '第三网点'], + ]); + app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-batch-quotes.json')); + } + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $path) { + if (is_file($path)) { + unlink($path); + } + } + parent::tearDown(); + } + + public function test_preview_keeps_workbook_without_creating_quotes_and_never_guesses_ambiguous_names(): void + { + $this->loginAs()->get(route('business.courier-stock-quotes.create'))->assertOk() + ->assertSeeText('共用计费规则')->assertSeeText('只补录一家'); + $batch = $this->preview(['EXP-001', '同名网点', '第三网点', '说明']); + $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk() + ->assertSeeText('名称重复,请选编号')->assertSeeText('保存并提交审核'); + $service = app(CourierStockQuoteBatchService::class); + $manifest = $service->load($batch, (int) $this->user->id); + $rows = $service->rows($manifest, app(\App\Support\CourierStockQuoteEntryService::class)->courierOptions()); + $this->assertSame(['EXP-001', '', 'EXP-003', ''], array_column($rows, 'courier_expcode')); + $this->assertSame('=B2/2', $manifest['sheets'][0]['formulas'][1][2]); + Storage::disk('uploads')->assertExists($manifest['source_file']['path']); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $this->assertDatabaseCount('business_courier_quote_changes', 0); + } + + public function test_bulk_save_reuses_rule_groups_overrides_dates_and_preserves_separate_prices(): void + { + $batch = $this->preview(['EXP-001', 'EXP-002', '说明']); + $selection = $this->selection(['EXP-001', 'EXP-002', '']); + $selection['entries'][2]['selected'] = false; + $selection['rule_groups'][] = ['name' => '单独规则', 'rules' => '超长件按实际重量计费']; + $selection['entries'][1]['rule_group'] = 1; + $selection['entries'][1]['effective_date'] = '2025-11-01'; + $url = route('business.courier-stock-quotes.batch-save', $batch); + $this->loginAs()->post($url, ['selection_json' => json_encode($selection)])->assertRedirect(); + $entries = CourierStockQuoteEntry::with('change')->orderBy('id')->get(); + $this->assertCount(2, $entries); + $this->assertSame('按原表重量段计费,面单另计', $entries[0]->change->quote_payload['billing_rules']); + $this->assertSame('超长件按实际重量计费', $entries[1]->change->quote_payload['billing_rules']); + $this->assertSame('2025-11-01', $entries[1]->change->effective_date->toDateString()); + $this->assertEquals(5, $entries[0]->sourceQuote()['values'][1][1]); + $this->assertEquals(6, $entries[1]->sourceQuote()['values'][1][1]); + $this->assertSame($entries[0]->files[0]['path'], $entries[1]->files[0]['path']); + $this->assertSame(CourierQuoteChange::STATUS_DRAFT, $entries[0]->change->status); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('已录入'); + // 同一预览重发只返回原结果,不生成重复记录。 + $this->post($url, ['selection_json' => json_encode($selection)])->assertRedirect(); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); + } + + public function test_bulk_submit_requires_confirmation_and_does_not_approve_or_fake_signatures(): void + { + $batch = $this->preview(['EXP-001', 'EXP-002']); + $selection = [...$this->selection(['EXP-001', 'EXP-002']), 'submit_for_review' => true, 'confirmed' => false]; + $url = route('business.courier-stock-quotes.batch-save', $batch); + $this->withExceptionHandling()->loginAs()->post($url, ['selection_json' => json_encode($selection)])->assertSessionHasErrors('confirmed'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $selection['confirmed'] = true; + $this->post($url, ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors()->assertRedirect(); + $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all()); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + $this->assertDatabaseCount('business_courier_quote_signatures', 0); + } + + public function test_row_errors_are_atomic_keep_preview_and_do_not_override_existing_quotes(): void + { + $batch = $this->preview(['EXP-001', 'EXP-002']); + $selection = $this->selection(['EXP-001', 'EXP-001']); + $this->withExceptionHandling()->loginAs()->from(route('business.courier-stock-quotes.batch', $batch)) + ->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)]) + ->assertSessionHasErrors('entries.1')->assertSessionHasInput('selection_json'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $selection['entries'][1]['courier_expcode'] = 'EXP-002'; + $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors(); + $another = $this->preview(['EXP-001']); + $this->post(route('business.courier-stock-quotes.batch-save', $another), ['selection_json' => json_encode($this->selection(['EXP-001']))]) + ->assertSessionHasErrors('entries.0'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); + $this->get(route('business.courier-stock-quotes.batch', $another))->assertOk()->assertSeeText('已录入,默认跳过'); + } + + public function test_real_date_and_known_courier_are_required_but_notes_can_be_skipped(): void + { + $batch = $this->preview(['EXP-001', '说明']); + $selection = $this->selection(['EXP-001', 'UNKNOWN']); + $selection['effective_date'] = ''; + $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)]) + ->assertSessionHasErrors(['entries.0', 'entries.1']); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $selection['entries'][0]['effective_date'] = '2024-06-01'; + $selection['entries'][1]['selected'] = false; + $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors(); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 1); + } + + public function test_preview_is_scoped_to_company_and_uploader_and_rejects_fake_sheet_indices(): void + { + $batch = $this->preview(['EXP-001']); + $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id); + $manifestPath = dirname($manifest['source_file']['path']) . '/manifest.json'; + $selection = $this->selection(['EXP-001']); + $selection['entries'] = [50 => $selection['entries'][0]]; + $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)]) + ->assertSessionHasErrors('entries.50'); + $manifest['user_id'] = (int) $this->user->id + 1; + Storage::disk('uploads')->put($manifestPath, json_encode($manifest)); + $this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound(); + $manifest['user_id'] = (int) $this->user->id; + $manifest['company_id'] = (int) company_id() + 1; + Storage::disk('uploads')->put($manifestPath, json_encode($manifest)); + $this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound(); + } + + public function test_one_hundred_twenty_sheets_preview_and_save_as_one_batch(): void + { + $couriers = $names = []; + for ($index = 1; $index <= 120; $index++) { + $names[] = $code = sprintf('BULK-%03d', $index); + $couriers[] = ['code' => $code, 'name' => '批量网点' . $index]; + } + $this->mirror($couriers); + $batch = $this->preview($names); + $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120'); + $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($this->selection($names))])->assertRedirect(); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 120); + $this->assertDatabaseCount('business_courier_quote_changes', 120); + $this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique()); + } + + public function test_empty_sheet_can_be_skipped_and_failed_result_storage_rolls_back_the_entire_batch(): void + { + $batch = $this->preview(['EXP-001', '空表']); + $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('空工作表,请取消选择'); + $selection = $this->selection(['EXP-001', 'EXP-002']); + $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)]) + ->assertSessionHasErrors('entries.1'); + $selection['entries'][1]['selected'] = false; + $disk = Storage::disk('uploads'); + $failingDisk = \Mockery::mock($disk); + $failingDisk->shouldReceive('put')->once()->andReturn(false); + Storage::shouldReceive('disk')->with('uploads')->andReturn($failingDisk); + $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)]) + ->assertSessionHasErrors('source_quote'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $this->assertDatabaseCount('business_courier_quote_changes', 0); + } + + private function preview(array $names): string + { + $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ + 'source_quote' => $this->workbook($names), 'quote_type' => '标准快递报价', + 'effective_date' => '2025-12-01', 'billing_rules' => '按原表重量段计费,面单另计', + ])->assertRedirect(); + + return basename($response->headers->get('Location')); + } + + private function selection(array $codes): array + { + return ['quote_type' => '标准快递报价', 'effective_date' => '2025-12-01', 'summary' => '整本报价历史补录', + 'rule_groups' => [['name' => '共用规则', 'rules' => '按原表重量段计费,面单另计']], + 'submit_for_review' => false, 'confirmed' => false, + 'entries' => array_map(fn ($code) => ['selected' => true, 'courier_expcode' => $code, 'rule_group' => 0, 'effective_date' => ''], $codes), + ]; + } + + private function workbook(array $names): UploadedFile + { + $book = new Spreadsheet(); + foreach ($names as $index => $name) { + $sheet = ($index === 0 ? $book->getActiveSheet() : $book->createSheet())->setTitle($name); + if ($name === '空表') { + continue; + } + $sheet->fromArray([['省份', '首重', '续重'], ['江苏省', 5 + $index, null]]); + $sheet->setCellValue('C2', '=B2/2'); + } + $path = tempnam(sys_get_temp_dir(), 'batch-quote-'); + $this->temporaryFiles[] = $path; + IOFactory::createWriter($book, 'Xlsx')->save($path); + $book->disconnectWorksheets(); + + return new UploadedFile($path, '整本存量报价.xlsx', null, null, true); + } + + private function mirror(array $couriers): void + { + $mirror = \Mockery::mock(CourierInformationMirrorRepository::class); + $mirror->shouldReceive('overviewSource')->andReturn(['express' => array_map(fn ($courier) => (object) ['expcode' => $courier['code'], 'expname' => $courier['name']], $couriers)]); + app()->instance(CourierInformationMirrorRepository::class, $mirror); + } +} diff --git a/tests/Feature/Business/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php index 06e605a1..10340454 100644 --- a/tests/Feature/Business/CourierStockQuoteEntryTest.php +++ b/tests/Feature/Business/CourierStockQuoteEntryTest.php @@ -46,7 +46,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.create'))->assertOk() + $this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk() ->assertSeeText('EXP-001')->assertSeeText('EXP-002')->assertSeeText('实际开始执行日期'); $data = $this->payload(); $originalHash = hash_file('sha256', $data['source_quote']->getRealPath()); -- 2.47.2 From 5cb8e3e3119d715188c4953722ae8575fcdf0bc3 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 16 Oct 2026 14:26:52 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(=E6=8A=A5=E4=BB=B7=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E9=9A=8F=E6=95=B4=E6=9C=AC=E6=8A=A5=E4=BB=B7=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=BD=92=E6=A1=A3=E5=B7=A5=E4=BD=9C=E8=A1=A8=E7=AD=BE?= =?UTF-8?q?=E7=BD=B2=E5=8E=9F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierStockQuoteEntries.php | 28 +++- app/Support/CourierStockQuoteBatchService.php | 35 ++-- app/Support/CourierStockQuoteEntryService.php | 10 +- app/Support/CourierStockQuoteImageArchive.php | 59 +++++++ .../partials/stock-quote-review.blade.php | 6 + .../stock-quote-batch.blade.php | 26 ++- routes/admin.php | 1 + .../Business/CourierStockQuoteBatchTest.php | 154 +++++++++++++++++- 8 files changed, 291 insertions(+), 28 deletions(-) create mode 100644 app/Support/CourierStockQuoteImageArchive.php diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index ccdc33fa..318353d8 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -120,13 +120,35 @@ class CourierStockQuoteEntries extends Controller return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。'); } - public function download(CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse + public function batchImage(Request $request, string $batch, int $sheet, int $file): StreamedResponse + { + $manifest = $this->batches->load($batch, (int) user_id()); + $attachment = $manifest['sheets'][$sheet]['signed_images'][$file] ?? null; + + return $this->attachmentResponse($attachment, $request->boolean('preview')); + } + + public function download(Request $request, CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse { abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404); $attachment = $stockQuoteEntry->files[$file] ?? null; - abort_if($attachment === null || ! Storage::disk('uploads')->exists($attachment['path']), 404, '附件不存在。'); - return Storage::disk('uploads')->download($attachment['path'], $attachment['name']); + return $this->attachmentResponse($attachment, $request->boolean('preview')); + } + + private function attachmentResponse(?array $attachment, bool $preview): StreamedResponse + { + $disk = Storage::disk('uploads'); + abort_if($attachment === null || ! $disk->exists($attachment['path']), 404, '附件不存在。'); + $headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']; + if ($preview) { + $mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']); + abort_unless(in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。'); + + return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]); + } + + return $disk->download($attachment['path'], $attachment['name'], $headers); } private function metadataRules(): array diff --git a/app/Support/CourierStockQuoteBatchService.php b/app/Support/CourierStockQuoteBatchService.php index 057a4c06..be2160cf 100644 --- a/app/Support/CourierStockQuoteBatchService.php +++ b/app/Support/CourierStockQuoteBatchService.php @@ -29,19 +29,27 @@ final class CourierStockQuoteBatchService public function preview(UploadedFile $file, array $defaults, int $userId): string { - $sheets = $this->entries->readBatchWorkbook($file); $batch = (string) Str::uuid(); $disk = Storage::disk('uploads'); - $originalPath = $disk->putFileAs($this->directory($batch, $userId), $file, 'original.' . $file->getClientOriginalExtension(), 'private'); - if ($originalPath === false) { - throw ValidationException::withMessages(['source_quote' => '报价原文件保存失败,请重试。']); + $savedPaths = []; + try { + $originalPath = $disk->putFileAs($this->directory($batch, $userId), $file, 'original.' . $file->getClientOriginalExtension(), 'private'); + if ($originalPath === false) { + throw ValidationException::withMessages(['source_quote' => '报价原文件保存失败,请重试。']); + } + $savedPaths[] = $originalPath; + $sheets = $this->entries->readBatchWorkbook($file, $this->directory($batch, $userId) . '/images', $savedPaths); + $manifest = [ + 'company_id' => (int) company_id(), 'user_id' => $userId, 'created_at' => now()->toIso8601String(), + 'defaults' => $defaults, 'sheets' => $sheets, 'result' => null, + 'source_file' => ['kind' => 'source_quote', 'path' => $originalPath, 'name' => $file->getClientOriginalName(), 'sha256' => hash_file('sha256', $file->getRealPath())], + ]; + $savedPaths[] = $this->directory($batch, $userId) . '/manifest.json'; + $this->write($batch, $userId, $manifest); + } catch (\Throwable $exception) { + $disk->delete($savedPaths); + throw $exception; } - $manifest = [ - 'company_id' => (int) company_id(), 'user_id' => $userId, 'created_at' => now()->toIso8601String(), - 'defaults' => $defaults, 'sheets' => $sheets, 'result' => null, - 'source_file' => ['kind' => 'source_quote', 'path' => $originalPath, 'name' => $file->getClientOriginalName(), 'sha256' => hash_file('sha256', $file->getRealPath())], - ]; - $this->write($batch, $userId, $manifest); return $batch; } @@ -107,6 +115,11 @@ final class CourierStockQuoteBatchService $reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null); $reason ??= $courier === null ? '请选择已有快递资料中的唯一编号。' : null; $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; @@ -125,7 +138,7 @@ final class CourierStockQuoteBatchService return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array { $created = []; foreach ($prepared as $item) { - $entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file']]); + $entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file'], ...($item['sheet']['signed_images'] ?? [])]); if ($data['submit_for_review'] ?? false) { $this->changes->submit($entry->change, $userId); } diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php index a38acfc0..9e593a01 100644 --- a/app/Support/CourierStockQuoteEntryService.php +++ b/app/Support/CourierStockQuoteEntryService.php @@ -79,7 +79,7 @@ final class CourierStockQuoteEntryService } /** 一次加载整本文件;各工作表保持独立,不计算公式。 */ - public function readBatchWorkbook(UploadedFile $file): array + public function readBatchWorkbook(UploadedFile $file, string $imageDirectory, array &$savedPaths): array { $book = null; try { @@ -99,14 +99,16 @@ final class CourierStockQuoteEntryService throw ValidationException::withMessages(['source_quote' => '整本报价最多 500000 个单元格,请分批上传。']); } $reader->setReadDataOnly(false); + $reader->setAllowExternalImages(false); $book = $reader->load($file->getRealPath()); $sheets = []; - foreach ($book->getWorksheetIterator() as $sheet) { + foreach ($book->getWorksheetIterator() as $index => $sheet) { try { - $sheets[] = [...$this->readSheet($sheet), 'error' => null]; + $matrix = [...$this->readSheet($sheet), 'error' => null]; } catch (ValidationException $exception) { - $sheets[] = ['sheet_name' => $sheet->getTitle(), 'error' => '空工作表,请取消选择。']; + $matrix = ['sheet_name' => $sheet->getTitle(), 'error' => '空工作表,请取消选择。']; } + $sheets[] = [...$matrix, ...app(CourierStockQuoteImageArchive::class)->store($sheet, $file->getRealPath(), $imageDirectory . '/' . $index, $savedPaths)]; } return $sheets; diff --git a/app/Support/CourierStockQuoteImageArchive.php b/app/Support/CourierStockQuoteImageArchive.php new file mode 100644 index 00000000..2bd7d1a5 --- /dev/null +++ b/app/Support/CourierStockQuoteImageArchive.php @@ -0,0 +1,59 @@ +getDrawingCollection() as $index => $drawing) { + if (! $drawing instanceof Drawing || ! str_starts_with($drawing->getPath(), $prefix)) { + $warnings[] = '图片 ' . ($index + 1) . ' 无法无损提取,请另附原图;整本 Excel 已保留。'; + continue; + } + $stream = @fopen($drawing->getPath(), 'rb'); + if ($stream === false) { + $this->failed($sheet); + } + try { + $bytes = stream_get_contents($stream, 20 * 1024 * 1024 + 1); + } finally { + fclose($stream); + } + if ($bytes === false || $bytes === '' || strlen($bytes) > 20 * 1024 * 1024) { + $this->failed($sheet); + } + $mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes); + $extension = strtolower(pathinfo($drawing->getPath(), PATHINFO_EXTENSION)); + // 不支持浏览器预览的原始格式仍按原字节留存,只提供下载。 + $extension = preg_match('/^[a-z0-9]{1,8}$/', $extension) ? $extension : 'bin'; + $name = $sheet->getTitle() . '-原件图片-' . ($index + 1) . '.' . $extension; + $path = $directory . '/' . ($index + 1) . '.' . $extension; + $savedPaths[] = $path; + if (! Storage::disk('uploads')->put($path, $bytes, 'private')) { + $this->failed($sheet); + } + $files[] = [ + 'kind' => 'signed', 'path' => $path, 'name' => $name, 'sha256' => hash('sha256', $bytes), + 'mime_type' => $mime, 'size' => strlen($bytes), 'sheet_name' => $sheet->getTitle(), + 'anchor' => $drawing->getCoordinates(), 'image_index' => $index + 1, + 'source' => 'workbook_embedded', 'archived_at' => now()->toIso8601String(), + ]; + } + + return ['signed_images' => $files, 'image_warnings' => array_values(array_unique($warnings))]; + } + + private function failed(Worksheet $sheet): never + { + throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet->getTitle() . '」原件图片提取或存档失败(每张最多 20 MB),本次上传未完成,请重试。']); + } +} 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 1fa80e88..f01ae4ea 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 @@ -8,8 +8,14 @@
@foreach($stockEntry->files as $index => $file) {{ ['source_quote' => '原报价', 'previous_source_quote' => '历次原报价', 'confirmation' => '报价依据', 'signed' => '历史签署资料'][$file['kind']] }} · {{ $file['name'] }} + @if(in_array($file['mime_type'] ?? '', ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true)) + 查看原图 · {{ $file['sheet_name'] ?? $file['name'] }} · {{ $file['anchor'] ?? '' }} + @endif @endforeach
+ @if(collect($stockEntry->files)->contains('source', 'workbook_embedded')) +

工作表内嵌图片已按原始字节独立留存,未裁剪、未压缩。提取成功不代表已核对一致;后续调价仍保留本版原件。

+ @endif @if($canReview && $change->status === 'pending_review')
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 e0d95395..a9e86ce6 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 @@ -73,7 +73,7 @@
@endif @if(!$batch) -

一个 Excel,每家一张工作表。整本上传一次,共用计费规则只填一次,各家的报价金额仍以各自原表为准。

+

一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。

@csrf
@@ -83,6 +83,7 @@ 选择包含全部快递报价的 Excel

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

+

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

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

@@ -103,7 +104,7 @@ @elseif($result)
已录入 {{ count($result['created']) }} 家报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},跳过 {{ $result['skipped'] }} 张工作表。原 Excel 已留存,各家报价独立管理。
-

{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}历史签署资料仍须按家补充、核验。

+

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

本批录入结果

@@ -142,12 +143,12 @@

-

输入快递编号即可选择;名称相同的网点按各自编号区分。说明页、重复报价可取消勾选。展开“原文”可查看各表前 8 行、前 10 列。

+

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

@foreach($couriers as $courier)@endforeach
原工作表快递及编号查看
- + @foreach($rows as $index => $row) @php($choice = $previous['entries'][$index] ?? []) @@ -157,6 +158,23 @@ + @endforeach diff --git a/routes/admin.php b/routes/admin.php index a00e9d91..e9ac2459 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -132,6 +132,7 @@ Route::prefix('workspace/courier-payables/quote-management/stock-entries') Route::get('single', [CourierStockQuoteEntries::class, 'single'])->name('single'); 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'); Route::post('batches/{batch}', [CourierStockQuoteEntries::class, 'saveBatch'])->name('batch-save'); Route::post('/', [CourierStockQuoteEntries::class, 'store'])->name('store'); Route::get('{stockQuoteEntry}/edit', [CourierStockQuoteEntries::class, 'edit'])->name('edit'); diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php index f77a9ca1..1dc819cf 100644 --- a/tests/Feature/Business/CourierStockQuoteBatchTest.php +++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php @@ -6,18 +6,22 @@ use App\Models\Auth\Permission; use App\Models\Business\CourierQuoteChange; use App\Models\Business\CourierStockQuoteEntry; use App\Support\CourierStockQuoteBatchService; +use App\Support\CourierQuoteChangeService; use App\Support\QuoteManagementSnapshot; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Drawing; use Tests\Feature\FeatureTestCase; class CourierStockQuoteBatchTest extends FeatureTestCase { private array $temporaryFiles = []; + private array $sourceImages = []; + protected function setUp(): void { parent::setUp(); @@ -154,12 +158,13 @@ class CourierStockQuoteBatchTest extends FeatureTestCase $couriers[] = ['code' => $code, 'name' => '批量网点' . $index]; } $this->mirror($couriers); - $batch = $this->preview($names); + $batch = $this->preview($names, array_fill(0, 120, 1)); $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120'); $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($this->selection($names))])->assertRedirect(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 120); $this->assertDatabaseCount('business_courier_quote_changes', 120); $this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique()); + $this->assertCount(120, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[1]['path'])->unique()); } public function test_empty_sheet_can_be_skipped_and_failed_result_storage_rolls_back_the_entire_batch(): void @@ -180,10 +185,135 @@ class CourierStockQuoteBatchTest extends FeatureTestCase $this->assertDatabaseCount('business_courier_quote_changes', 0); } - private function preview(array $names): string + public function test_embedded_images_are_archived_byte_for_byte_per_sheet_and_never_auto_verified(): void + { + $file = $this->workbook(['EXP-001', 'EXP-002'], [2, 1]); + $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ + 'source_quote' => $file, 'quote_type' => '标准快递报价', + ])->assertSessionHasNoErrors()->assertRedirect(); + $batch = basename($response->headers->get('Location')); + $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id); + $this->assertCount(2, $manifest['sheets'][0]['signed_images']); + $this->assertCount(1, $manifest['sheets'][1]['signed_images']); + foreach ($manifest['sheets'] as $sheetIndex => $sheet) { + foreach ($sheet['signed_images'] as $imageIndex => $image) { + $this->assertSame($this->sourceImages[$sheetIndex][$imageIndex], Storage::disk('uploads')->get($image['path'])); + $this->assertSame(hash('sha256', $this->sourceImages[$sheetIndex][$imageIndex]), $image['sha256']); + $this->assertSame($sheet['sheet_name'], $image['sheet_name']); + $this->assertSame('G' . (2 + $imageIndex * 20), $image['anchor']); + $this->assertSame('image/png', $image['mime_type']); + $url = route('business.courier-stock-quotes.batch-image', [$batch, $sheetIndex, $imageIndex]); + $download = $this->get($url)->assertOk()->assertDownload()->assertStreamedContent($this->sourceImages[$sheetIndex][$imageIndex]); + $this->assertStringContainsString(rawurlencode($image['name']), $download->headers->get('Content-Disposition')); + $this->get($url . '?preview=1')->assertOk()->assertHeader('Content-Type', 'image/png') + ->assertHeader('X-Content-Type-Options', 'nosniff')->assertStreamedContent($this->sourceImages[$sheetIndex][$imageIndex]); + } + } + $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('已留存 2 张 · 待核对'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $selection = [...$this->selection(['EXP-001', 'EXP-002']), 'submit_for_review' => true, 'confirmed' => true]; + $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors(); + $entries = CourierStockQuoteEntry::orderBy('id')->get(); + $this->assertSame([3, 2], $entries->map(fn ($entry) => count($entry->files))->all()); + $this->assertSame($manifest['sheets'][0]['signed_images'][0]['path'], $entries[0]->files[1]['path']); + $this->assertSame($manifest['sheets'][1]['signed_images'][0]['path'], $entries[1]->files[1]['path']); + $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all()); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + $this->assertDatabaseCount('business_courier_quote_signatures', 0); + $this->get(route('business.courier-quote-changes.show', $entries[0]->change))->assertOk()->assertSeeText('查看原图'); + $this->get(route('business.courier-stock-quotes.download', [$entries[0], 1, 'preview' => 1]))->assertOk() + ->assertStreamedContent($this->sourceImages[0][0]); + } + + public function test_image_storage_failure_does_not_publish_a_preview_or_create_quotes(): void + { + $file = $this->workbook(['EXP-001'], [1]); + $disk = Storage::disk('uploads'); + $failingDisk = \Mockery::mock($disk); + $failingDisk->shouldReceive('put')->withArgs(fn ($path) => str_contains($path, '/images/'))->once()->andReturn(false); + Storage::shouldReceive('disk')->with('uploads')->andReturn($failingDisk); + $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ + 'source_quote' => $file, 'quote_type' => '标准快递报价', + ])->assertSessionHasErrors('source_quote'); + $this->assertSame([], $disk->allFiles()); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + } + + public function test_missing_archived_image_blocks_the_entire_save_and_image_routes_are_private(): void { $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ - 'source_quote' => $this->workbook($names), 'quote_type' => '标准快递报价', + 'source_quote' => $this->workbook(['EXP-001', 'EXP-002'], [1, 1]), 'quote_type' => '标准快递报价', + ])->assertRedirect(); + $batch = basename($response->headers->get('Location')); + $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id); + Storage::disk('uploads')->delete($manifest['sheets'][1]['signed_images'][0]['path']); + $this->withExceptionHandling()->post(route('business.courier-stock-quotes.batch-save', $batch), [ + 'selection_json' => json_encode($this->selection(['EXP-001', 'EXP-002'])), + ])->assertSessionHasErrors('entries.1'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $url = route('business.courier-stock-quotes.batch-image', [$batch, 0, 0, 'preview' => 1]); + $this->get(route('business.courier-stock-quotes.batch-image', [$batch, 0, 99]), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound(); + $manifestPath = dirname($manifest['source_file']['path']) . '/manifest.json'; + $manifest['user_id']++; + Storage::disk('uploads')->put($manifestPath, json_encode($manifest)); + $this->get($url, ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound(); + $manifest['user_id']--; + $manifest['company_id']++; + Storage::disk('uploads')->put($manifestPath, json_encode($manifest)); + $this->get($url, ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound(); + } + + public function test_followup_price_change_keeps_all_original_images_and_unverified_images_do_not_activate_quote(): void + { + $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ + 'source_quote' => $this->workbook(['EXP-001'], [2]), 'quote_type' => '标准快递报价', + ])->assertRedirect(); + $batch = basename($response->headers->get('Location')); + $this->post(route('business.courier-stock-quotes.batch-save', $batch), [ + 'selection_json' => json_encode([...$this->selection(['EXP-001']), 'submit_for_review' => true, 'confirmed' => true]), + ])->assertSessionHasNoErrors(); + $entry = CourierStockQuoteEntry::firstOrFail(); + $reviewer = user_model_class()::create([ + 'name' => '原件核验员', 'email' => 'image-reviewer@example.com', 'password' => 'local-test-password', + 'locale' => 'zh-CN', 'enabled' => 1, 'landing_page' => 'dashboard', 'created_from' => 'core::test', + ]); + $reviewer->companies()->attach($this->company->id); + $changes = app(CourierQuoteChangeService::class); + $approved = $changes->approve($entry->change, (int) $reviewer->id); + $this->assertSame('待确认/待补签', $approved->version->signature_status); + $change = $changes->create([ + 'source_quote_id' => $approved->version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16', + 'summary' => '基于存量版本调价,原件继续保留', + 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]], + ], (int) $this->user->id); + $changes->submit($change, (int) $this->user->id); + $changes->approve($change->fresh(), (int) $reviewer->id); + $this->assertCount(3, $entry->fresh()->files); + foreach ([1, 2] as $index) { + $this->get(route('business.courier-stock-quotes.download', [$entry, $index]))->assertOk() + ->assertStreamedContent($this->sourceImages[0][$index - 1]); + } + \DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]); + $this->withExceptionHandling()->get(route('business.courier-stock-quotes.download', [$entry, 1, 'preview' => 1]), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound(); + } + + public function test_legacy_xls_images_are_not_reencoded_and_falsely_labeled_originals(): void + { + $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ + 'source_quote' => $this->workbook(['EXP-001'], [1], 'Xls'), 'quote_type' => '标准快递报价', + ])->assertSessionHasNoErrors()->assertRedirect(); + $batch = basename($response->headers->get('Location')); + $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id); + $this->assertSame([], $manifest['sheets'][0]['signed_images']); + $this->assertNotEmpty($manifest['sheets'][0]['image_warnings']); + Storage::disk('uploads')->assertExists($manifest['source_file']['path']); + $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('无法无损提取'); + } + + private function preview(array $names, array $imageCounts = []): string + { + $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ + 'source_quote' => $this->workbook($names, $imageCounts), 'quote_type' => '标准快递报价', 'effective_date' => '2025-12-01', 'billing_rules' => '按原表重量段计费,面单另计', ])->assertRedirect(); @@ -199,7 +329,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase ]; } - private function workbook(array $names): UploadedFile + private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx'): UploadedFile { $book = new Spreadsheet(); foreach ($names as $index => $name) { @@ -209,13 +339,25 @@ class CourierStockQuoteBatchTest extends FeatureTestCase } $sheet->fromArray([['省份', '首重', '续重'], ['江苏省', 5 + $index, null]]); $sheet->setCellValue('C2', '=B2/2'); + for ($imageIndex = 0; $imageIndex < ($imageCounts[$index] ?? 0); $imageIndex++) { + $image = imagecreatetruecolor(24 + $index, 32 + $imageIndex); + imagefill($image, 0, 0, imagecolorallocate($image, (80 + $index * 20) % 256, 50, (30 + $imageIndex * 20) % 256)); + $imagePath = tempnam(sys_get_temp_dir(), 'quote-image-'); + rename($imagePath, $imagePath . '.png'); + $imagePath .= '.png'; + $this->temporaryFiles[] = $imagePath; + imagepng($image, $imagePath); + imagedestroy($image); + $this->sourceImages[$index][$imageIndex] = file_get_contents($imagePath); + (new Drawing())->setPath($imagePath)->setCoordinates('G' . (2 + $imageIndex * 20))->setWorksheet($sheet); + } } $path = tempnam(sys_get_temp_dir(), 'batch-quote-'); $this->temporaryFiles[] = $path; - IOFactory::createWriter($book, 'Xlsx')->save($path); + IOFactory::createWriter($book, $format)->save($path); $book->disconnectWorksheets(); - return new UploadedFile($path, '整本存量报价.xlsx', null, null, true); + return new UploadedFile($path, '整本存量报价.' . strtolower($format), null, null, true); } private function mirror(array $couriers): void -- 2.47.2
录入原工作表关联快递编号计费规则执行日期报价原文
录入原工作表关联快递编号计费规则执行日期签署原件图片报价原文
{{ $row['match_status'] }}
留空沿用共用日期 + @if(count($row['signed_images'] ?? [])) +
已留存 {{ count($row['signed_images']) }} 张 · 待核对 + @foreach($row['signed_images'] as $imageIndex => $image) +

图片 {{ $imageIndex + 1 }} · 原位置 {{ $image['anchor'] }} + @if(in_array($image['mime_type'] ?? '', ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true)) + 查看 + @endif + 下载原图 +

+ @endforeach +
+ @else + 未提取到图片,待补原件 + @endif + @foreach($row['image_warnings'] ?? [] as $warning)

{{ $warning }}

@endforeach +
@if(!$row['error'])
原文@foreach(array_slice($row['values'], 0, 8) as $valueRow)@foreach(array_slice($valueRow, 0, 10) as $value)@endforeach@endforeach
{{ $value }}
@else{{ $row['error'] }}@endif