diff --git a/app/Http/Controllers/Business/CourierQuoteChanges.php b/app/Http/Controllers/Business/CourierQuoteChanges.php new file mode 100644 index 00000000..8467806a --- /dev/null +++ b/app/Http/Controllers/Business/CourierQuoteChanges.php @@ -0,0 +1,249 @@ +with(['items', 'version']) + ->latest('updated_at') + ->paginate(20); + + return view('business.pages.courier-payables.quote-change-history', [ + 'changes' => $changes, + 'canReview' => $this->canReview(), + ]); + } + + public function create(Request $request): View + { + $quotes = $this->snapshot->load()['quotes'] ?? []; + abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。'); + + $selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']); + $source = $this->service->findSourceQuote($selectedId); + + return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source)); + } + + public function store(Request $request): RedirectResponse + { + $change = $this->service->create($this->validatedChange($request, true), user_id()); + + return redirect()->route('business.courier-quote-changes.edit', $change) + ->with('success', "已保存变更草稿 {$change->number}。原始报价未被修改。"); + } + + public function show(CourierQuoteChange $quoteChange): View + { + $quoteChange->load(['items', 'events.actor', 'version.signature']); + + return view('business.pages.courier-payables.quote-change-show', [ + 'change' => $quoteChange, + 'canReview' => $this->canReview() && $quoteChange->created_by !== user_id(), + 'canManageSignature' => $this->canReview(), + 'esignConfigured' => EsignClient::configured(), + 'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'), + ]); + } + + public function edit(CourierQuoteChange $quoteChange): View + { + abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403); + $quotes = $this->snapshot->load()['quotes'] ?? []; + $source = $this->service->findSourceQuote($quoteChange->source_quote_id); + $quoteChange->load('items'); + + return view('business.pages.courier-payables.quote-change-editor', $this->editorData($quoteChange, $quotes, $source)); + } + + public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse + { + $change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id()); + + return redirect()->route('business.courier-quote-changes.edit', $change) + ->with('success', "已保存变更草稿 {$change->number}。"); + } + + public function submit(CourierQuoteChange $quoteChange): RedirectResponse + { + $change = $this->service->submit($quoteChange, user_id()); + + return redirect()->route('business.courier-quote-changes.show', $change) + ->with('success', "{$change->number} 已提交审核。"); + } + + public function approve(CourierQuoteChange $quoteChange): RedirectResponse + { + abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + $change = $this->service->approve($quoteChange, user_id()); + + $message = "审核完成,已生成新报价版本 {$change->version->version_code}。"; + + try { + $this->signatureService->prepareVersion($change->version, user_id()); + $message .= ' 签署 PDF 已生成并固定校验值。'; + } catch (\Throwable $exception) { + report($exception); + $message .= ' 签署 PDF 生成失败,可在详情页重试。'; + } + + if ($change->isRetrospective()) { + $message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。'; + } + + return redirect()->route('business.courier-quote-changes.show', $change) + ->with('success', $message); + } + + public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse + { + abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + $validated = $request->validate([ + 'review_note' => ['required', 'string', 'min:2', 'max:1000'], + ]); + $change = $this->service->returnForChanges($quoteChange, $validated['review_note'], user_id()); + + return redirect()->route('business.courier-quote-changes.show', $change) + ->with('success', "{$change->number} 已退回发起人修改。"); + } + + private function validatedChange(Request $request, bool $requiresSource): array + { + $rules = [ + 'change_type' => ['required', 'in:费率调整,面单调整,规则备注,执行日期,其他'], + 'effective_date' => ['required', 'date_format:Y-m-d'], + 'summary' => ['required', 'string', 'min:2', 'max:2000'], + 'items' => ['required', 'array', 'min:1', 'max:500'], + 'items.*.source_cell' => ['required', 'regex:/^[A-Z]+[1-9][0-9]*$/'], + 'items.*.source_row' => ['required', 'integer', 'min:0'], + 'items.*.source_column' => ['required', 'integer', 'min:0'], + 'items.*.region' => ['nullable', 'string', 'max:255'], + 'items.*.weight_band' => ['nullable', 'string', 'max:255'], + 'items.*.charge_item' => ['nullable', 'string', 'max:255'], + 'items.*.original_amount' => ['required', 'numeric', 'min:0', 'decimal:0,4'], + 'items.*.new_amount' => ['required', 'numeric', 'min:0', 'decimal:0,4'], + 'items.*.unit' => ['nullable', 'string', 'max:32'], + 'items.*.note' => ['nullable', 'string', 'max:500'], + ]; + + if ($requiresSource) { + $rules['source_quote_id'] = ['required', 'string', 'max:32']; + } + + return $request->validate($rules); + } + + private function editorData(?CourierQuoteChange $change, array $quotes, array $source): array + { + $existingItems = $change?->items->keyBy('source_cell') ?? collect(); + + return [ + 'change' => $change, + 'quotes' => $quotes, + 'source' => $source, + 'editableCells' => $this->editableCells($source, $existingItems), + ]; + } + + private function editableCells(array $source, $existingItems): array + { + $values = $source['values']; + $cells = []; + + foreach ($values as $rowIndex => $row) { + foreach ($row as $columnIndex => $value) { + if (! is_numeric($value)) { + continue; + } + + $formula = $source['formulas'][$rowIndex][$columnIndex] ?? null; + + if (is_string($formula) && trim($formula) !== '') { + continue; + } + + $cell = $this->columnName($columnIndex) . ($rowIndex + 1); + $existing = $existingItems->get($cell); + $cells[] = [ + 'source_cell' => $cell, + 'source_row' => $rowIndex, + 'source_column' => $columnIndex, + 'region' => $this->nearestText($row, $columnIndex - 1) ?? $this->nearestText($row, 0), + 'weight_band' => $this->headerText($values, $rowIndex, $columnIndex), + 'charge_item' => $this->headerText($values, $rowIndex, $columnIndex), + 'original_amount' => number_format((float) $value, 4, '.', ''), + 'new_amount' => $existing?->new_amount ?? number_format((float) $value, 4, '.', ''), + 'unit' => $existing?->unit, + 'note' => $existing?->note, + 'selected' => $existing !== null, + ]; + } + } + + return $cells; + } + + private function headerText(array $values, int $row, int $column): ?string + { + for ($index = $row - 1; $index >= 0; $index--) { + $candidate = $values[$index][$column] ?? null; + + if (is_string($candidate) && trim($candidate) !== '') { + return trim($candidate); + } + } + + return null; + } + + private function nearestText(array $row, int $column): ?string + { + for ($index = $column; $index >= 0; $index--) { + $candidate = $row[$index] ?? null; + + if (is_string($candidate) && trim($candidate) !== '') { + return trim($candidate); + } + } + + return null; + } + + private function columnName(int $column): string + { + $name = ''; + + do { + $name = chr(65 + ($column % 26)) . $name; + $column = intdiv($column, 26) - 1; + } while ($column >= 0); + + return $name; + } + + private function canReview(): bool + { + return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true); + } +} diff --git a/app/Http/Controllers/Business/CourierQuoteSignatures.php b/app/Http/Controllers/Business/CourierQuoteSignatures.php new file mode 100644 index 00000000..98fb9a7d --- /dev/null +++ b/app/Http/Controllers/Business/CourierQuoteSignatures.php @@ -0,0 +1,100 @@ +ensureCanManage(); + abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。'); + $signature = $this->service->prepareVersion($quoteVersion, user_id()); + + return redirect()->route('business.courier-quote-changes.show', $quoteVersion->source_change_id) + ->with('success', "{$quoteVersion->version_code} 的签署 PDF 已生成,文件校验值已固定。") + ->with('signature_id', $signature->id); + } + + public function send(Request $request, CourierQuoteSignature $quoteSignature): RedirectResponse + { + $this->ensureCanManage(); + abort_unless($quoteSignature->version?->status === 'approved', 422, '只有已审核报价版本可以发起签署。'); + abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。'); + $data = $request->validate([ + 'organization_name' => ['required', 'string', 'min:2', 'max:255'], + 'operator_name' => ['required', 'string', 'min:2', 'max:100'], + 'operator_account' => ['required', 'string', 'max:255'], + 'notice_type' => ['required', 'in:1,2'], + 'expires_at' => ['required', 'date', 'after:now', 'before_or_equal:' . now()->addDays(90)->toDateTimeString()], + ]); + $this->validateAccountForNotice($data['operator_account'], $data['notice_type']); + $signature = $this->service->send($quoteSignature, $data, user_id()); + $environment = $signature->environment === 'sandbox' ? '沙箱' : '正式环境'; + + return redirect()->route('business.courier-quote-changes.show', $signature->version->source_change_id) + ->with('success', "{$signature->version->version_code} 已推送到 E 签宝{$environment},等待快递公司签署。"); + } + + public function synchronize(CourierQuoteSignature $quoteSignature): RedirectResponse + { + $this->ensureCanManage(); + $signature = $this->service->synchronize($quoteSignature, user_id()); + $message = $signature->status === CourierQuoteSignature::STATUS_ARCHIVED + ? '签署完成,签署 PDF 和完成证书已私有归档。' + : "已同步 E 签宝状态:{$signature->statusLabel()}。"; + + return redirect()->route('business.courier-quote-changes.show', $signature->version->source_change_id) + ->with('success', $message); + } + + public function download(CourierQuoteSignature $quoteSignature, string $kind): StreamedResponse + { + [$path, $suffix] = match ($kind) { + 'source' => [$quoteSignature->source_pdf_path, '报价签署原文'], + 'signed' => [$quoteSignature->signed_pdf_path, '已签署报价'], + 'certificate' => [$quoteSignature->certificate_path, '签署完成证书'], + default => abort(404), + }; + + abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。'); + $fileName = $quoteSignature->version->version_code . '-' . $suffix . '.pdf'; + + return \Storage::disk('uploads')->download($path, $fileName, ['Content-Type' => 'application/pdf']); + } + + private function validateAccountForNotice(string $account, string $noticeType): void + { + $valid = $noticeType === '1' + ? preg_match('/^1\d{10}$/', $account) === 1 + : filter_var($account, FILTER_VALIDATE_EMAIL) !== false; + + if (! $valid) { + $message = $noticeType === '1' ? '短信通知必须填写 11 位手机号。' : '邮件通知必须填写有效邮箱。'; + throw ValidationException::withMessages(['operator_account' => $message]); + } + } + + private function ensureCanManage(): void + { + abort_unless( + in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true), + 403, + '当前账号没有报价签署管理权限。', + ); + } +} diff --git a/app/Http/Controllers/Integrations/EsignCallbacks.php b/app/Http/Controllers/Integrations/EsignCallbacks.php new file mode 100644 index 00000000..2bf8778e --- /dev/null +++ b/app/Http/Controllers/Integrations/EsignCallbacks.php @@ -0,0 +1,95 @@ +json(['code' => '503', 'msg' => 'not configured'], 503); + } + + $timestamp = trim((string) $request->header('X-Tsign-Open-TIMESTAMP')); + $algorithm = trim((string) $request->header('X-Tsign-Open-SIGNATURE-ALGORITHM')); + $callbackAppId = trim((string) $request->header('X-Tsign-Open-App-Id')); + $signature = strtolower(trim((string) $request->header('X-Tsign-Open-SIGNATURE'))); + $rawBody = $request->getContent(); + + if (! ctype_digit($timestamp) + || strcasecmp($algorithm, 'HmacSHA256') !== 0 + || ! hash_equals($appId, $callbackAppId) + || $signature === '' + || ! $this->timestampIsFresh($timestamp)) { + return response()->json(['code' => '401', 'msg' => 'invalid callback'], 401); + } + + $queryValues = $request->query(); + ksort($queryValues, SORT_STRING); + $data = $timestamp . collect($queryValues)->map(fn ($value): string => $this->queryValue($value))->implode('') . $rawBody; + $expected = strtolower(hash_hmac('sha256', $data, $appSecret)); + + if (! hash_equals($expected, $signature)) { + return response()->json(['code' => '401', 'msg' => 'invalid callback'], 401); + } + + $payload = json_decode($rawBody, true); + + if (! is_array($payload)) { + return response()->json(['code' => '400', 'msg' => 'invalid json'], 400); + } + + if (($payload['action'] ?? null) !== 'SIGN_FLOW_COMPLETE') { + return $this->success(); + } + + $signFlowId = trim((string) ($payload['signFlowId'] ?? '')); + $remoteStatus = trim((string) ($payload['signFlowStatus'] ?? '')); + + if ($signFlowId === '' || $remoteStatus === '') { + return $this->success(); + } + + $signatureRecord = $service->acceptCallback($signFlowId, $remoteStatus, hash('sha256', $rawBody)); + + if ($signatureRecord + && $remoteStatus === '2' + && $signatureRecord->status !== CourierQuoteSignature::STATUS_ARCHIVED) { + ArchiveCourierQuoteSignature::dispatchAfterResponse($signatureRecord->id); + } + + return $this->success(); + } + + private function timestampIsFresh(string $timestamp): bool + { + $seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp; + $tolerance = max(60, (int) config('services.esign.callback_tolerance_seconds', 900)); + + return abs(now()->timestamp - $seconds) <= $tolerance; + } + + private function queryValue(mixed $value): string + { + if (is_array($value)) { + return collect($value)->map(fn ($item): string => $this->queryValue($item))->implode(''); + } + + return is_scalar($value) ? (string) $value : ''; + } + + private function success(): JsonResponse + { + return response()->json(['code' => '200', 'msg' => 'success']); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index e686b035..c27aa552 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + 'integrations/esign/callback', ]; -} \ No newline at end of file +} diff --git a/app/Jobs/Business/ArchiveCourierQuoteSignature.php b/app/Jobs/Business/ArchiveCourierQuoteSignature.php new file mode 100644 index 00000000..f387dfd7 --- /dev/null +++ b/app/Jobs/Business/ArchiveCourierQuoteSignature.php @@ -0,0 +1,28 @@ +synchronizeById($this->signatureId); + } +} diff --git a/app/Models/Business/CourierQuoteChange.php b/app/Models/Business/CourierQuoteChange.php new file mode 100644 index 00000000..5ef081e9 --- /dev/null +++ b/app/Models/Business/CourierQuoteChange.php @@ -0,0 +1,82 @@ + 'integer', + 'effective_date' => 'date', + 'is_retrospective' => 'boolean', + 'submitted_at' => 'datetime', + 'approved_at' => 'datetime', + ]; + + public function items(): HasMany + { + return $this->hasMany(CourierQuoteChangeItem::class, 'quote_change_id')->orderBy('position'); + } + + public function events(): HasMany + { + return $this->hasMany(CourierQuoteChangeEvent::class, 'quote_change_id')->orderBy('created_at'); + } + + public function version(): HasOne + { + return $this->hasOne(CourierQuoteVersion::class, 'source_change_id'); + } + + public function isEditable(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true); + } + + public function isRetrospective(): bool + { + return (bool) $this->is_retrospective; + } + + public function recalculationStatusLabel(): string + { + return match ($this->recalculation_status) { + self::RECALCULATION_NOT_REQUIRED => '无需追溯', + self::RECALCULATION_PENDING => '待追溯核算', + self::RECALCULATION_BLOCKED => '核算受阻', + default => $this->recalculation_status, + }; + } + + public function statusLabel(): string + { + return match ($this->status) { + self::STATUS_DRAFT => '草稿', + self::STATUS_PENDING_REVIEW => '待审核', + self::STATUS_RETURNED => '已退回', + self::STATUS_APPROVED => '已审核', + default => $this->status, + }; + } +} diff --git a/app/Models/Business/CourierQuoteChangeEvent.php b/app/Models/Business/CourierQuoteChangeEvent.php new file mode 100644 index 00000000..3f3f7b8e --- /dev/null +++ b/app/Models/Business/CourierQuoteChangeEvent.php @@ -0,0 +1,39 @@ + 'integer']; + + public function change(): BelongsTo + { + return $this->belongsTo(CourierQuoteChange::class, 'quote_change_id'); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(user_model_class(), 'created_by'); + } + + public function eventLabel(): string + { + return match ($this->event_type) { + 'created' => '创建草稿', + 'saved' => '保存草稿', + 'submitted' => '提交审核', + 'returned' => '退回修改', + 'approved' => '审核通过', + default => $this->event_type, + }; + } +} diff --git a/app/Models/Business/CourierQuoteChangeItem.php b/app/Models/Business/CourierQuoteChangeItem.php new file mode 100644 index 00000000..5f13e08d --- /dev/null +++ b/app/Models/Business/CourierQuoteChangeItem.php @@ -0,0 +1,30 @@ + 'integer', + 'position' => 'integer', + 'source_row' => 'integer', + 'source_column' => 'integer', + 'original_amount' => 'decimal:4', + 'new_amount' => 'decimal:4', + ]; + + public function change(): BelongsTo + { + return $this->belongsTo(CourierQuoteChange::class, 'quote_change_id'); + } +} diff --git a/app/Models/Business/CourierQuoteSignature.php b/app/Models/Business/CourierQuoteSignature.php new file mode 100644 index 00000000..09474c94 --- /dev/null +++ b/app/Models/Business/CourierQuoteSignature.php @@ -0,0 +1,68 @@ + 'integer', + 'quote_version_id' => 'integer', + 'expires_at' => 'datetime', + 'source_pdf_generated_at' => 'datetime', + 'sent_at' => 'datetime', + 'completed_at' => 'datetime', + 'archived_at' => 'datetime', + 'last_synced_at' => 'datetime', + ]; + + public function version(): BelongsTo + { + return $this->belongsTo(CourierQuoteVersion::class, 'quote_version_id'); + } + + public function statusLabel(): string + { + return match ($this->status) { + self::STATUS_PREPARED => '待推送', + self::STATUS_SENDING => '推送中', + self::STATUS_SIGNING => '待签署', + self::STATUS_COMPLETED => '签署完成,待归档', + self::STATUS_ARCHIVED => '已签署并归档', + self::STATUS_REVOKED => '已撤销', + self::STATUS_EXPIRED => '已过期', + self::STATUS_REJECTED => '已拒签', + self::STATUS_FAILED => '处理失败', + default => $this->status, + }; + } + + public function canSend(): bool + { + return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED], true) + && blank($this->esign_sign_flow_id); + } +} diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php new file mode 100644 index 00000000..33f6497e --- /dev/null +++ b/app/Models/Business/CourierQuoteVersion.php @@ -0,0 +1,33 @@ + 'integer', + 'effective_date' => 'date', + ]; + + public function change(): BelongsTo + { + return $this->belongsTo(CourierQuoteChange::class, 'source_change_id'); + } + + public function signature(): HasOne + { + return $this->hasOne(CourierQuoteSignature::class, 'quote_version_id'); + } +} diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php new file mode 100644 index 00000000..ab9d06ca --- /dev/null +++ b/app/Services/Esign/EsignClient.php @@ -0,0 +1,201 @@ +baseUrl = rtrim((string) config('services.esign.base_url'), '/'); + $this->signer = new EsignRequestSigner($appId, $appSecret); + } + + public static function configured(): bool + { + return filled(config('services.esign.app_id')) && filled(config('services.esign.app_secret')); + } + + public function uploadPdf(string $bytes, string $fileName): string + { + $contentMd5 = base64_encode(md5($bytes, true)); + $data = $this->request('POST', '/v3/files/file-upload-url', [ + 'contentMd5' => $contentMd5, + 'contentType' => 'application/pdf', + 'fileName' => $fileName, + 'fileSize' => strlen($bytes), + 'convertToPDF' => false, + ]); + $fileId = (string) ($data['fileId'] ?? ''); + $uploadUrl = (string) ($data['fileUploadUrl'] ?? ''); + + if ($fileId === '' || $uploadUrl === '') { + throw new RuntimeException('E 签宝未返回文件 ID 或上传地址。'); + } + + $uploadResponse = Http::timeout($this->timeout()) + ->withHeaders(['Content-MD5' => $contentMd5]) + ->withBody($bytes, 'application/pdf') + ->send('PUT', $uploadUrl); + + if (! $uploadResponse->successful()) { + throw new RuntimeException('报价 PDF 上传到 E 签宝文件服务失败。'); + } + + $this->waitUntilFileReady($fileId); + + return $fileId; + } + + public function createSignFlow(string $fileId, string $fileName, array $signer, int $expiresAtMs): string + { + $flowConfig = [ + 'signFlowTitle' => $signer['flow_title'], + 'signFlowExpireTime' => $expiresAtMs, + 'autoStart' => true, + 'autoFinish' => true, + ]; + $notifyUrl = trim((string) config('services.esign.notify_url')); + + if ($notifyUrl !== '') { + $flowConfig['notifyUrl'] = $notifyUrl; + } + + $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ + 'docs' => [[ + 'fileId' => $fileId, + 'fileName' => $fileName, + ]], + 'signFlowConfig' => $flowConfig, + 'signers' => [[ + 'signerType' => 1, + 'signConfig' => ['signOrder' => 1], + 'noticeConfig' => ['noticeTypes' => $signer['notice_types']], + 'orgSignerInfo' => [ + 'orgName' => $signer['organization_name'], + 'transactorInfo' => [ + 'psnAccount' => $signer['operator_account'], + 'psnInfo' => ['psnName' => $signer['operator_name']], + ], + ], + 'signFields' => [[ + 'fileId' => $fileId, + 'normalSignFieldConfig' => [ + 'freeMode' => true, + 'movableSignField' => true, + 'signFieldSize' => '159', + ], + ]], + ]], + ]); + $signFlowId = (string) ($data['signFlowId'] ?? ''); + + if ($signFlowId === '') { + throw new RuntimeException('E 签宝未返回签署流程 ID。'); + } + + return $signFlowId; + } + + public function signFlowDetail(string $signFlowId): array + { + return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail"); + } + + public function downloadUrls(string $signFlowId): array + { + return $this->request('POST', "/v3/sign-flow/{$signFlowId}/file-download-url", [ + 'urlAvailableDate' => '3600', + ]); + } + + public function download(string $url): string + { + $response = Http::timeout($this->timeout())->get($url); + + if (! $response->successful()) { + throw new RuntimeException('无法从 E 签宝下载签署归档文件。'); + } + + return $response->body(); + } + + private function waitUntilFileReady(string $fileId): void + { + for ($attempt = 0; $attempt < 8; $attempt++) { + $data = $this->request('GET', "/v3/files/{$fileId}"); + $status = (string) ($data['fileStatus'] ?? $data['status'] ?? ''); + + if (in_array($status, ['2', '5'], true)) { + return; + } + + if ($attempt < 7) { + usleep(400000); + } + } + + throw new RuntimeException('E 签宝文件处理超时,请稍后重试。'); + } + + private function request(string $method, string $path, array $payload = []): array + { + $method = strtoupper($method); + $body = $method === 'GET' ? '' : $this->encodeJson($payload); + $request = Http::timeout($this->timeout()) + ->withHeaders($this->signer->headers($method, $path, $body)); + + if ($body !== '') { + $request = $request->withBody($body, EsignRequestSigner::CONTENT_TYPE); + } + + $response = $request->send($method, $this->baseUrl . $path); + + if (! $response->successful()) { + throw new RuntimeException("E 签宝接口请求失败(HTTP {$response->status()})。"); + } + + $decoded = $response->json(); + + if (! is_array($decoded)) { + throw new RuntimeException('E 签宝接口返回了无法识别的数据。'); + } + + if ((int) ($decoded['code'] ?? -1) !== 0) { + $code = (string) ($decoded['code'] ?? 'unknown'); + $message = mb_substr((string) ($decoded['message'] ?? '未知错误'), 0, 300); + + throw new RuntimeException("E 签宝接口返回业务错误({$code}):{$message}"); + } + + return is_array($decoded['data'] ?? null) ? $decoded['data'] : []; + } + + private function encodeJson(array $payload): string + { + try { + return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new RuntimeException('E 签宝请求数据无法编码。', previous: $exception); + } + } + + private function timeout(): int + { + return max(5, (int) config('services.esign.timeout', 20)); + } +} diff --git a/app/Services/Esign/EsignRequestSigner.php b/app/Services/Esign/EsignRequestSigner.php new file mode 100644 index 00000000..3644bb6b --- /dev/null +++ b/app/Services/Esign/EsignRequestSigner.php @@ -0,0 +1,75 @@ +appId === '' || $this->appSecret === '') { + throw new InvalidArgumentException('E 签宝 App ID 和 AppSecret 不能为空。'); + } + } + + public function headers(string $method, string $pathAndParameters, string $body = ''): array + { + $method = strtoupper($method); + $pathAndParameters = $this->canonicalPath($pathAndParameters); + $contentMd5 = $body === '' ? '' : base64_encode(md5($body, true)); + $stringToSign = implode("\n", [ + $method, + self::ACCEPT, + $contentMd5, + self::CONTENT_TYPE, + '', + ]) . "\n" . $pathAndParameters; + + return [ + 'X-Tsign-Open-App-Id' => $this->appId, + 'X-Tsign-Open-Auth-Mode' => 'Signature', + 'X-Tsign-Open-Ca-Timestamp' => (string) floor(microtime(true) * 1000), + 'X-Tsign-Open-Ca-Signature' => base64_encode(hash_hmac('sha256', $stringToSign, $this->appSecret, true)), + 'Accept' => self::ACCEPT, + 'Content-Type' => self::CONTENT_TYPE, + 'Content-MD5' => $contentMd5, + ]; + } + + public function canonicalPath(string $pathAndParameters): string + { + $parts = parse_url($pathAndParameters); + $path = (string) ($parts['path'] ?? ''); + + if ($path === '' || ! str_starts_with($path, '/')) { + throw new InvalidArgumentException('E 签宝 API 路径必须以 / 开头。'); + } + + if (! isset($parts['query']) || $parts['query'] === '') { + return $path; + } + + $parameters = []; + + foreach (explode('&', $parts['query']) as $pair) { + [$key, $value] = array_pad(explode('=', $pair, 2), 2, null); + + if (! array_key_exists($key, $parameters)) { + $parameters[$key] = $value; + } + } + + ksort($parameters, SORT_STRING); + $query = collect($parameters)->map(function (?string $value, string $key): string { + return $value === null || $value === '' ? $key : $key . '=' . $value; + })->implode('&'); + + return $path . '?' . $query; + } +} diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php new file mode 100644 index 00000000..90fccc09 --- /dev/null +++ b/app/Support/CourierQuoteChangeService.php @@ -0,0 +1,317 @@ +findSourceQuote($data['source_quote_id']); + + return DB::transaction(function () use ($data, $userId, $source): CourierQuoteChange { + $retrospective = $this->isRetrospectiveDate($data['effective_date']); + $change = CourierQuoteChange::create([ + 'company_id' => company_id(), + 'number' => $this->nextChangeNumber(), + 'source_quote_id' => $source['id'], + 'source_sheet_name' => $source['sheet_name'], + 'carrier_name' => $source['company_name'], + 'quote_type' => $source['quote_type'], + 'change_type' => $data['change_type'], + 'status' => CourierQuoteChange::STATUS_DRAFT, + 'effective_date' => $data['effective_date'], + 'is_retrospective' => $retrospective, + 'recalculation_status' => $retrospective + ? CourierQuoteChange::RECALCULATION_PENDING + : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, + 'recalculation_note' => null, + 'summary' => $data['summary'], + 'created_by' => $userId, + ]); + + $this->replaceItems($change, $data['items'], $source); + $this->recordEvent($change, 'created', null, CourierQuoteChange::STATUS_DRAFT, '创建报价变更草稿。', $userId, $this->changeDetails($change)); + + return $change->load('items', 'events'); + }); + } + + public function update(CourierQuoteChange $change, array $data, int $userId): CourierQuoteChange + { + $this->ensureEditableBy($change, $userId); + $source = $this->findSourceQuote($change->source_quote_id); + + return DB::transaction(function () use ($change, $data, $userId, $source): CourierQuoteChange { + $from = $change->status; + $retrospective = $this->isRetrospectiveDate($data['effective_date']); + $change->update([ + 'change_type' => $data['change_type'], + 'effective_date' => $data['effective_date'], + 'is_retrospective' => $retrospective, + 'recalculation_status' => $retrospective + ? CourierQuoteChange::RECALCULATION_PENDING + : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, + 'recalculation_note' => null, + 'summary' => $data['summary'], + 'status' => CourierQuoteChange::STATUS_DRAFT, + ]); + $this->replaceItems($change, $data['items'], $source); + $this->recordEvent($change, 'saved', $from, CourierQuoteChange::STATUS_DRAFT, '保存报价变更草稿。', $userId, $this->changeDetails($change)); + + return $change->fresh(['items', 'events']); + }); + } + + public function submit(CourierQuoteChange $change, int $userId): CourierQuoteChange + { + $this->ensureEditableBy($change, $userId); + + if ($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); + $change->update([ + 'status' => CourierQuoteChange::STATUS_PENDING_REVIEW, + 'is_retrospective' => $retrospective, + 'recalculation_status' => $retrospective + ? CourierQuoteChange::RECALCULATION_PENDING + : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, + 'recalculation_note' => null, + 'submitted_by' => $userId, + 'submitted_at' => now(), + ]); + $this->recordEvent($change, 'submitted', $from, CourierQuoteChange::STATUS_PENDING_REVIEW, '提交报价变更审核。', $userId, $this->changeDetails($change)); + + return $change->fresh(['items', 'events']); + }); + } + + public function returnForChanges(CourierQuoteChange $change, string $note, int $userId): CourierQuoteChange + { + if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { + throw ValidationException::withMessages(['status' => '只有待审核的变更单可以退回。']); + } + + return DB::transaction(function () use ($change, $note, $userId): CourierQuoteChange { + $change->update(['status' => CourierQuoteChange::STATUS_RETURNED]); + $this->recordEvent($change, 'returned', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, $note, $userId); + + return $change->fresh(['items', 'events']); + }); + } + + public function approve(CourierQuoteChange $change, int $userId): CourierQuoteChange + { + if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { + throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']); + } + + if ($change->created_by === $userId) { + throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']); + } + + $source = $this->findSourceQuote($change->source_quote_id); + + return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange { + $values = $source['values']; + + foreach ($change->items as $item) { + if (! array_key_exists($item->source_row, $values) || ! array_key_exists($item->source_column, $values[$item->source_row])) { + throw ValidationException::withMessages(['items' => "报价单元格 {$item->source_cell} 已不存在,请退回重新核对。"]); + } + + $values[$item->source_row][$item->source_column] = $item->new_amount; + } + + $importedVersionCount = collect($this->snapshot->load()['quotes'] ?? []) + ->whereNull('source_change_id') + ->count(); + $version = CourierQuoteVersion::create([ + 'company_id' => company_id(), + 'version_code' => 'PENDING-' . $change->id, + 'source_quote_id' => $source['id'], + 'source_change_id' => $change->id, + 'sheet_name' => $source['sheet_name'], + 'company_name' => $source['company_name'], + 'carrier_name' => $source['company_name'], + 'quote_type' => $source['quote_type'], + 'effective_date' => $change->effective_date, + 'source_range' => $source['source_range'], + 'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'formulas_json' => json_encode($source['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'status' => 'approved', + 'signature_status' => '未签署', + 'created_by' => $change->created_by, + 'approved_by' => $userId, + ]); + $version->update([ + 'version_code' => sprintf('QB-%03d', $importedVersionCount + $version->id), + ]); + + $retrospective = $this->isRetrospectiveDate($change->effective_date); + $recalculationNote = $retrospective ? $this->retrospectiveBlockReason() : null; + $change->update([ + 'status' => CourierQuoteChange::STATUS_APPROVED, + 'is_retrospective' => $retrospective, + 'recalculation_status' => $retrospective + ? CourierQuoteChange::RECALCULATION_BLOCKED + : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, + 'recalculation_note' => $recalculationNote, + 'approved_by' => $userId, + 'approved_at' => now(), + 'new_quote_version_id' => $version->id, + ]); + $eventNote = "审核通过并生成新版本 {$version->version_code}。"; + + if ($retrospective) { + $eventNote .= ' 该报价为历史生效;补差明细尚未生成,原运费保持不变。'; + } + + $this->recordEvent($change, 'approved', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_APPROVED, $eventNote, $userId, [ + ...$this->changeDetails($change), + 'version_code' => $version->version_code, + ]); + + return $change->fresh(['items', 'events', 'version']); + }); + } + + public function findSourceQuote(string $id): array + { + $quote = collect($this->snapshot->load()['quotes'] ?? [])->firstWhere('id', $id); + + if (! is_array($quote)) { + throw ValidationException::withMessages(['source_quote_id' => '选择的原报价版本不存在或已不可用。']); + } + + return $quote; + } + + private function replaceItems(CourierQuoteChange $change, array $items, array $source): void + { + $change->items()->withTrashed()->forceDelete(); + + foreach (array_values($items) as $position => $item) { + $row = (int) $item['source_row']; + $column = (int) $item['source_column']; + $sourceValue = $source['values'][$row][$column] ?? null; + $formula = $source['formulas'][$row][$column] ?? null; + + if (is_string($formula) && trim($formula) !== '') { + throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 是公式单元格,不能直接改价。"]); + } + + if (! is_numeric($sourceValue)) { + throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 不是可调整的数值单元格。"]); + } + + if (abs((float) $sourceValue - (float) $item['original_amount']) > 0.00005) { + throw ValidationException::withMessages(["items.{$position}.original_amount" => "{$item['source_cell']} 的原价格已经变化,请重新载入后再保存。"]); + } + + if ((float) $item['new_amount'] < 0) { + throw ValidationException::withMessages(["items.{$position}.new_amount" => '新价格不能小于 0。']); + } + + if (round((float) $item['new_amount'], 4) === round((float) $item['original_amount'], 4)) { + continue; + } + + $change->items()->create([ + 'company_id' => company_id(), + 'position' => $position, + 'source_cell' => strtoupper($item['source_cell']), + 'source_row' => $row, + 'source_column' => $column, + 'region' => $item['region'] ?? null, + 'weight_band' => $item['weight_band'] ?? null, + 'charge_item' => $item['charge_item'] ?? null, + 'original_amount' => number_format((float) $item['original_amount'], 4, '.', ''), + 'new_amount' => number_format((float) $item['new_amount'], 4, '.', ''), + 'unit' => $item['unit'] ?? null, + 'note' => $item['note'] ?? null, + ]); + } + + if ($change->items()->count() === 0) { + throw ValidationException::withMessages(['items' => '至少需要一条原价格与新价格不同的变价明细。']); + } + } + + private function ensureEditableBy(CourierQuoteChange $change, int $userId): void + { + if (! $change->isEditable()) { + throw ValidationException::withMessages(['status' => '当前状态不允许继续修改。']); + } + + if ($change->created_by !== $userId) { + abort(403, '只有草稿发起人可以修改或提交该变更单。'); + } + } + + private function recordEvent(CourierQuoteChange $change, string $type, ?string $from, ?string $to, string $note, int $userId, array $details = []): void + { + CourierQuoteChangeEvent::create([ + 'company_id' => company_id(), + 'quote_change_id' => $change->id, + 'event_type' => $type, + 'from_status' => $from, + 'to_status' => $to, + 'note' => $note, + 'details_json' => $details === [] ? null : json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'created_by' => $userId, + ]); + } + + private function changeDetails(CourierQuoteChange $change): array + { + return [ + 'change_type' => $change->change_type, + 'effective_date' => $change->effective_date->toDateString(), + 'is_retrospective' => $change->isRetrospective(), + 'recalculation_status' => $change->recalculation_status, + 'recalculation_note' => $change->recalculation_note, + 'summary' => $change->summary, + 'items' => $change->items()->get()->map(static fn ($item): array => [ + 'source_cell' => $item->source_cell, + 'original_amount' => $item->original_amount, + 'new_amount' => $item->new_amount, + 'note' => $item->note, + ])->all(), + ]; + } + + private function nextChangeNumber(): string + { + $prefix = 'BG-' . now()->format('Ymd') . '-'; + $last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); + $sequence = $last ? ((int) substr($last, -3)) + 1 : 1; + + return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); + } + + private function isRetrospectiveDate(string|Carbon $effectiveDate): bool + { + return Carbon::parse($effectiveDate)->startOfDay()->lt(today()); + } + + private function retrospectiveBlockReason(): string + { + return '未生成补差明细:当前报价未提供可执行的运费计算方式,历史运费也未记录所用报价版本。补差前需建立逐票原报价版本关联和可验证的报价计算规则;原运费及原始 Excel 保持不变。'; + } + +} diff --git a/app/Support/CourierQuotePdf.php b/app/Support/CourierQuotePdf.php new file mode 100644 index 00000000..2b5ced85 --- /dev/null +++ b/app/Support/CourierQuotePdf.php @@ -0,0 +1,46 @@ +loadMissing(['change.items']); + $values = $this->decodeMatrix($version->values_json); + $formulas = $this->decodeMatrix($version->formulas_json); + $html = view('business.pdf.courier-quote', [ + 'version' => $version, + 'change' => $version->change, + 'values' => $values, + 'formulas' => $formulas, + 'generatedAt' => now(), + 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), + ])->render(); + + $pdf = app('dompdf.wrapper'); + $pdf->setPaper('a4', 'landscape'); + $pdf->loadHTML($html, 'UTF-8'); + $bytes = $pdf->output(); + + if (! is_string($bytes) || ! str_starts_with($bytes, '%PDF-')) { + throw new RuntimeException('报价 PDF 生成失败。'); + } + + return $bytes; + } + + private function decodeMatrix(?string $json): array + { + if (blank($json)) { + return []; + } + + $decoded = json_decode($json, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/app/Support/CourierQuoteSignatureService.php b/app/Support/CourierQuoteSignatureService.php new file mode 100644 index 00000000..dc573f54 --- /dev/null +++ b/app/Support/CourierQuoteSignatureService.php @@ -0,0 +1,298 @@ +signature; + + if ($existing && $this->sourcePdfIsIntact($existing)) { + return $existing; + } + + if ($existing && ! $existing->canSend()) { + throw ValidationException::withMessages([ + 'signature' => '该报价已经进入签署流程,签署原文不可重新生成。', + ]); + } + + $bytes = $this->pdf->render($version); + $path = $this->path($version, 'source.pdf'); + $sha256 = hash('sha256', $bytes); + + if (! Storage::disk('uploads')->put($path, $bytes)) { + throw new RuntimeException('报价 PDF 无法写入私有业务存储。'); + } + + $signature = CourierQuoteSignature::updateOrCreate( + ['company_id' => $version->company_id, 'quote_version_id' => $version->id], + [ + 'provider' => 'esign', + 'environment' => (string) config('services.esign.environment', 'sandbox'), + 'status' => CourierQuoteSignature::STATUS_PREPARED, + 'organization_name' => $version->carrier_name, + 'source_pdf_path' => $path, + 'source_pdf_sha256' => $sha256, + 'source_pdf_generated_at' => now(), + 'last_error' => null, + 'created_by' => $existing?->created_by ?? $userId, + 'updated_by' => $userId, + ], + ); + $this->updateVersionStatus($signature, $signature->statusLabel()); + + return $signature->fresh('version'); + } + + public function send(CourierQuoteSignature $signature, array $data, int $userId): CourierQuoteSignature + { + $signature = DB::transaction(function () use ($signature, $data, $userId): CourierQuoteSignature { + $locked = CourierQuoteSignature::query()->lockForUpdate()->findOrFail($signature->id); + + if (! $locked->canSend()) { + throw ValidationException::withMessages(['signature' => '当前报价版本已经进入签署流程,不能重复推送。']); + } + + if (! $this->sourcePdfIsIntact($locked)) { + throw ValidationException::withMessages(['signature' => '待签报价 PDF 缺失或校验不一致,请重新生成后再推送。']); + } + + $locked->update([ + 'status' => CourierQuoteSignature::STATUS_SENDING, + 'organization_name' => $data['organization_name'], + 'operator_name' => $data['operator_name'], + 'operator_account' => $data['operator_account'], + 'notice_types' => (string) $data['notice_type'], + 'expires_at' => Carbon::parse($data['expires_at']), + 'last_error' => null, + 'updated_by' => $userId, + ]); + + return $locked->fresh('version'); + }); + $this->updateVersionStatus($signature, '推送中'); + + try { + $client = app(EsignClient::class); + $bytes = Storage::disk('uploads')->get($signature->source_pdf_path); + $fileName = 'quote-' . $signature->version->version_code . '.pdf'; + $fileId = $client->uploadPdf($bytes, $fileName); + $signFlowId = $client->createSignFlow($fileId, $fileName, [ + 'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认', + 'organization_name' => $signature->organization_name, + 'operator_name' => $signature->operator_name, + 'operator_account' => $signature->operator_account, + 'notice_types' => $signature->notice_types, + ], $signature->expires_at->getTimestampMs()); + + $signature->update([ + 'status' => CourierQuoteSignature::STATUS_SIGNING, + 'esign_file_id' => $fileId, + 'esign_sign_flow_id' => $signFlowId, + 'remote_status' => '1', + 'sent_at' => now(), + 'last_synced_at' => now(), + 'last_error' => null, + 'updated_by' => $userId, + ]); + $this->updateVersionStatus($signature, '待签署'); + + return $signature->fresh('version'); + } catch (Throwable $exception) { + $signature->update([ + 'status' => CourierQuoteSignature::STATUS_FAILED, + 'last_error' => $this->safeError($exception), + 'updated_by' => $userId, + ]); + $this->updateVersionStatus($signature, '推送失败'); + + throw ValidationException::withMessages(['signature' => $signature->last_error]); + } + } + + public function synchronize(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature + { + if (blank($signature->esign_sign_flow_id)) { + throw ValidationException::withMessages(['signature' => '该报价版本尚未创建 E 签宝签署流程。']); + } + + if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) { + return $signature; + } + + try { + $client = app(EsignClient::class); + $detail = $client->signFlowDetail($signature->esign_sign_flow_id); + $remoteStatus = (string) ($detail['signFlowStatus'] ?? $detail['status'] ?? ''); + + if ($remoteStatus === '') { + throw new RuntimeException('E 签宝未返回签署流程状态。'); + } + + $signature = $this->applyRemoteStatus($signature, $remoteStatus, $userId); + + return $remoteStatus === '2' ? $this->archive($signature, $client, $userId) : $signature; + } catch (Throwable $exception) { + $signature->update([ + 'last_error' => $this->safeError($exception), + 'last_synced_at' => now(), + 'updated_by' => $userId, + ]); + + throw ValidationException::withMessages(['signature' => $signature->last_error]); + } + } + + public function synchronizeById(int $signatureId): void + { + $signature = CourierQuoteSignature::allCompanies()->find($signatureId); + + if (! $signature) { + return; + } + + $this->synchronize($signature, $signature->updated_by); + } + + public function acceptCallback(string $signFlowId, string $remoteStatus, string $payloadHash): ?CourierQuoteSignature + { + return DB::transaction(function () use ($signFlowId, $remoteStatus, $payloadHash): ?CourierQuoteSignature { + $signature = CourierQuoteSignature::allCompanies() + ->where('esign_sign_flow_id', $signFlowId) + ->lockForUpdate() + ->first(); + + if (! $signature) { + return null; + } + + if ($signature->callback_payload_hash === $payloadHash) { + return null; + } + + $signature->callback_payload_hash = $payloadHash; + + return $this->applyRemoteStatus($signature, $remoteStatus, $signature->updated_by); + }); + } + + public function sourcePdfIsIntact(CourierQuoteSignature $signature): bool + { + if (blank($signature->source_pdf_path) || blank($signature->source_pdf_sha256)) { + return false; + } + + $disk = Storage::disk('uploads'); + + return $disk->exists($signature->source_pdf_path) + && hash_equals($signature->source_pdf_sha256, hash('sha256', $disk->get($signature->source_pdf_path))); + } + + private function applyRemoteStatus(CourierQuoteSignature $signature, string $remoteStatus, int $userId): CourierQuoteSignature + { + [$status, $label] = match ($remoteStatus) { + '1' => [CourierQuoteSignature::STATUS_SIGNING, '待签署'], + '2' => [CourierQuoteSignature::STATUS_COMPLETED, '签署完成,待归档'], + '3' => [CourierQuoteSignature::STATUS_REVOKED, '已撤销'], + '5' => [CourierQuoteSignature::STATUS_EXPIRED, '已过期'], + '7' => [CourierQuoteSignature::STATUS_REJECTED, '已拒签'], + default => [$signature->status, $signature->statusLabel()], + }; + $signature->forceFill([ + 'status' => $status, + 'remote_status' => $remoteStatus, + 'completed_at' => $remoteStatus === '2' ? ($signature->completed_at ?? now()) : $signature->completed_at, + 'last_synced_at' => now(), + 'last_error' => null, + 'updated_by' => $userId, + ])->save(); + $this->updateVersionStatus($signature, $label); + + $signature->refresh(); + + return $signature; + } + + private function archive(CourierQuoteSignature $signature, EsignClient $client, int $userId): CourierQuoteSignature + { + if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) { + return $signature; + } + + $urls = $client->downloadUrls($signature->esign_sign_flow_id); + $signedUrl = (string) data_get($urls, 'files.0.downloadUrl', ''); + $certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? ''); + + if ($signedUrl === '' || $certificateUrl === '') { + throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。'); + } + + $signedBytes = $client->download($signedUrl); + $certificateBytes = $client->download($certificateUrl); + + if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) { + throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。'); + } + + $version = CourierQuoteVersion::allCompanies()->findOrFail($signature->quote_version_id); + $signedPath = $this->path($version, 'signed.pdf'); + $certificatePath = $this->path($version, 'completion-certificate.pdf'); + $disk = Storage::disk('uploads'); + + if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) { + throw new RuntimeException('签署归档文件无法写入私有业务存储。'); + } + + $signature->update([ + 'status' => CourierQuoteSignature::STATUS_ARCHIVED, + 'signed_pdf_path' => $signedPath, + 'signed_pdf_sha256' => hash('sha256', $signedBytes), + 'certificate_path' => $certificatePath, + 'certificate_sha256' => hash('sha256', $certificateBytes), + 'archived_at' => now(), + 'last_synced_at' => now(), + 'last_error' => null, + 'updated_by' => $userId, + ]); + $this->updateVersionStatus($signature, '已签署并归档'); + + return $signature->fresh('version'); + } + + private function path(CourierQuoteVersion $version, string $fileName): string + { + $versionCode = preg_replace('/[^A-Za-z0-9_-]/', '-', $version->version_code) ?: (string) $version->id; + + return "courier-quotes/{$version->company_id}/{$versionCode}/{$fileName}"; + } + + private function updateVersionStatus(CourierQuoteSignature $signature, string $label): void + { + CourierQuoteVersion::allCompanies() + ->whereKey($signature->quote_version_id) + ->update(['signature_status' => $label]); + } + + private function safeError(Throwable $exception): string + { + $message = preg_replace('/[[:cntrl:]]+/u', ' ', $exception->getMessage()) ?: 'E 签宝处理失败。'; + + return mb_substr($message, 0, 1000); + } +} diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index 121a8bbc..f57b1c36 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -2,6 +2,8 @@ namespace App\Support; +use App\Models\Business\CourierQuoteVersion; +use Illuminate\Support\Facades\Schema; use JsonException; final class QuoteManagementSnapshot @@ -40,6 +42,12 @@ final class QuoteManagementSnapshot $quotes[] = $this->normalizeSheet($sheet, $position); } + if (Schema::hasTable('business_courier_quote_versions')) { + foreach (CourierQuoteVersion::query()->orderBy('id')->get() as $version) { + $quotes[] = $this->normalizeVersion($version); + } + } + return [ 'available' => true, 'state' => 'ready', @@ -88,6 +96,45 @@ final class QuoteManagementSnapshot return $companyName !== '' ? $companyName : $name; } + private function normalizeVersion(CourierQuoteVersion $version): array + { + $values = $this->decodeMatrix($version->values_json); + $formulas = $this->decodeMatrix($version->formulas_json); + $columnCount = max(array_map('count', $values ?: [[]])); + + return [ + 'id' => $version->version_code, + 'sheet_index' => null, + 'sheet_name' => $version->sheet_name, + 'company_name' => $version->company_name, + 'carrier' => $version->carrier_name, + 'quote_type' => $version->quote_type, + 'effective_date' => $version->effective_date?->toDateString(), + 'source_range' => $version->source_range ?? '', + 'row_count' => count($values), + 'column_count' => $columnCount, + 'formula_count' => $this->formulaCount($formulas), + 'status' => '已审核待签署', + 'signature_status' => $version->signature_status, + 'values' => $values, + 'formulas' => $formulas, + 'source_change_id' => $version->source_change_id, + ]; + } + + private function decodeMatrix(?string $json): array + { + if ($json === null || trim($json) === '') { + return []; + } + + try { + return $this->normalizeMatrix(json_decode($json, true, 512, JSON_THROW_ON_ERROR)); + } catch (JsonException) { + return []; + } + } + private function normalizeMatrix(mixed $matrix): array { if (! is_array($matrix)) { diff --git a/config/services.php b/config/services.php index f88a8933..e7f3c686 100644 --- a/config/services.php +++ b/config/services.php @@ -34,4 +34,14 @@ return [ 'api_key' => env('SENDGRID_API_KEY'), ], + 'esign' => [ + 'environment' => env('ESIGN_ENVIRONMENT', 'sandbox'), + 'base_url' => env('ESIGN_BASE_URL', 'https://smlopenapi.esign.cn'), + 'app_id' => env('ESIGN_APP_ID'), + 'app_secret' => env('ESIGN_APP_SECRET'), + 'notify_url' => env('ESIGN_NOTIFY_URL'), + 'callback_tolerance_seconds' => (int) env('ESIGN_CALLBACK_TOLERANCE_SECONDS', 900), + 'timeout' => (int) env('ESIGN_TIMEOUT', 20), + ], + ]; diff --git a/database/migrations/2026_08_13_000000_create_courier_quote_change_workflow_tables.php b/database/migrations/2026_08_13_000000_create_courier_quote_change_workflow_tables.php new file mode 100644 index 00000000..77b2415e --- /dev/null +++ b/database/migrations/2026_08_13_000000_create_courier_quote_change_workflow_tables.php @@ -0,0 +1,113 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('number', 32); + $table->string('source_quote_id', 32); + $table->string('source_sheet_name'); + $table->string('carrier_name'); + $table->string('quote_type'); + $table->string('change_type', 32); + $table->string('status', 32)->default('draft'); + $table->date('effective_date'); + $table->boolean('is_retrospective')->default(false); + $table->string('recalculation_status', 32)->default('not_required'); + $table->text('recalculation_note')->nullable(); + $table->text('summary'); + $table->unsignedInteger('created_by'); + $table->unsignedInteger('submitted_by')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->unsignedInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->unsignedInteger('new_quote_version_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'number'], 'courier_quote_changes_company_number_unique'); + $table->index(['company_id', 'status'], 'courier_quote_changes_company_status_index'); + $table->index(['company_id', 'source_quote_id'], 'courier_quote_changes_company_source_index'); + $table->index(['company_id', 'recalculation_status'], 'courier_quote_changes_company_recalc_index'); + }); + + Schema::create('business_courier_quote_change_items', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('quote_change_id'); + $table->unsignedInteger('position')->default(0); + $table->string('source_cell', 24); + $table->unsignedInteger('source_row'); + $table->unsignedInteger('source_column'); + $table->string('region')->nullable(); + $table->string('weight_band')->nullable(); + $table->string('charge_item')->nullable(); + $table->decimal('original_amount', 15, 4); + $table->decimal('new_amount', 15, 4); + $table->string('unit', 32)->nullable(); + $table->text('note')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'quote_change_id'], 'courier_quote_items_company_change_index'); + $table->unique(['quote_change_id', 'source_cell'], 'courier_quote_items_change_cell_unique'); + }); + + Schema::create('business_courier_quote_change_events', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('quote_change_id'); + $table->string('event_type', 32); + $table->string('from_status', 32)->nullable(); + $table->string('to_status', 32)->nullable(); + $table->text('note')->nullable(); + $table->longText('details_json')->nullable(); + $table->unsignedInteger('created_by'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'quote_change_id'], 'courier_quote_events_company_change_index'); + }); + + Schema::create('business_courier_quote_versions', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('company_id'); + $table->string('version_code', 32); + $table->string('source_quote_id', 32); + $table->unsignedInteger('source_change_id'); + $table->string('sheet_name'); + $table->string('company_name'); + $table->string('carrier_name'); + $table->string('quote_type'); + $table->date('effective_date'); + $table->string('source_range')->nullable(); + $table->longText('values_json'); + $table->longText('formulas_json')->nullable(); + $table->string('status', 32)->default('approved'); + $table->string('signature_status', 32)->default('未签署'); + $table->unsignedInteger('created_by'); + $table->unsignedInteger('approved_by'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'version_code'], 'courier_quote_versions_company_code_unique'); + $table->unique(['company_id', 'source_change_id'], 'courier_quote_versions_company_change_unique'); + $table->index(['company_id', 'carrier_name', 'quote_type'], 'courier_quote_versions_lookup_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_courier_quote_versions'); + Schema::dropIfExists('business_courier_quote_change_events'); + Schema::dropIfExists('business_courier_quote_change_items'); + Schema::dropIfExists('business_courier_quote_changes'); + } +}; diff --git a/database/migrations/2026_08_14_210000_create_business_courier_quote_signatures_table.php b/database/migrations/2026_08_14_210000_create_business_courier_quote_signatures_table.php new file mode 100644 index 00000000..2c918b55 --- /dev/null +++ b/database/migrations/2026_08_14_210000_create_business_courier_quote_signatures_table.php @@ -0,0 +1,54 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('quote_version_id'); + $table->string('provider', 32)->default('esign'); + $table->string('environment', 32)->default('sandbox'); + $table->string('status', 32)->default('prepared'); + $table->string('organization_name'); + $table->string('operator_name')->nullable(); + $table->string('operator_account')->nullable(); + $table->string('notice_types', 16)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->string('source_pdf_path', 1024); + $table->string('source_pdf_sha256', 64); + $table->timestamp('source_pdf_generated_at'); + $table->string('esign_file_id', 128)->nullable(); + $table->string('esign_sign_flow_id', 128)->nullable(); + $table->string('remote_status', 32)->nullable(); + $table->string('signed_pdf_path', 1024)->nullable(); + $table->string('signed_pdf_sha256', 64)->nullable(); + $table->string('certificate_path', 1024)->nullable(); + $table->string('certificate_sha256', 64)->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->text('last_error')->nullable(); + $table->string('callback_payload_hash', 64)->nullable(); + $table->unsignedInteger('created_by'); + $table->unsignedInteger('updated_by'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'quote_version_id'], 'courier_quote_signatures_company_version_unique'); + $table->unique('esign_sign_flow_id', 'courier_quote_signatures_flow_unique'); + $table->index(['company_id', 'status'], 'courier_quote_signatures_company_status_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_courier_quote_signatures'); + } +}; diff --git a/resources/fonts/DroidSansFallback-LICENSE.txt b/resources/fonts/DroidSansFallback-LICENSE.txt new file mode 100644 index 00000000..f7bd78d2 --- /dev/null +++ b/resources/fonts/DroidSansFallback-LICENSE.txt @@ -0,0 +1,189 @@ + + Copyright (c) 2005-2008, The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/resources/fonts/DroidSansFallback.ttf b/resources/fonts/DroidSansFallback.ttf new file mode 100644 index 00000000..1099b177 Binary files /dev/null and b/resources/fonts/DroidSansFallback.ttf differ diff --git a/resources/views/business/pages/courier-payables/quote-change-editor.blade.php b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php new file mode 100644 index 00000000..2f9d7ef4 --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php @@ -0,0 +1,218 @@ + + {{ $change ? '编辑报价变更' : '发起报价变更' }} + + + + +
+ + + @if (is_string(session('success'))) +
{{ session('success') }}
+ @endif + @if ($errors->any()) + + @endif + + @unless ($change) +
+
+
第一步:选择原报价
原报价保持只读,系统只保存发生变化的单元格。
+
+
+
+
+ +
+
+
+ @endunless + +
+ @csrf + @if ($change) @method('PATCH') @else @endif + +
+
{{ $change ? '变更单基本信息' : '第二步:填写变更信息' }}
生效日期可以早于今天;历史日期会进入追溯核算流程。
+
+
+
原报价版本
{{ $source['id'] }}
快递公司
{{ $source['company_name'] }}
报价类型
{{ $source['quote_type'] }}
原执行日期
{{ $source['effective_date'] ?? '未标注' }}
+
+
+
+
+
+
+ +
+
+ +
+
{{ $change ? '变价明细' : '第三步:编辑新价格' }}
仅新价格可编辑;系统会再次核对原价格,防止过期数据覆盖。
+
+
保存草稿不会让报价生效。提交审核后草稿将锁定,由另一位管理员审核并生成新版本。
+
+
+
+ +
+
+ + + + @foreach ($editableCells as $index => $cell) + + + + + + + + + + + @endforeach + +
单元格地区/规则重量段/计费项原价格新价格差额备注
{{ $cell['source_cell'] }}{{ $cell['region'] ?: '—' }}{{ $cell['weight_band'] ?: ($cell['charge_item'] ?: '—') }}{{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}0.0000
+
+
+
+ +
+ + +
+
+ + @if ($change) +
@csrf
+ @endif +
+ + +
+
diff --git a/resources/views/business/pages/courier-payables/quote-change-history.blade.php b/resources/views/business/pages/courier-payables/quote-change-history.blade.php new file mode 100644 index 00000000..3ca91f1a --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-change-history.blade.php @@ -0,0 +1,56 @@ + + 报价变更历史 + + + +
+ + @if(is_string(session('success')))
{{ session('success') }}
@endif +
+
报价变更记录
草稿、审核和新版本按当前公司隔离保存,历史记录不可覆盖原报价。
+ @if($changes->isEmpty()) +
还没有报价变更记录
发起第一张变更单后,草稿和审核进度会显示在这里。
+ @else +
+ @foreach($changes as $change) + @php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''}) + + @endforeach +
变更单号原报价快递公司变更类型明细生效日期生效方式追溯核算状态新版本更新时间操作
{{ $change->number }}{{ $change->source_quote_id }}{{ $change->carrier_name }}{{ $change->change_type }}{{ $change->items->count() }} 项{{ $change->effective_date->toDateString() }}{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}{{ $change->recalculationStatusLabel() }}{{ $change->statusLabel() }}{{ $change->version?->version_code ?? '—' }}{{ $change->updated_at?->format('Y-m-d H:i') }}查看@if($change->isEditable() && $change->created_by===user_id()) · 编辑@endif
{{ $changes->links() }}
+ @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 new file mode 100644 index 00000000..121a1f04 --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-change-show.blade.php @@ -0,0 +1,118 @@ + + 报价变更详情 + + +
+
返回报价历史
@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($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->effective_date->toDateString() }}
生效方式
{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}
追溯核算
{{ $change->recalculationStatusLabel() }}
变价明细
{{ $change->items->count() }} 项
新报价版本
{{ $change->version?->version_code ?? '尚未生成' }}
签署状态
{{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
+
新旧价格差异
@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)@endforeach
单元格地区/规则重量段/计费项原价格新价格差额备注
{{ $item->source_cell }}{{ $item->region ?: '—' }}{{ $item->weight_band ?: ($item->charge_item ?: '—') }}{{ rtrim(rtrim($item->original_amount,'0'),'.') }}{{ rtrim(rtrim($item->new_amount,'0'),'.') }}{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}{{ $item->note ?: '—' }}
+ @if($change->version) + @php($quoteSignature=$change->version->signature) +
+
E 签宝签署与归档
+
+ @if(!$quoteSignature) +
该历史报价版本尚未生成签署原文。
+ @if($canManageSignature)
@csrf
@endif + @else +
+
签署状态
{{ $quoteSignature->statusLabel() }}
+
运行环境
{{ $esignEnvironment==='sandbox'?'沙箱环境':'正式环境' }}
+
原文校验值
{{ substr($quoteSignature->source_pdf_sha256,0,16) }}…
+
最近同步
{{ $quoteSignature->last_synced_at?->format('Y-m-d H:i') ?? '尚未同步' }}
+
签署企业
{{ $quoteSignature->organization_name }}
+
经办人
{{ $quoteSignature->operator_name ?: '尚未填写' }}
+
签署流程
{{ $quoteSignature->esign_sign_flow_id ?: '尚未创建' }}
+
归档时间
{{ $quoteSignature->archived_at?->format('Y-m-d H:i') ?? '尚未归档' }}
+
+ @if($quoteSignature->last_error)@endif +
+ 签署原文 + @if($quoteSignature->signed_pdf_path)已签署报价@endif + @if($quoteSignature->certificate_path)完成证书@endif + @if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')
@csrf
@endif +
+ @if($canManageSignature && $quoteSignature->canSend()) +
@csrf +
+
+
+
+
+
+
+
+ +
+ @if(!$esignConfigured)
E 签宝沙箱配置缺失,当前不能创建签署流程。
@endif +
+ @endif + @endif +
+
+ @endif +
状态与审计记录
@foreach($change->events as $event)
{{ $event->eventLabel() }}
{{ $event->note }}
操作人:{{ $event->actor?->name ?? ('用户 #' . $event->created_by) }}
@endforeach
+ @if($canReview && $change->status==='pending_review')
@csrf
@endif +
+
+
+
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 9077b4e8..3a3d01cb 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -423,16 +423,6 @@ box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10); } - .quote-change-modal__panel { - grid-template-rows: auto minmax(0, 1fr); - width: min(42rem, calc(100vw - 3rem)); - height: auto; - max-height: min(42rem, calc(100vh - 3rem)); - margin: auto 1.5rem auto auto; - border: 1px solid var(--quote-border); - border-radius: 12px; - } - .quote-modal__header { display: flex; min-width: 0; @@ -528,86 +518,6 @@ font-size: 0.7rem; } - .quote-change-form { - display: grid; - min-height: 0; - grid-template-rows: minmax(0, 1fr) auto; - } - - .quote-change-form__body { - display: grid; - gap: 1rem; - min-height: 0; - overflow-y: auto; - padding: 1.25rem; - } - - .quote-change-form__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; - } - - .quote-change-form__field { - display: grid; - gap: 0.4rem; - min-width: 0; - } - - .quote-change-form__field--full { - grid-column: 1 / -1; - } - - .quote-change-form__label { - color: var(--quote-muted); - font-size: 0.75rem; - font-weight: 500; - } - - .quote-change-form__required { - color: #B80000; - } - - .quote-change-form__textarea { - min-height: 8rem; - resize: vertical; - padding: 0.65rem 0.75rem; - line-height: 1.5; - } - - .quote-change-form__notice { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.7rem 0.8rem; - border: 1px solid var(--quote-border); - border-radius: 8px; - background: var(--quote-accent-soft); - color: var(--quote-accent); - font-size: 0.75rem; - line-height: 1.5; - } - - .quote-change-form__notice .material-icons-outlined { - flex: 0 0 auto; - font-size: 18px; - } - - .quote-change-form__status { - min-height: 1.25rem; - color: #53793E; - font-size: 0.75rem; - } - - .quote-change-form__footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.5rem; - padding: 0.85rem 1.25rem; - border-top: 1px solid var(--quote-border); - } - [hidden] { display: none !important; } @@ -700,28 +610,6 @@ width: 100%; } - .quote-change-modal__panel { - width: calc(100% - 1rem); - max-height: calc(100vh - 1rem); - margin: auto 0.5rem; - } - - .quote-change-form__grid { - grid-template-columns: 1fr; - } - - .quote-change-form__field--full { - grid-column: auto; - } - - .quote-change-form__footer { - flex-direction: column-reverse; - align-items: stretch; - } - - .quote-change-form__footer .quote-button { - width: 100%; - } } @@ -789,14 +677,14 @@ - - + @@ -886,60 +774,6 @@ - -