- @foreach ($errors->all() as $error)
- {{ $error }} @endforeach
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 @@
+@foreach ($errors->all() as $error)
+ @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
自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}
{{ $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 ?: '—' }}
{{ $quoteSignature->last_error }}