报价变更与 E 签宝签署闭环 (#47)

This commit is contained in:
limeng 2026-08-15 16:48:12 +08:00
parent 809bca2e5c
commit eb76dc4754
32 changed files with 3374 additions and 283 deletions

View File

@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteChange;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierQuoteSignatureService;
use App\Support\QuoteManagementSnapshot;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
class CourierQuoteChanges extends Controller
{
public function __construct(
private readonly CourierQuoteChangeService $service,
private readonly QuoteManagementSnapshot $snapshot,
private readonly CourierQuoteSignatureService $signatureService,
) {
}
public function index(): View
{
$changes = CourierQuoteChange::query()
->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);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteSignatureService;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierQuoteSignatures extends Controller
{
public function __construct(private readonly CourierQuoteSignatureService $service)
{
}
public function prepare(CourierQuoteVersion $quoteVersion): RedirectResponse
{
$this->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,
'当前账号没有报价签署管理权限。',
);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Integrations;
use App\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class EsignCallbacks extends Controller
{
public function __invoke(Request $request, CourierQuoteSignatureService $service): JsonResponse
{
$appId = trim((string) config('services.esign.app_id'));
$appSecret = trim((string) config('services.esign.app_secret'));
if ($appId === '' || $appSecret === '') {
return response()->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']);
}
}

View File

@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware
* @var array
*/
protected $except = [
//
'integrations/esign/callback',
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Jobs\Business;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ArchiveCourierQuoteSignature implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(public readonly int $signatureId)
{
}
public function handle(CourierQuoteSignatureService $service): void
{
$service->synchronizeById($this->signatureId);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class CourierQuoteChange extends Model
{
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING_REVIEW = 'pending_review';
public const STATUS_RETURNED = 'returned';
public const STATUS_APPROVED = 'approved';
public const RECALCULATION_NOT_REQUIRED = 'not_required';
public const RECALCULATION_PENDING = 'pending';
public const RECALCULATION_BLOCKED = 'blocked';
protected $table = 'business_courier_quote_changes';
protected $fillable = [
'company_id', 'number', 'source_quote_id', 'source_sheet_name', 'carrier_name', 'quote_type',
'change_type', 'status', 'effective_date', 'is_retrospective', 'recalculation_status',
'recalculation_note', 'summary', 'created_by', 'submitted_by', 'submitted_at', 'approved_by',
'approved_at', 'new_quote_version_id',
];
protected $casts = [
'company_id' => '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,
};
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierQuoteChangeEvent extends Model
{
protected $table = 'business_courier_quote_change_events';
protected $fillable = [
'company_id', 'quote_change_id', 'event_type', 'from_status', 'to_status', 'note', 'details_json', 'created_by',
];
protected $casts = ['company_id' => '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,
};
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierQuoteChangeItem extends Model
{
protected $table = 'business_courier_quote_change_items';
protected $fillable = [
'company_id', 'quote_change_id', 'position', 'source_cell', 'source_row', 'source_column',
'region', 'weight_band', 'charge_item', 'original_amount', 'new_amount', 'unit', 'note',
];
protected $casts = [
'company_id' => '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');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierQuoteSignature extends Model
{
public const STATUS_PREPARED = 'prepared';
public const STATUS_SENDING = 'sending';
public const STATUS_SIGNING = 'signing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_REVOKED = 'revoked';
public const STATUS_EXPIRED = 'expired';
public const STATUS_REJECTED = 'rejected';
public const STATUS_FAILED = 'failed';
protected $table = 'business_courier_quote_signatures';
protected $fillable = [
'company_id', 'quote_version_id', 'provider', 'environment', 'status', 'organization_name',
'operator_name', 'operator_account', 'notice_types', 'expires_at', 'source_pdf_path',
'source_pdf_sha256', 'source_pdf_generated_at', 'esign_file_id', 'esign_sign_flow_id',
'remote_status', 'signed_pdf_path', 'signed_pdf_sha256', 'certificate_path',
'certificate_sha256', 'sent_at', 'completed_at', 'archived_at', 'last_synced_at',
'last_error', 'callback_payload_hash', 'created_by', 'updated_by',
];
protected $casts = [
'company_id' => '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);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class CourierQuoteVersion extends Model
{
protected $table = 'business_courier_quote_versions';
protected $fillable = [
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'quote_type', 'effective_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by',
];
protected $casts = [
'company_id' => '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');
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace App\Services\Esign;
use Illuminate\Support\Facades\Http;
use JsonException;
use RuntimeException;
final class EsignClient
{
private readonly string $baseUrl;
private readonly EsignRequestSigner $signer;
public function __construct()
{
$appId = trim((string) config('services.esign.app_id'));
$appSecret = trim((string) config('services.esign.app_secret'));
if ($appId === '' || $appSecret === '') {
throw new RuntimeException('E 签宝沙箱尚未配置 App ID 或 AppSecret。');
}
$this->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));
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Services\Esign;
use InvalidArgumentException;
final class EsignRequestSigner
{
public const ACCEPT = '*/*';
public const CONTENT_TYPE = 'application/json; charset=UTF-8';
public function __construct(
private readonly string $appId,
private readonly string $appSecret,
) {
if ($this->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;
}
}

View File

@ -0,0 +1,317 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteChangeEvent;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class CourierQuoteChangeService
{
public function __construct(private readonly QuoteManagementSnapshot $snapshot)
{
}
public function create(array $data, int $userId): CourierQuoteChange
{
$source = $this->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 保持不变。';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteVersion;
use RuntimeException;
final class CourierQuotePdf
{
public function render(CourierQuoteVersion $version): string
{
$version->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 : [];
}
}

View File

@ -0,0 +1,298 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
final class CourierQuoteSignatureService
{
public function __construct(private readonly CourierQuotePdf $pdf)
{
}
public function prepareVersion(CourierQuoteVersion $version, int $userId): CourierQuoteSignature
{
$existing = $version->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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -0,0 +1,218 @@
<x-layouts.admin>
<x-slot name="title">{{ $change ? '编辑报价变更' : '发起报价变更' }}</x-slot>
<x-slot name="content">
<style>
.quote-change-workspace {
--qc-ink: var(--vip-content-ink, #171717);
--qc-muted: var(--vip-content-muted, #595959);
--qc-gold: var(--vip-content-accent-dark, #856632);
--qc-gold-soft: var(--vip-content-accent-soft, #F7F0DE);
--qc-border: var(--vip-border, #D8D2C7);
margin: 24px 40px;
color: var(--qc-ink);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: 13px;
}
.quote-change-workspace *, .quote-change-workspace *::before, .quote-change-workspace *::after { box-sizing: border-box; }
.quote-change-breadcrumb { display: flex; align-items: center; gap: 6px; margin-bottom: 16px; color: var(--qc-muted); font-size: 12px; }
.quote-change-breadcrumb a { color: var(--qc-gold); }
.quote-change-panel { border: 1px solid var(--qc-border); border-radius: 12px; background: #fff; }
.quote-change-panel + .quote-change-panel { margin-top: 16px; }
.quote-change-panel__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 58px; padding: 12px 16px; border-bottom: 1px solid var(--qc-border); }
.quote-change-panel__title { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; }
.quote-change-panel__title .material-icons-outlined { color: var(--qc-gold); font-size: 19px; }
.quote-change-panel__hint { margin-top: 3px; color: var(--qc-muted); font-size: 12px; }
.quote-change-panel__body { padding: 16px; }
.quote-change-alert { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--qc-border); border-radius: 8px; background: var(--qc-gold-soft); color: #513E21; font-size: 12px; line-height: 1.5; }
.quote-change-alert--error { border-color: #E6B7B7; background: #FFF4F4; color: #8A1515; }
.quote-change-alert--retrospective { margin: 14px 0 0; border-color: #D9BD88; background: #FFF8E8; color: #704800; }
.quote-change-alert[hidden] { display: none; }
.quote-change-alert .material-icons-outlined { flex: 0 0 auto; font-size: 18px; }
.quote-change-source { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; }
.quote-change-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.quote-change-field { display: grid; gap: 6px; }
.quote-change-field--full { grid-column: 1 / -1; }
.quote-change-field label { color: var(--qc-ink); font-size: 12px; font-weight: 500; }
.quote-change-required { color: #B80000; }
.quote-change-control { width: 100%; height: 34px; border: 1px solid var(--qc-border); border-radius: 8px; background: #fff; padding: 0 10px; color: var(--qc-ink); font: inherit; }
textarea.quote-change-control { min-height: 88px; height: auto; padding: 9px 10px; resize: vertical; }
.quote-change-control:focus { border-color: var(--qc-gold); outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .2); }
.quote-change-control[readonly] { background: #F8F6F1; color: var(--qc-muted); }
.quote-change-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; border: 1px solid var(--qc-border); border-radius: 8px; background: #fff; padding: 0 14px; color: var(--qc-ink); font: inherit; font-weight: 500; white-space: nowrap; cursor: pointer; }
.quote-change-button:hover { border-color: var(--qc-gold); }
.quote-change-button:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .2); }
.quote-change-button--primary { border-color: var(--qc-gold); background: var(--qc-gold); color: #fff; }
.quote-change-button--primary:hover { border-color: #6A5129; background: #6A5129; }
.quote-change-button .material-icons-outlined { font-size: 18px; }
.quote-change-source-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 14px; padding: 12px; border: 1px solid var(--qc-border); border-radius: 8px; background: #F8F6F1; }
.quote-change-source-meta dt { color: var(--qc-muted); font-size: 11px; }
.quote-change-source-meta dd { margin: 3px 0 0; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.quote-batch-tools { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(130px, .6fr) auto; gap: 8px; align-items: end; }
.quote-change-table-wrap { overflow: auto; max-height: 540px; border: 1px solid var(--qc-border); border-radius: 8px; }
.quote-change-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.quote-change-table th { position: sticky; top: 0; z-index: 1; padding: 9px 10px; border-bottom: 1px solid var(--qc-border); background: var(--qc-gold-soft); color: var(--qc-ink); font-size: 12px; font-weight: 600; text-align: left; white-space: nowrap; }
.quote-change-table td { padding: 7px 10px; border-bottom: 1px solid #ECE8E0; vertical-align: middle; }
.quote-change-table tbody tr:last-child td { border-bottom: 0; }
.quote-change-table tbody tr.is-changed td { background: #FFFDF7; }
.quote-change-table__number { text-align: right !important; font-variant-numeric: tabular-nums; }
.quote-change-price-input { width: 108px; height: 30px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; text-align: right; font: inherit; font-variant-numeric: tabular-nums; }
.quote-change-price-input:focus { border-color: var(--qc-gold); outline: 0; box-shadow: 0 0 0 2px rgba(133, 102, 50, .16); }
.quote-change-delta { font-weight: 600; font-variant-numeric: tabular-nums; }
.quote-change-delta--up { color: #935F07; }
.quote-change-delta--down { color: #53793E; }
.quote-change-note-input { width: 180px; height: 30px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; font: inherit; }
.quote-change-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding: 12px 16px; border: 1px solid var(--qc-border); border-radius: 12px; background: rgba(255, 255, 255, .96); }
.quote-change-footer__summary { color: var(--qc-muted); font-size: 12px; }
.quote-change-footer__summary strong { color: var(--qc-ink); font-variant-numeric: tabular-nums; }
.quote-change-footer__actions { display: flex; gap: 8px; }
@media (max-width: 900px) {
.quote-change-workspace { margin: 16px; }
.quote-change-form-grid, .quote-change-source-meta { grid-template-columns: 1fr; }
.quote-change-field--full { grid-column: auto; }
.quote-batch-tools { grid-template-columns: 1fr; }
.quote-change-footer { align-items: stretch; flex-direction: column; }
.quote-change-footer__actions { justify-content: flex-end; }
}
</style>
<main class="quote-change-workspace" data-quote-change-editor data-today="{{ now()->toDateString() }}">
<nav class="quote-change-breadcrumb" aria-label="当前位置">
<a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">报价管理</a>
<span class="material-icons-outlined" aria-hidden="true" style="font-size:15px">chevron_right</span>
<span>{{ $change ? $change->number : '新建变更单' }}</span>
</nav>
@if (is_string(session('success')))
<div class="quote-change-alert" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span><span>{{ session('success') }}</span></div>
@endif
@if ($errors->any())
<div class="quote-change-alert quote-change-alert--error" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<div><strong>草稿未保存,请检查:</strong><ul>@foreach ($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
</div>
@endif
@unless ($change)
<section class="quote-change-panel" aria-labelledby="quote-source-title">
<header class="quote-change-panel__header">
<div><div class="quote-change-panel__title" id="quote-source-title"><span class="material-icons-outlined" aria-hidden="true">source</span><span>第一步:选择原报价</span></div><div class="quote-change-panel__hint">原报价保持只读,系统只保存发生变化的单元格。</div></div>
</header>
<div class="quote-change-panel__body">
<form class="quote-change-source" method="GET" action="{{ route('business.courier-quote-changes.create') }}">
<div class="quote-change-field"><label for="source_quote_id">报价版本</label><select class="quote-change-control" id="source_quote_id" name="source_quote_id">@foreach ($quotes as $quote)<option value="{{ $quote['id'] }}" @selected($quote['id'] === $source['id'])>{{ $quote['id'] }} · {{ $quote['company_name'] }} · {{ $quote['quote_type'] }}</option>@endforeach</select></div>
<button class="quote-change-button" type="submit"><span class="material-icons-outlined" aria-hidden="true">sync_alt</span><span>载入报价</span></button>
</form>
</div>
</section>
@endunless
<form method="POST" action="{{ $change ? route('business.courier-quote-changes.update', $change) : route('business.courier-quote-changes.store') }}" data-quote-change-form>
@csrf
@if ($change) @method('PATCH') @else <input type="hidden" name="source_quote_id" value="{{ $source['id'] }}"> @endif
<section class="quote-change-panel" aria-labelledby="quote-base-title">
<header class="quote-change-panel__header"><div><div class="quote-change-panel__title" id="quote-base-title"><span class="material-icons-outlined" aria-hidden="true">edit_note</span><span>{{ $change ? '变更单基本信息' : '第二步:填写变更信息' }}</span></div><div class="quote-change-panel__hint">生效日期可以早于今天;历史日期会进入追溯核算流程。</div></div></header>
<div class="quote-change-panel__body">
<div class="quote-change-source-meta">
<div><dt>原报价版本</dt><dd>{{ $source['id'] }}</dd></div><div><dt>快递公司</dt><dd>{{ $source['company_name'] }}</dd></div><div><dt>报价类型</dt><dd>{{ $source['quote_type'] }}</dd></div><div><dt>原执行日期</dt><dd>{{ $source['effective_date'] ?? '未标注' }}</dd></div>
</div>
<div class="quote-change-form-grid" style="margin-top:16px">
<div class="quote-change-field"><label for="change_type">变更类型 <span class="quote-change-required">*</span></label><select class="quote-change-control" id="change_type" name="change_type" required>@foreach (['费率调整', '面单调整', '规则备注', '执行日期', '其他'] as $type)<option value="{{ $type }}" @selected(old('change_type', $change?->change_type ?? '费率调整') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="quote-change-field"><label for="effective_date">生效日期 <span class="quote-change-required">*</span></label><input class="quote-change-control" id="effective_date" name="effective_date" type="date" value="{{ old('effective_date', $change?->effective_date?->toDateString() ?? now()->toDateString()) }}" data-effective-date required></div>
<div class="quote-change-field quote-change-field--full"><label for="summary">变更依据与说明 <span class="quote-change-required">*</span></label><textarea class="quote-change-control" id="summary" name="summary" maxlength="2000" required placeholder="请说明调整区域、重量段、金额及业务依据">{{ old('summary', $change?->summary) }}</textarea></div>
</div>
<div class="quote-change-alert quote-change-alert--retrospective" data-retrospective-alert hidden role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><span>这是历史生效日期。审核通过后应从 <strong data-retrospective-date></strong> 起追溯核算,只新增补款或扣款明细,不覆盖原运费。当前报价缺少可执行计算方式和逐票报价版本关联,在这些条件完整前系统不会自动写入可能错误的补差金额。</span></div>
</div>
</section>
<section class="quote-change-panel" aria-labelledby="quote-detail-edit-title">
<header class="quote-change-panel__header"><div><div class="quote-change-panel__title" id="quote-detail-edit-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>{{ $change ? '变价明细' : '第三步:编辑新价格' }}</span></div><div class="quote-change-panel__hint">仅新价格可编辑;系统会再次核对原价格,防止过期数据覆盖。</div></div></header>
<div class="quote-change-panel__body">
<div class="quote-change-alert"><span class="material-icons-outlined" aria-hidden="true">verified_user</span><span>保存草稿不会让报价生效。提交审核后草稿将锁定,由另一位管理员审核并生成新版本。</span></div>
<div class="quote-batch-tools" aria-label="批量调整">
<div class="quote-change-field"><label for="batch_mode">批量方式</label><select class="quote-change-control" id="batch_mode" data-batch-mode><option value="add">统一加减金额</option><option value="percent">按比例调整</option><option value="set">统一设为金额</option></select></div>
<div class="quote-change-field"><label for="batch_value">调整值</label><input class="quote-change-control" id="batch_value" type="number" step="0.0001" data-batch-value placeholder="例如 1 或 -0.5"></div>
<button class="quote-change-button" type="button" data-batch-apply><span class="material-icons-outlined" aria-hidden="true">calculate</span><span>应用到勾选行</span></button>
</div>
<div class="quote-change-table-wrap" style="margin-top:12px">
<table class="quote-change-table">
<thead><tr><th scope="col"><input type="checkbox" data-select-all aria-label="全选可调整行"></th><th scope="col">单元格</th><th scope="col">地区/规则</th><th scope="col">重量段/计费项</th><th scope="col" class="quote-change-table__number">原价格</th><th scope="col" class="quote-change-table__number">新价格</th><th scope="col" class="quote-change-table__number">差额</th><th scope="col">备注</th></tr></thead>
<tbody>
@foreach ($editableCells as $index => $cell)
<tr data-price-row data-original="{{ $cell['original_amount'] }}">
<td><input type="checkbox" data-row-select aria-label="选择 {{ $cell['source_cell'] }}" @checked($cell['selected'])></td>
<td><strong>{{ $cell['source_cell'] }}</strong><input type="hidden" name="items[{{ $index }}][source_cell]" value="{{ $cell['source_cell'] }}"><input type="hidden" name="items[{{ $index }}][source_row]" value="{{ $cell['source_row'] }}"><input type="hidden" name="items[{{ $index }}][source_column]" value="{{ $cell['source_column'] }}"></td>
<td>{{ $cell['region'] ?: '—' }}<input type="hidden" name="items[{{ $index }}][region]" value="{{ $cell['region'] }}"></td>
<td>{{ $cell['weight_band'] ?: ($cell['charge_item'] ?: '—') }}<input type="hidden" name="items[{{ $index }}][weight_band]" value="{{ $cell['weight_band'] }}"><input type="hidden" name="items[{{ $index }}][charge_item]" value="{{ $cell['charge_item'] }}"></td>
<td class="quote-change-table__number">{{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}<input type="hidden" name="items[{{ $index }}][original_amount]" value="{{ $cell['original_amount'] }}"></td>
<td class="quote-change-table__number"><input class="quote-change-price-input" type="number" name="items[{{ $index }}][new_amount]" min="0" step="0.0001" value="{{ old("items.$index.new_amount", $cell['new_amount']) }}" data-new-price required></td>
<td class="quote-change-table__number"><span class="quote-change-delta" data-price-delta>0.0000</span></td>
<td><input class="quote-change-note-input" name="items[{{ $index }}][note]" maxlength="500" value="{{ old("items.$index.note", $cell['note']) }}" placeholder="选填"><input type="hidden" name="items[{{ $index }}][unit]" value="{{ $cell['unit'] }}"></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</section>
<footer class="quote-change-footer">
<div class="quote-change-footer__summary">当前已修改 <strong data-changed-count>0</strong> 个价格单元格;原始工作簿不会被修改。</div>
<div class="quote-change-footer__actions"><a class="quote-change-button" href="{{ $change ? route('business.courier-quote-changes.show', $change) : route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">取消</a><button class="quote-change-button quote-change-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">save</span><span>保存草稿</span></button></div>
</footer>
</form>
@if ($change)
<form method="POST" action="{{ route('business.courier-quote-changes.submit', $change) }}" style="display:flex;justify-content:flex-end;margin-top:12px" onsubmit="return confirm('提交后草稿将锁定并进入审核,确认继续吗?')">@csrf<button class="quote-change-button" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>
@endif
</main>
<script>
(function () {
var root = document.querySelector('[data-quote-change-editor]');
if (!root || root.dataset.initialized === 'true') return;
root.dataset.initialized = 'true';
var rows = Array.from(root.querySelectorAll('[data-price-row]'));
var count = root.querySelector('[data-changed-count]');
var effectiveDate = root.querySelector('[data-effective-date]');
var retrospectiveAlert = root.querySelector('[data-retrospective-alert]');
function refreshRetrospective() {
if (!effectiveDate || !retrospectiveAlert) return;
var isRetrospective = effectiveDate.value !== '' && effectiveDate.value < root.dataset.today;
retrospectiveAlert.hidden = !isRetrospective;
if (isRetrospective) retrospectiveAlert.querySelector('[data-retrospective-date]').textContent = effectiveDate.value;
}
function refreshRow(row) {
var original = Number(row.dataset.original);
var input = row.querySelector('[data-new-price]');
var next = Number(input.value);
var delta = Number.isFinite(next) ? next - original : 0;
var output = row.querySelector('[data-price-delta]');
output.textContent = (delta > 0 ? '+' : '') + delta.toFixed(4);
output.className = 'quote-change-delta' + (delta > 0 ? ' quote-change-delta--up' : delta < 0 ? ' quote-change-delta--down' : '');
row.classList.toggle('is-changed', Math.abs(delta) > 0.00005);
if (Math.abs(delta) > 0.00005) row.querySelector('[data-row-select]').checked = true;
}
function refreshAll() { rows.forEach(refreshRow); count.textContent = String(rows.filter(function (row) { return row.classList.contains('is-changed'); }).length); }
rows.forEach(function (row) { row.querySelector('[data-new-price]').addEventListener('input', refreshAll); });
if (effectiveDate) effectiveDate.addEventListener('change', refreshRetrospective);
root.querySelector('[data-select-all]').addEventListener('change', function (event) { rows.forEach(function (row) { row.querySelector('[data-row-select]').checked = event.target.checked; }); });
root.querySelector('[data-batch-apply]').addEventListener('click', function () {
var value = Number(root.querySelector('[data-batch-value]').value);
if (!Number.isFinite(value)) { root.querySelector('[data-batch-value]').focus(); return; }
var mode = root.querySelector('[data-batch-mode]').value;
rows.filter(function (row) { return row.querySelector('[data-row-select]').checked; }).forEach(function (row) {
var input = row.querySelector('[data-new-price]');
var current = Number(input.value);
var result = mode === 'add' ? current + value : mode === 'percent' ? current * (1 + value / 100) : value;
input.value = Math.max(0, result).toFixed(4);
});
refreshAll();
});
refreshAll();
refreshRetrospective();
}());
</script>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,56 @@
<x-layouts.admin>
<x-slot name="title">报价变更历史</x-slot>
<x-slot name="content">
<style>
.quote-history { --qh-ink:var(--vip-content-ink,#171717); --qh-muted:var(--vip-content-muted,#595959); --qh-gold:var(--vip-content-accent-dark,#856632); --qh-soft:var(--vip-content-accent-soft,#F7F0DE); --qh-border:var(--vip-border,#D8D2C7); margin:24px 40px; color:var(--qh-ink); font-family:"JetBrains Mono","Microsoft YaHei","PingFang SC",monospace; font-size:13px; }
.quote-history * { box-sizing:border-box; }
.quote-history__top { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:16px; }
.quote-history__back { display:inline-flex; align-items:center; gap:5px; color:var(--qh-gold); font-size:12px; }
.quote-history__actions { display:flex; gap:8px; }
.quote-history__button { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:34px; border:1px solid var(--qh-border); border-radius:8px; background:#fff; padding:0 14px; color:var(--qh-ink); font:inherit; font-weight:500; }
.quote-history__button--primary { border-color:var(--qh-gold); background:var(--qh-gold); color:#fff; }
.quote-history__button .material-icons-outlined { font-size:18px; }
.quote-history__panel { overflow:hidden; border:1px solid var(--qh-border); border-radius:12px; background:#fff; }
.quote-history__header { padding:14px 16px; border-bottom:1px solid var(--qh-border); }
.quote-history__title { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; }
.quote-history__title .material-icons-outlined { color:var(--qh-gold); font-size:19px; }
.quote-history__hint { margin-top:4px; color:var(--qh-muted); font-size:12px; }
.quote-history__table-wrap { overflow-x:auto; }
.quote-history table { width:100%; border-collapse:collapse; font-size:12.5px; }
.quote-history th { padding:10px 12px; border-bottom:1px solid var(--qh-border); background:var(--qh-soft); font-size:12px; font-weight:600; text-align:left; white-space:nowrap; }
.quote-history td { padding:10px 12px; border-bottom:1px solid #ECE8E0; white-space:nowrap; }
.quote-history tbody tr:last-child td { border-bottom:0; }
.quote-history__number { font-weight:600; font-variant-numeric:tabular-nums; }
.quote-history__badge { display:inline-flex; align-items:center; min-height:24px; border:1px solid var(--qh-border); border-radius:999px; padding:2px 9px; background:#fff; font-size:11px; }
.quote-history__badge--pending { border-color:#D9BD88; background:#FFF8E8; color:#7A4C00; }
.quote-history__badge--approved { border-color:#BAD0AB; background:#F3F8EF; color:#40622F; }
.quote-history__badge--returned { border-color:#E6B7B7; background:#FFF4F4; color:#8A1515; }
.quote-history__link { color:var(--qh-gold); font-weight:500; }
.quote-history__empty { padding:56px 16px; color:var(--qh-muted); text-align:center; }
.quote-history__empty .material-icons-outlined { display:block; margin-bottom:8px; color:var(--qh-gold); font-size:32px; }
.quote-history__pagination { padding:12px 16px; border-top:1px solid var(--qh-border); }
@media(max-width:800px){.quote-history{margin:16px}.quote-history__top{align-items:stretch;flex-direction:column}.quote-history__actions{justify-content:flex-end}}
</style>
<main class="quote-history">
<div class="quote-history__top">
<a class="quote-history__back" href="{{ route('business.pages.show', ['section'=>'courier-payables','page'=>'quote-management']) }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价管理</span></a>
<div class="quote-history__actions"><a class="quote-history__button quote-history__button--primary" href="{{ route('business.courier-quote-changes.create') }}"><span class="material-icons-outlined" aria-hidden="true">edit_note</span><span>发起报价变更</span></a></div>
</div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
<section class="quote-history__panel" aria-labelledby="quote-history-title">
<header class="quote-history__header"><div class="quote-history__title" id="quote-history-title"><span class="material-icons-outlined" aria-hidden="true">history</span><span>报价变更记录</span></div><div class="quote-history__hint">草稿、审核和新版本按当前公司隔离保存,历史记录不可覆盖原报价。</div></header>
@if($changes->isEmpty())
<div class="quote-history__empty"><span class="material-icons-outlined" aria-hidden="true">difference</span><strong>还没有报价变更记录</strong><div>发起第一张变更单后,草稿和审核进度会显示在这里。</div></div>
@else
<div class="quote-history__table-wrap"><table><thead><tr><th>变更单号</th><th>原报价</th><th>快递公司</th><th>变更类型</th><th>明细</th><th>生效日期</th><th>生效方式</th><th>追溯核算</th><th>状态</th><th>新版本</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
@foreach($changes as $change)
@php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''})
<tr><td class="quote-history__number">{{ $change->number }}</td><td>{{ $change->source_quote_id }}</td><td>{{ $change->carrier_name }}</td><td>{{ $change->change_type }}</td><td>{{ $change->items->count() }} 项</td><td>{{ $change->effective_date->toDateString() }}</td><td><span class="quote-history__badge @if($change->isRetrospective()) quote-history__badge--pending @endif">{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</span></td><td><span class="quote-history__badge @if($change->recalculation_status==='blocked') quote-history__badge--returned @elseif($change->recalculation_status==='pending') quote-history__badge--pending @endif">{{ $change->recalculationStatusLabel() }}</span></td><td><span class="quote-history__badge @if($badge) quote-history__badge--{{ $badge }} @endif">{{ $change->statusLabel() }}</span></td><td>{{ $change->version?->version_code ?? '—' }}</td><td>{{ $change->updated_at?->format('Y-m-d H:i') }}</td><td><a class="quote-history__link" href="{{ route('business.courier-quote-changes.show',$change) }}">查看</a>@if($change->isEditable() && $change->created_by===user_id()) · <a class="quote-history__link" href="{{ route('business.courier-quote-changes.edit',$change) }}">编辑</a>@endif</td></tr>
@endforeach
</tbody></table></div><div class="quote-history__pagination">{{ $changes->links() }}</div>
@endif
</section>
</main>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,118 @@
<x-layouts.admin>
<x-slot name="title">报价变更详情</x-slot>
<x-slot name="content">
<style>
.quote-review { --qr-ink:var(--vip-content-ink,#171717); --qr-muted:var(--vip-content-muted,#595959); --qr-gold:var(--vip-content-accent-dark,#856632); --qr-soft:var(--vip-content-accent-soft,#F7F0DE); --qr-border:var(--vip-border,#D8D2C7); margin:24px 40px; color:var(--qr-ink); font-family:"JetBrains Mono","Microsoft YaHei","PingFang SC",monospace; font-size:13px; }
.quote-review * { box-sizing:border-box; }
.quote-review__toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; }
.quote-review__back,.quote-review__link { color:var(--qr-gold); }
.quote-review__back { display:inline-flex; align-items:center; gap:5px; font-size:12px; }
.quote-review__actions { display:flex; flex-wrap:wrap; gap:8px; }
.quote-review__button { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:34px; border:1px solid var(--qr-border); border-radius:8px; background:#fff; padding:0 14px; color:var(--qr-ink); font:inherit; font-weight:500; cursor:pointer; }
.quote-review__button--primary { border-color:var(--qr-gold); background:var(--qr-gold); color:#fff; }
.quote-review__button--danger { border-color:#B80000; color:#8A1515; }
.quote-review__button .material-icons-outlined { font-size:18px; }
.quote-review__panel { margin-bottom:16px; overflow:hidden; border:1px solid var(--qr-border); border-radius:8px; background:#fff; }
.quote-review__header { padding:13px 16px; border-bottom:1px solid var(--qr-border); }
.quote-review__title { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; }
.quote-review__title .material-icons-outlined { color:var(--qr-gold); font-size:19px; }
.quote-review__body { padding:16px; }
.quote-review__meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
.quote-review__meta dt { color:var(--qr-muted); font-size:11px; }
.quote-review__meta dd { margin:4px 0 0; font-size:12.5px; font-variant-numeric:tabular-nums; }
.quote-review__summary { margin-top:16px; border-top:1px solid var(--qr-border); padding-top:14px; line-height:1.65; white-space:pre-wrap; }
.quote-review__alert { display:flex; align-items:flex-start; gap:8px; margin-bottom:16px; border:1px solid #D9BD88; border-radius:8px; background:#FFF8E8; padding:10px 12px; color:#704800; font-size:12px; line-height:1.6; }
.quote-review__alert .material-icons-outlined { flex:0 0 auto; font-size:18px; }
.quote-review__table-wrap { overflow-x:auto; }
.quote-review table { width:100%; border-collapse:collapse; font-size:12.5px; }
.quote-review th { padding:10px 12px; border-bottom:1px solid var(--qr-border); background:var(--qr-soft); font-size:12px; font-weight:600; text-align:left; white-space:nowrap; }
.quote-review td { padding:10px 12px; border-bottom:1px solid #ECE8E0; }
.quote-review tbody tr:last-child td { border-bottom:0; }
.quote-review__number { text-align:right!important; font-variant-numeric:tabular-nums; }
.quote-review__delta { color:#935F07; font-weight:600; }
.quote-review__timeline { display:grid; gap:0; }
.quote-review__event { position:relative; display:grid; grid-template-columns:130px 1fr; gap:16px; padding:0 0 18px 22px; }
.quote-review__event::before { position:absolute; top:4px; bottom:-4px; left:6px; width:1px; background:var(--qr-border); content:''; }
.quote-review__event::after { position:absolute; top:4px; left:2px; width:9px; height:9px; border:2px solid var(--qr-gold); border-radius:50%; background:#fff; content:''; }
.quote-review__event:last-child::before { display:none; }
.quote-review__event-time { color:var(--qr-muted); font-size:11px; }
.quote-review__event-title { font-weight:600; }
.quote-review__event-note { margin-top:3px; color:var(--qr-muted); font-size:12px; }
.quote-review__event-actor { margin-top:3px; color:var(--qr-muted); font-size:11px; }
.quote-review__review-form { display:flex; align-items:end; gap:8px; margin-top:12px; }
.quote-review__field { flex:1; display:grid; gap:6px; }
.quote-review__field label { font-size:12px; font-weight:500; }
.quote-review__control { width:100%; min-height:34px; border:1px solid var(--qr-border); border-radius:8px; padding:8px 10px; font:inherit; }
.quote-review__signature-meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
.quote-review__signature-meta dt { color:var(--qr-muted); font-size:11px; }
.quote-review__signature-meta dd { margin:4px 0 0; overflow-wrap:anywhere; font-size:12.5px; }
.quote-review__status { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
.quote-review__status::before { width:7px; height:7px; border-radius:50%; background:var(--qr-gold); content:''; }
.quote-review__files { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
.quote-review__form { margin-top:16px; border-top:1px solid var(--qr-border); padding-top:16px; }
.quote-review__form-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
.quote-review__form-actions { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:14px; }
.quote-review__choice { display:flex; align-items:center; gap:14px; min-height:34px; }
.quote-review__choice label { display:inline-flex; align-items:center; gap:5px; font-size:12px; }
.quote-review__hint { color:var(--qr-muted); font-size:11px; line-height:1.55; }
@media(max-width:1000px){.quote-review__form-grid{grid-template-columns:1fr 1fr}.quote-review__signature-meta{grid-template-columns:1fr 1fr}}
@media(max-width:800px){.quote-review{margin:16px}.quote-review__toolbar{align-items:stretch;flex-direction:column}.quote-review__meta,.quote-review__signature-meta,.quote-review__form-grid{grid-template-columns:1fr}.quote-review__event{grid-template-columns:1fr}.quote-review__review-form,.quote-review__form-actions{align-items:stretch;flex-direction:column}}
</style>
<main class="quote-review">
<div class="quote-review__toolbar"><a class="quote-review__back" href="{{ route('business.courier-quote-changes.index') }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价历史</span></a><div class="quote-review__actions">@if($change->isEditable() && $change->created_by===user_id())<a class="quote-review__button" href="{{ route('business.courier-quote-changes.edit',$change) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑草稿</span></a><form method="POST" action="{{ route('business.courier-quote-changes.submit',$change) }}">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>@endif @if($canReview && $change->status==='pending_review')<form method="POST" action="{{ route('business.courier-quote-changes.approve',$change) }}" onsubmit="return confirm('审核通过后将生成不可覆盖的新报价版本,确认继续吗?')">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>审核通过</span></button></form>@endif</div></div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
@if($errors->any())<div style="margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;padding:10px 12px;color:#8A1515" role="alert">{{ $errors->first() }}</div>@endif
@if($change->isRetrospective())<div class="quote-review__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><div><strong>追溯变价 · {{ $change->recalculationStatusLabel() }}</strong><br>自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}</div></div>@endif
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>生效日期</dt><dd>{{ $change->effective_date->toDateString() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $change->items->count() }} 项</dd></div><div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div><div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
<section class="quote-review__panel" aria-labelledby="review-diff-title"><header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧价格差异</span></div></header><div class="quote-review__table-wrap"><table><thead><tr><th>单元格</th><th>地区/规则</th><th>重量段/计费项</th><th class="quote-review__number">原价格</th><th class="quote-review__number">新价格</th><th class="quote-review__number">差额</th><th>备注</th></tr></thead><tbody>@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)<tr><td><strong>{{ $item->source_cell }}</strong></td><td>{{ $item->region ?: '—' }}</td><td>{{ $item->weight_band ?: ($item->charge_item ?: '—') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->original_amount,'0'),'.') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->new_amount,'0'),'.') }}</td><td class="quote-review__number quote-review__delta">{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}</td><td>{{ $item->note ?: '—' }}</td></tr>@endforeach</tbody></table></div></section>
@if($change->version)
@php($quoteSignature=$change->version->signature)
<section class="quote-review__panel" aria-labelledby="review-signature-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-signature-title"><span class="material-icons-outlined" aria-hidden="true">draw</span><span>E 签宝签署与归档</span></div></header>
<div class="quote-review__body">
@if(!$quoteSignature)
<div class="quote-review__hint">该历史报价版本尚未生成签署原文。</div>
@if($canManageSignature)<form method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}" style="margin-top:12px">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></form>@endif
@else
<dl class="quote-review__signature-meta">
<div><dt>签署状态</dt><dd><span class="quote-review__status">{{ $quoteSignature->statusLabel() }}</span></dd></div>
<div><dt>运行环境</dt><dd>{{ $esignEnvironment==='sandbox'?'沙箱环境':'正式环境' }}</dd></div>
<div><dt>原文校验值</dt><dd title="{{ $quoteSignature->source_pdf_sha256 }}">{{ substr($quoteSignature->source_pdf_sha256,0,16) }}…</dd></div>
<div><dt>最近同步</dt><dd>{{ $quoteSignature->last_synced_at?->format('Y-m-d H:i') ?? '尚未同步' }}</dd></div>
<div><dt>签署企业</dt><dd>{{ $quoteSignature->organization_name }}</dd></div>
<div><dt>经办人</dt><dd>{{ $quoteSignature->operator_name ?: '尚未填写' }}</dd></div>
<div><dt>签署流程</dt><dd>{{ $quoteSignature->esign_sign_flow_id ?: '尚未创建' }}</dd></div>
<div><dt>归档时间</dt><dd>{{ $quoteSignature->archived_at?->format('Y-m-d H:i') ?? '尚未归档' }}</dd></div>
</dl>
@if($quoteSignature->last_error)<div class="quote-review__alert" role="alert" style="margin-top:14px;margin-bottom:0"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><div><strong>最近一次处理失败</strong><br>{{ $quoteSignature->last_error }}</div></div>@endif
<div class="quote-review__files">
<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'source']) }}"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>签署原文</span></a>
@if($quoteSignature->signed_pdf_path)<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'signed']) }}"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>已签署报价</span></a>@endif
@if($quoteSignature->certificate_path)<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'certificate']) }}"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span><span>完成证书</span></a>@endif
@if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')<form method="POST" action="{{ route('business.courier-quote-signatures.synchronize',$quoteSignature) }}">@csrf<button class="quote-review__button" type="submit"><span class="material-icons-outlined" aria-hidden="true">sync</span><span>同步签署状态</span></button></form>@endif
</div>
@if($canManageSignature && $quoteSignature->canSend())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.send',$quoteSignature) }}">@csrf
<div class="quote-review__form-grid">
<div class="quote-review__field"><label for="organization_name">签署企业全称</label><input class="quote-review__control" id="organization_name" name="organization_name" value="{{ old('organization_name',$quoteSignature->organization_name) }}" maxlength="255" required></div>
<div class="quote-review__field"><label for="operator_name">经办人姓名</label><input class="quote-review__control" id="operator_name" name="operator_name" value="{{ old('operator_name',$quoteSignature->operator_name) }}" maxlength="100" autocomplete="name" required></div>
<div class="quote-review__field"><label for="operator_account">手机号或邮箱</label><input class="quote-review__control" id="operator_account" name="operator_account" value="{{ old('operator_account',$quoteSignature->operator_account) }}" maxlength="255" autocomplete="email" required></div>
<div class="quote-review__field"><label for="expires_at">签署截止时间</label><input class="quote-review__control" id="expires_at" name="expires_at" type="datetime-local" value="{{ old('expires_at',now()->addDays(7)->format('Y-m-d\TH:i')) }}" required></div>
</div>
<div class="quote-review__form-actions">
<div class="quote-review__choice" role="radiogroup" aria-label="通知方式"><label><input type="radio" name="notice_type" value="1" {{ old('notice_type','1')==='1'?'checked':'' }}>短信</label><label><input type="radio" name="notice_type" value="2" {{ old('notice_type')==='2'?'checked':'' }}>邮件</label></div>
<button class="quote-review__button quote-review__button--primary" type="submit" @disabled(!$esignConfigured)><span class="material-icons-outlined" aria-hidden="true">send</span><span>推送到 E 签宝</span></button>
</div>
@if(!$esignConfigured)<div class="quote-review__hint" role="status" style="margin-top:8px">E 签宝沙箱配置缺失,当前不能创建签署流程。</div>@endif
</form>
@endif
@endif
</div>
</section>
@endif
<section class="quote-review__panel" aria-labelledby="review-events-title"><header class="quote-review__header"><div class="quote-review__title" id="review-events-title"><span class="material-icons-outlined" aria-hidden="true">history</span><span>状态与审计记录</span></div></header><div class="quote-review__body"><div class="quote-review__timeline">@foreach($change->events as $event)<div class="quote-review__event"><time class="quote-review__event-time">{{ $event->created_at?->format('Y-m-d H:i') }}</time><div><div class="quote-review__event-title">{{ $event->eventLabel() }}</div><div class="quote-review__event-note">{{ $event->note }}</div><div class="quote-review__event-actor">操作人:{{ $event->actor?->name ?? ('用户 #' . $event->created_by) }}</div></div></div>@endforeach</div>
@if($canReview && $change->status==='pending_review')<form class="quote-review__review-form" method="POST" action="{{ route('business.courier-quote-changes.return',$change) }}">@csrf<div class="quote-review__field"><label for="review_note">退回原因</label><textarea class="quote-review__control" id="review_note" name="review_note" minlength="2" maxlength="1000" required placeholder="明确说明需要修改的价格、范围或依据"></textarea></div><button class="quote-review__button quote-review__button--danger" type="submit"><span class="material-icons-outlined" aria-hidden="true">undo</span><span>退回修改</span></button></form>@endif
</div></section>
</main>
</x-slot>
</x-layouts.admin>

View File

@ -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%;
}
}
</style>
@ -789,14 +677,14 @@
<button class="quote-icon-button" type="button" title="重置筛选" aria-label="重置筛选" data-quote-reset>
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
</button>
<button class="quote-button" type="button" disabled title="当前文件尚未建立变价历史数据库">
<a class="quote-button" href="{{ route('business.courier-quote-changes.index') }}" title="查看报价变更草稿、审核状态和新版本">
<span class="material-icons-outlined" aria-hidden="true">history</span>
<span>报价历史</span>
</button>
<button class="quote-button quote-button--primary" type="button" data-quote-change-open @if (!$quoteSnapshot['available']) disabled @endif title="打开报价变更草稿">
</a>
<a class="quote-button quote-button--primary" href="{{ route('business.courier-quote-changes.create') }}" title="创建并保存报价变更草稿">
<span class="material-icons-outlined" aria-hidden="true">edit_note</span>
<span>发起报价变更</span>
</button>
</a>
</div>
</div>
@ -886,60 +774,6 @@
</div>
</div>
<div class="quote-modal" role="dialog" aria-modal="true" aria-labelledby="quote-change-title" data-quote-change-modal hidden>
<div class="quote-modal__panel quote-change-modal__panel">
<header class="quote-modal__header">
<div class="quote-modal__title-wrap">
<div class="quote-modal__title" id="quote-change-title">发起报价变更</div>
<div class="quote-modal__subtitle">创建本机变更草稿 · 原始工作簿保持不变</div>
</div>
<button class="quote-modal__close" type="button" aria-label="关闭报价变更" title="关闭" data-quote-change-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<form class="quote-change-form" data-quote-change-form>
<div class="quote-change-form__body">
<div class="quote-change-form__notice">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>当前为本机只读报价源。提交后只生成浏览器会话内的草稿提示,不会修改金额、数据库或原始 Excel。</span>
</div>
<div class="quote-change-form__grid">
<label class="quote-change-form__field quote-change-form__field--full">
<span class="quote-change-form__label">报价版本 <span class="quote-change-form__required">*</span></span>
<select class="quote-control" data-quote-change-field="quote" required></select>
</label>
<label class="quote-change-form__field">
<span class="quote-change-form__label">变更类型 <span class="quote-change-form__required">*</span></span>
<select class="quote-control" data-quote-change-field="type" required>
<option value="费率调整">费率调整</option>
<option value="面单调整">面单调整</option>
<option value="规则备注">规则备注</option>
<option value="执行日期">执行日期</option>
<option value="其他">其他</option>
</select>
</label>
<label class="quote-change-form__field">
<span class="quote-change-form__label">拟执行日期 <span class="quote-change-form__required">*</span></span>
<input class="quote-control" type="date" data-quote-change-field="date" required>
</label>
<label class="quote-change-form__field quote-change-form__field--full">
<span class="quote-change-form__label">变更说明 <span class="quote-change-form__required">*</span></span>
<textarea class="quote-control quote-change-form__textarea" data-quote-change-field="note" required placeholder="请说明调整的区域、重量段、金额或规则依据"></textarea>
</label>
</div>
<div class="quote-change-form__status" role="status" data-quote-change-status></div>
</div>
<div class="quote-change-form__footer">
<button class="quote-button" type="button" data-quote-change-close>取消</button>
<button class="quote-button quote-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<span>生成变更草稿</span>
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var root = document.querySelector('[data-quote-management]');
@ -959,16 +793,7 @@
var tableWrap = root.querySelector('.quote-table-wrap');
var pagination = root.querySelector('[data-quote-pagination]');
var modal = root.querySelector('[data-quote-modal]');
var changeModal = root.querySelector('[data-quote-change-modal]');
var changeForm = root.querySelector('[data-quote-change-form]');
var changeFields = changeForm ? {
quote: changeForm.querySelector('[data-quote-change-field="quote"]'),
type: changeForm.querySelector('[data-quote-change-field="type"]'),
date: changeForm.querySelector('[data-quote-change-field="date"]'),
note: changeForm.querySelector('[data-quote-change-field="note"]')
} : null;
var lastFocusedElement = null;
var lastChangeFocusedElement = null;
var fields = {
keyword: root.querySelector('[data-quote-filter="keyword"]'),
carrier: root.querySelector('[data-quote-filter="carrier"]'),
@ -1075,9 +900,8 @@
action.dataset.quoteOpen = quote.id;
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
actionCell.appendChild(action);
var changeAction = create('button', 'quote-action', '变价');
changeAction.type = 'button';
changeAction.dataset.quoteChange = quote.id;
var changeAction = create('a', 'quote-action', '变价');
changeAction.href = @json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id);
changeAction.title = '发起 ' + quote.sheet_name + ' 的变更草稿';
changeAction.setAttribute('aria-label', '变价 ' + quote.sheet_name);
actionCell.appendChild(changeAction);
@ -1199,58 +1023,6 @@
}
}
function setChangeDate() {
var date = new Date();
var month = String(date.getMonth() + 1).padStart(2, '0');
var day = String(date.getDate()).padStart(2, '0');
changeFields.date.value = date.getFullYear() + '-' + month + '-' + day;
}
function populateChangeQuotes(selectedId) {
changeFields.quote.replaceChildren();
quotes.forEach(function (quote) {
var option = create('option', '', quote.sheet_name);
option.value = quote.id;
option.textContent = quote.sheet_name;
changeFields.quote.appendChild(option);
});
if (selectedId && quotes.some(function (quote) { return quote.id === selectedId; })) {
changeFields.quote.value = selectedId;
}
}
function openChange(quote, trigger) {
if (!changeModal || !changeForm) {
return;
}
lastChangeFocusedElement = trigger || root.querySelector('[data-quote-change-open]');
populateChangeQuotes(quote && quote.id);
changeFields.type.value = '费率调整';
changeFields.note.value = '';
setChangeDate();
root.querySelector('[data-quote-change-status]').textContent = '';
changeModal.hidden = false;
changeModal.classList.add('is-open');
document.body.style.overflow = 'hidden';
changeFields.quote.focus();
}
function closeChange() {
if (!changeModal) {
return;
}
changeModal.classList.remove('is-open');
changeModal.hidden = true;
document.body.style.overflow = '';
if (lastChangeFocusedElement) {
lastChangeFocusedElement.focus();
}
}
Object.keys(fields).forEach(function (key) {
fields[key].addEventListener(key === 'keyword' ? 'input' : 'change', function () {
filterQuotes(true);
@ -1263,14 +1035,6 @@
fields.keyword.focus();
});
var changeOpenButton = root.querySelector('[data-quote-change-open]');
if (changeOpenButton) {
changeOpenButton.addEventListener('click', function () {
openChange(filteredQuotes[0] || quotes[0], changeOpenButton);
});
}
root.querySelector('[data-quote-page="previous"]').addEventListener('click', function () {
currentPage = Math.max(currentPage - 1, 1);
render();
@ -1283,18 +1047,6 @@
body.addEventListener('click', function (event) {
var trigger = event.target.closest('[data-quote-open]');
var changeTrigger = event.target.closest('[data-quote-change]');
if (changeTrigger) {
var changeQuote = quotes.find(function (item) { return item.id === changeTrigger.dataset.quoteChange; });
if (changeQuote) {
openChange(changeQuote, changeTrigger);
}
return;
}
if (!trigger) {
return;
}
@ -1317,29 +1069,8 @@
closeDetail();
}
if (event.key === 'Escape' && changeModal && changeModal.classList.contains('is-open')) {
closeChange();
}
});
if (changeModal && changeForm) {
changeModal.querySelectorAll('[data-quote-change-close]').forEach(function (button) {
button.addEventListener('click', closeChange);
});
changeModal.addEventListener('click', function (event) {
if (event.target === changeModal) {
closeChange();
}
});
changeForm.addEventListener('submit', function (event) {
event.preventDefault();
var quote = quotes.find(function (item) { return item.id === changeFields.quote.value; });
var status = root.querySelector('[data-quote-change-status]');
status.textContent = '已生成“' + (quote ? quote.sheet_name : '当前报价') + '”的本机变更草稿;原始工作簿未修改。';
});
}
restoreUrlFilters();
filterQuotes(false);
}());

View File

@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Quote {{ $version->version_code }}</title>
<style>
@font-face { font-family: "Droid Sans Fallback"; font-style: normal; font-weight: 400; src: url("file://{{ $fontPath }}") format("truetype"); }
@page { margin: 18mm 14mm 16mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #171717; font-family: "Droid Sans Fallback", sans-serif; font-size: 9px; line-height: 1.45; }
.latin { font-family: "DejaVu Sans", sans-serif; }
h1, h2, p, dl, dd { margin: 0; }
h1 { font-size: 20px; font-weight: 400; }
h2 { margin: 16px 0 7px; border-bottom: 1px solid #9b7a3f; padding-bottom: 4px; color: #6f5426; font-size: 12px; font-weight: 400; }
.heading { display: table; width: 100%; border-bottom: 2px solid #171717; padding-bottom: 10px; }
.heading__title, .heading__mark { display: table-cell; vertical-align: bottom; }
.heading__mark { color: #6b6b6b; text-align: right; }
.meta { display: table; width: 100%; margin-top: 12px; table-layout: fixed; }
.meta__item { display: table-cell; border-left: 2px solid #b69455; padding: 2px 9px; }
.meta dt { color: #666; font-size: 8px; }
.meta dd { margin-top: 2px; font-size: 10px; }
.notice { margin-top: 12px; border: 1px solid #c9b27f; background: #faf6ea; padding: 7px 9px; color: #5c451e; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { overflow-wrap: anywhere; border: 1px solid #cfcfcf; padding: 4px 5px; text-align: left; vertical-align: top; }
th { background: #eee8db; font-weight: 400; }
.number { text-align: right; }
.matrix { font-size: 7.5px; table-layout: auto; }
.matrix th, .matrix td { max-width: 90px; padding: 3px 4px; }
.formula { color: #735c31; font-size: 7px; }
.summary { white-space: pre-wrap; }
.signature { page-break-before: always; }
.signature h2 { margin-top: 0; }
.signature__meta { margin-bottom: 18px; color: #555; }
.signature__box { width: 250px; height: 82px; margin-left: auto; border: 1px solid #777; padding: 8px; color: #666; }
.document-control { margin-top: 22px; border-top: 1px solid #bbb; padding-top: 6px; color: #777; font-size: 7.5px; text-align: right; }
</style>
</head>
<body>
@php
$mixedText = static function ($value): string {
$parts = preg_split('/([\x20-\x7E]+)/', (string) $value, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
return implode('', array_map(static function (string $part): string {
$escaped = e($part);
return preg_match('/^[\x20-\x7E]+$/', $part) === 1
? '<span class="latin">' . $escaped . '</span>'
: $escaped;
}, $parts));
};
@endphp
<header class="heading">
<div class="heading__title">
<h1>{!! $mixedText($version->company_name) !!} 报价单</h1>
<p>{!! $mixedText($version->quote_type) !!}</p>
</div>
<div class="heading__mark">不可覆盖版本:{!! $mixedText($version->version_code) !!}</div>
</header>
<dl class="meta">
<div class="meta__item"><dt>快递公司</dt><dd>{!! $mixedText($version->carrier_name) !!}</dd></div>
<div class="meta__item"><dt>报价版本</dt><dd>{!! $mixedText($version->version_code) !!}</dd></div>
<div class="meta__item"><dt>执行日期</dt><dd>{!! $mixedText($version->effective_date?->toDateString()) !!}</dd></div>
<div class="meta__item"><dt>审核日期</dt><dd>{!! $mixedText($change?->approved_at?->format('Y-m-d H:i') ?? $version->created_at?->format('Y-m-d H:i')) !!}</dd></div>
</dl>
<div class="notice">本文件由财务系统根据已审核报价版本自动生成。签署后文件与完成证书将按本版本单独归档;签署状态不代表历史运费补差已经完成。</div>
@if($change)
<h2>变价说明</h2>
<p class="summary">{!! $mixedText($change->summary) !!}</p>
<h2>新旧价格差异</h2>
<table>
<thead><tr><th>单元格</th><th>地区/规则</th><th>重量段/计费项</th><th class="number">原价格</th><th class="number">新价格</th><th>单位</th><th>备注</th></tr></thead>
<tbody>
@foreach($change->items as $item)
<tr>
<td>{!! $mixedText($item->source_cell) !!}</td>
<td>{!! $mixedText($item->region ?: '—') !!}</td>
<td>{!! $mixedText($item->weight_band ?: ($item->charge_item ?: '—')) !!}</td>
<td class="number">{!! $mixedText(rtrim(rtrim($item->original_amount, '0'), '.')) !!}</td>
<td class="number">{!! $mixedText(rtrim(rtrim($item->new_amount, '0'), '.')) !!}</td>
<td>{!! $mixedText($item->unit ?: '—') !!}</td>
<td>{!! $mixedText($item->note ?: '—') !!}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
<h2>完整报价规则</h2>
<table class="matrix">
<tbody>
@forelse($values as $rowIndex => $row)
<tr>
@foreach($row as $columnIndex => $cell)
@php($formula = $formulas[$rowIndex][$columnIndex] ?? null)
<{{ $rowIndex === 0 ? 'th' : 'td' }}>
{!! $mixedText(is_bool($cell) ? ($cell ? '是' : '否') : ($cell ?? '')) !!}
@if(is_string($formula) && trim($formula) !== '')<div class="formula">公式:{!! $mixedText($formula) !!}</div>@endif
</{{ $rowIndex === 0 ? 'th' : 'td' }}>
@endforeach
</tr>
@empty
<tr><td>该版本未保存可展示的报价矩阵。</td></tr>
@endforelse
</tbody>
</table>
<section class="signature">
<h2>签署确认</h2>
<div class="signature__meta">报价版本:{!! $mixedText($version->version_code) !!} 快递公司:{!! $mixedText($version->carrier_name) !!} 执行日期:{!! $mixedText($version->effective_date?->toDateString()) !!}</div>
<div class="signature__box">快递公司签章<br><br>签署日期:</div>
<div class="document-control">生成时间:{!! $mixedText($generatedAt->format('Y-m-d H:i:s')) !!} · 版本 {!! $mixedText($version->version_code) !!}</div>
</section>
</body>
</html>

View File

@ -1,5 +1,7 @@
<?php
use App\Http\Controllers\Business\CourierQuoteChanges;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
@ -61,6 +63,31 @@ Route::prefix('workspace-data/supplier-payables')
* @see \modules\OfflinePayments\Routes\admin.php for module example
*/
Route::prefix('workspace/courier-payables/quote-management/changes')
->name('business.courier-quote-changes.')
->middleware('business.page:courier-payables,quote-management')
->group(function () {
Route::get('/', [CourierQuoteChanges::class, 'index'])->name('index');
Route::get('create', [CourierQuoteChanges::class, 'create'])->name('create');
Route::post('/', [CourierQuoteChanges::class, 'store'])->name('store');
Route::get('{quoteChange}', [CourierQuoteChanges::class, 'show'])->name('show');
Route::get('{quoteChange}/edit', [CourierQuoteChanges::class, 'edit'])->name('edit');
Route::patch('{quoteChange}', [CourierQuoteChanges::class, 'update'])->name('update');
Route::post('{quoteChange}/submit', [CourierQuoteChanges::class, 'submit'])->name('submit');
Route::post('{quoteChange}/approve', [CourierQuoteChanges::class, 'approve'])->name('approve');
Route::post('{quoteChange}/return', [CourierQuoteChanges::class, 'returnForChanges'])->name('return');
});
Route::prefix('workspace/courier-payables/quote-management/signatures')
->name('business.courier-quote-signatures.')
->middleware('business.page:courier-payables,quote-management')
->group(function () {
Route::post('versions/{quoteVersion}/prepare', [CourierQuoteSignatures::class, 'prepare'])->name('prepare');
Route::post('{quoteSignature}/send', [CourierQuoteSignatures::class, 'send'])->name('send');
Route::post('{quoteSignature}/synchronize', [CourierQuoteSignatures::class, 'synchronize'])->name('synchronize');
Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download');
});
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Integrations\EsignCallbacks;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
@ -9,6 +10,10 @@ use Livewire\Livewire;
* @see \App\Providers\Route::mapWebRoutes
*/
Route::post('integrations/esign/callback', EsignCallbacks::class)
->middleware('throttle:120,1')
->name('integrations.esign.callback');
Livewire::setScriptRoute(function ($handle) {
$base = request()->getBasePath();

View File

@ -0,0 +1,305 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\QuoteManagementSnapshot;
use Tests\Feature\FeatureTestCase;
class CourierQuoteChangeWorkflowTest extends FeatureTestCase
{
private string $fixturePath;
protected function setUp(): void
{
parent::setUp();
$this->fixturePath = storage_path('framework/testing/courier-quote-change-' . uniqid('', true) . '.json');
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:D3',
'values' => [
['省份', '面单', '续重', '备注'],
['广东省', 5.0, 1.2, null],
['江苏省', 4.0, 1.0, null],
],
'formulas' => [[], [], []],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath));
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
protected function tearDown(): void
{
if (isset($this->fixturePath) && is_file($this->fixturePath)) {
unlink($this->fixturePath);
}
parent::tearDown();
}
public function test_user_can_save_real_quote_change_draft_and_open_history(): void
{
$response = $this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::with(['items', 'events'])->firstOrFail();
$response->assertRedirect(route('business.courier-quote-changes.edit', $change));
$this->assertSame('BG-' . now()->format('Ymd') . '-001', $change->number);
$this->assertSame(CourierQuoteChange::STATUS_DRAFT, $change->status);
$this->assertFalse($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $change->recalculation_status);
$this->assertSame('新北中通', $change->carrier_name);
$this->assertCount(1, $change->items);
$this->assertSame('5.0000', $change->items->first()->original_amount);
$this->assertSame('6.0000', $change->items->first()->new_amount);
$this->assertSame('created', $change->events->first()->event_type);
$this->loginAs()->get(route('business.courier-quote-changes.index'))
->assertOk()
->assertSeeText($change->number)
->assertSeeText('新北中通')
->assertSeeText('草稿');
}
public function test_create_page_shows_structured_price_editor_and_excludes_formula_cells(): void
{
$this->loginAs()->get(route('business.courier-quote-changes.create', ['source_quote_id' => 'QB-001']))
->assertOk()
->assertSeeText('第一步:选择原报价')
->assertSeeText('第三步:编辑新价格')
->assertSeeText('批量方式')
->assertSeeText('原价格')
->assertSeeText('新价格')
->assertSee('data-price-row', false)
->assertSee('name="items[0][new_amount]"', false)
->assertSee('data-effective-date', false)
->assertDontSee('type="date" min=', false)
->assertSeeText('生效日期可以早于今天')
->assertSeeText('保存草稿');
}
public function test_historical_effective_date_can_be_saved_and_submitted_for_retrospective_recalculation(): void
{
$payload = $this->validPayload();
$payload['effective_date'] = now()->subMonths(2)->startOfMonth()->toDateString();
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->assertSame($payload['effective_date'], $change->effective_date->toDateString());
$this->assertTrue($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_PENDING, $change->recalculation_status);
$this->assertNull($change->recalculation_note);
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status);
$this->assertTrue($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_PENDING, $change->recalculation_status);
$this->loginAs()->get(route('business.courier-quote-changes.show', $change))
->assertOk()
->assertSeeText('追溯变价 · 待追溯核算')
->assertSeeText('原运费和原始 Excel 保持不变');
}
public function test_invalid_or_unchanged_amount_is_rejected_without_saving_partial_data(): void
{
$payload = $this->validPayload();
$payload['items'][0]['new_amount'] = '5.0000';
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertSessionHasErrors('items');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
$this->assertDatabaseCount('business_courier_quote_change_items', 0);
$payload = $this->validPayload();
$payload['items'][0]['original_amount'] = '4.5000';
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertSessionHasErrors('items.0.original_amount');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
public function test_formula_cell_cannot_be_changed_directly(): void
{
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:B2',
'values' => [['省份', '面单'], ['广东省', 5.0]],
'formulas' => [[], ['', '=2+3']],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->withExceptionHandling()->loginAs()
->post(route('business.courier-quote-changes.store'), $this->validPayload())
->assertSessionHasErrors('items.0.source_cell');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
public function test_submit_locks_draft_and_creator_cannot_approve_own_change(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status);
$this->withExceptionHandling()->loginAs()->get(route('business.courier-quote-changes.edit', $change))->assertForbidden();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.approve', $change))
->assertSessionHasErrors('approval');
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_different_admin_can_approve_and_generate_immutable_new_version(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change));
$reviewer = $this->createReviewer();
$this->loginAs($reviewer, $this->company)
->post(route('business.courier-quote-changes.approve', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$version = CourierQuoteVersion::firstOrFail();
$values = json_decode($version->values_json, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame('QB-002', $version->version_code);
$this->assertSame('6.0000', $values[1][1]);
$this->assertEquals(5.0, app(QuoteManagementSnapshot::class)->load()['quotes'][0]['values'][1][1]);
$this->assertSame($reviewer->id, $change->approved_by);
$this->assertSame('QB-002', app(QuoteManagementSnapshot::class)->load()['quotes'][1]['id']);
}
public function test_approving_historical_change_blocks_automatic_compensation_and_records_reason(): void
{
$payload = $this->validPayload();
$payload['effective_date'] = now()->subMonths(2)->startOfMonth()->toDateString();
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload);
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change));
$this->loginAs($this->createReviewer(), $this->company)
->post(route('business.courier-quote-changes.approve', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change))
->assertSessionHas('success', function (string $message): bool {
return str_contains($message, '历史生效已登记') && str_contains($message, '补差核算尚未执行');
});
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame(CourierQuoteChange::RECALCULATION_BLOCKED, $change->recalculation_status);
$this->assertStringContainsString('当前报价未提供可执行的运费计算方式', $change->recalculation_note);
$this->assertStringContainsString('历史运费也未记录所用报价版本', $change->recalculation_note);
$this->assertDatabaseCount('business_courier_quote_versions', 1);
$approvalEvent = $change->events()->where('event_type', 'approved')->firstOrFail();
$this->assertStringContainsString('补差明细尚未生成', $approvalEvent->note);
$this->assertStringContainsString('原运费保持不变', $approvalEvent->note);
$this->assertSame(
CourierQuoteChange::RECALCULATION_BLOCKED,
json_decode($approvalEvent->details_json, true, 512, JSON_THROW_ON_ERROR)['recalculation_status']
);
$this->loginAs()->get(route('business.courier-quote-changes.show', $change))
->assertOk()
->assertSeeText('追溯变价 · 核算受阻')
->assertSeeText('未生成补差明细')
->assertSeeText('原运费保持不变');
$this->loginAs()->get(route('business.courier-quote-changes.index'))
->assertOk()
->assertSeeText('追溯变价')
->assertSeeText('核算受阻');
}
public function test_other_company_cannot_open_or_modify_quote_change(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
\DB::table('business_courier_quote_changes')->where('id', $change->id)->update([
'company_id' => $this->company->id + 999,
]);
$response = $this->withExceptionHandling()->loginAs()
->get(route('business.courier-quote-changes.show', $change->id));
$response->assertRedirect(route('business.courier-quote-changes.index'));
$this->assertNull(CourierQuoteChange::find($change->id));
}
public function test_user_without_quote_page_permission_cannot_use_workflow_routes(): void
{
$subject = 'user_' . $this->user->id;
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [$subject => ['courier-payables/freight-reconciliation']],
], JSON_UNESCAPED_UNICODE));
$this->withExceptionHandling()->loginAs()->get(route('business.courier-quote-changes.index'))->assertForbidden();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload())->assertForbidden();
}
private function validPayload(): array
{
return [
'source_quote_id' => 'QB-001',
'change_type' => '面单调整',
'effective_date' => now()->addDay()->toDateString(),
'summary' => '广东省面单每单增加1元。',
'items' => [[
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]],
];
}
private function createReviewer()
{
$reviewer = user_model_class()::create([
'name' => 'Quote Reviewer',
'email' => 'quote-reviewer-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$reviewer->companies()->attach($this->company->id);
$reviewer->roles()->attach(Role::where('name', 'admin')->firstOrFail()->id);
$reviewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
return $reviewer;
}
}

View File

@ -0,0 +1,399 @@
<?php
namespace Tests\Feature\Business;
use App\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Models\Auth\Permission;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\FeatureTestCase;
class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
config([
'services.esign.environment' => 'sandbox',
'services.esign.base_url' => 'https://smlopenapi.esign.test',
'services.esign.app_id' => 'sandbox-app-id',
'services.esign.app_secret' => 'sandbox-app-secret',
'services.esign.notify_url' => null,
]);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
public function test_approved_version_generates_an_immutable_private_pdf(): void
{
$version = $this->createVersion();
$signature = app(CourierQuoteSignatureService::class)->prepareVersion($version, $this->user->id);
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$this->assertStringStartsWith('%PDF-', $bytes);
$this->assertSame(hash('sha256', $bytes), $signature->source_pdf_sha256);
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->status);
$this->assertSame('待推送', $version->fresh()->signature_status);
$again = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id);
$this->assertSame($signature->id, $again->id);
$this->assertSame($signature->source_pdf_sha256, $again->source_pdf_sha256);
}
public function test_admin_can_push_the_exact_pdf_to_esign_sandbox(): void
{
$signature = $this->createPreparedSignature();
Http::fake(function (ClientRequest $request) {
return match (true) {
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([
'code' => 0,
'data' => ['fileId' => 'file-001', 'fileUploadUrl' => 'https://upload.esign.test/file-001'],
]),
$request->url() === 'https://upload.esign.test/file-001' => Http::response(['errCode' => 0]),
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-001' => Http::response([
'code' => 0,
'data' => ['status' => 2],
]),
$request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([
'code' => 0,
'data' => ['signFlowId' => 'flow-001'],
]),
default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404),
};
});
$this->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('file-001', $signature->esign_file_id);
$this->assertSame('flow-001', $signature->esign_sign_flow_id);
$this->assertSame('待签署', $signature->version->fresh()->signature_status);
Http::assertSent(function (ClientRequest $request) use ($signature): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/files/file-upload-url') {
return false;
}
$payload = json_decode($request->body(), true);
$pdfBytes = Storage::disk('uploads')->get($signature->source_pdf_path);
return $request->hasHeader('X-Tsign-Open-Ca-Signature')
&& $payload['contentMd5'] === base64_encode(md5($pdfBytes, true))
&& $payload['fileName'] === 'quote-QB-900.pdf';
});
Http::assertSent(function (ClientRequest $request): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') {
return false;
}
$payload = json_decode($request->body(), true);
return data_get($payload, 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司'
&& data_get($payload, 'signers.0.normalSignFieldConfig') === null
&& data_get($payload, 'signers.0.signFields.0.normalSignFieldConfig.freeMode') === true;
});
}
public function test_tampered_source_pdf_is_not_sent(): void
{
$signature = $this->createPreparedSignature();
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered');
Http::fake();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertSessionHasErrors('signature');
Http::assertNothingSent();
$this->assertNull($signature->fresh()->esign_sign_flow_id);
}
public function test_source_pdf_cannot_be_regenerated_after_signing_has_started(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-immutable',
'esign_sign_flow_id' => 'flow-immutable',
'remote_status' => '1',
'sent_at' => now(),
]);
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered-after-send');
try {
app(CourierQuoteSignatureService::class)->prepareVersion($signature->version, $this->user->id);
$this->fail('A sent quote must not regenerate its immutable source PDF.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertSame(
'该报价已经进入签署流程,签署原文不可重新生成。',
$exception->errors()['signature'][0],
);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('flow-immutable', $signature->esign_sign_flow_id);
}
public function test_non_admin_cannot_push_a_quote_to_esign(): void
{
$signature = $this->createPreparedSignature();
$operator = user_model_class()::create([
'name' => 'Quote Operator',
'email' => 'quote-operator-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$operator->companies()->attach($this->company->id);
$operator->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
Http::fake();
$this->withExceptionHandling()->loginAs($operator, $this->company)
->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])
->assertForbidden();
Http::assertNothingSent();
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->fresh()->status);
}
public function test_manual_sync_downloads_signed_pdf_and_completion_certificate(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-001',
'esign_sign_flow_id' => 'flow-001',
'remote_status' => '1',
'sent_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/detail' => Http::response([
'code' => 0,
'data' => ['signFlowStatus' => 2],
]),
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/file-download-url' => Http::response([
'code' => 0,
'data' => [
'files' => [['fileId' => 'file-001', 'fileName' => 'signed.pdf', 'downloadUrl' => 'https://download.esign.test/signed']],
'certificateDownloadUrl' => 'https://download.esign.test/certificate',
],
]),
'https://download.esign.test/signed' => Http::response('%PDF-signed-file'),
'https://download.esign.test/certificate' => Http::response('%PDF-completion-certificate'),
]);
$this->loginAs()->post(route('business.courier-quote-signatures.synchronize', $signature))
->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $signature->status);
$this->assertSame('已签署并归档', $signature->version->fresh()->signature_status);
Storage::disk('uploads')->assertExists($signature->signed_pdf_path);
Storage::disk('uploads')->assertExists($signature->certificate_path);
$this->assertSame(hash('sha256', '%PDF-signed-file'), $signature->signed_pdf_sha256);
$this->assertSame(hash('sha256', '%PDF-completion-certificate'), $signature->certificate_sha256);
}
public function test_archive_job_propagates_failures_so_the_queue_can_retry(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_COMPLETED,
'esign_file_id' => 'file-retry',
'esign_sign_flow_id' => 'flow-retry',
'remote_status' => '2',
'completed_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-retry/detail' => Http::response([], 503),
]);
try {
(new ArchiveCourierQuoteSignature($signature->id))->handle(app(CourierQuoteSignatureService::class));
$this->fail('The archive job must fail so the queue can retry it.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertStringContainsString('HTTP 503', $exception->errors()['signature'][0]);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertStringContainsString('HTTP 503', $signature->last_error);
$this->assertNull($signature->archived_at);
}
public function test_verified_completion_callback_is_idempotent_and_queues_archiving(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-callback',
'remote_status' => '1',
'sent_at' => now(),
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-callback',
'signFlowStatus' => '2',
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = (string) now()->getTimestampMs();
$callbackSignature = hash_hmac('sha256', $timestamp . '12' . $payload, 'sandbox-app-secret');
$server = [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => $timestamp,
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => $callbackSignature,
'CONTENT_TYPE' => 'application/json',
];
$this->call(
'POST',
route('integrations.esign.callback') . '?zeta=2&alpha=1',
[],
[],
[],
$server,
$payload,
)->assertOk()->assertExactJson(['code' => '200', 'msg' => 'success']);
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertSame(hash('sha256', $payload), $signature->callback_payload_hash);
Queue::assertPushed(ArchiveCourierQuoteSignature::class, fn ($job): bool => $job->signatureId === $signature->id);
$this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback(
'flow-callback',
'2',
hash('sha256', $payload),
));
Queue::assertPushed(ArchiveCourierQuoteSignature::class, 1);
}
public function test_callback_with_wrong_signature_is_rejected_without_state_change(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-invalid',
'remote_status' => '1',
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-invalid',
'signFlowStatus' => '2',
], JSON_THROW_ON_ERROR);
$this->call('POST', route('integrations.esign.callback'), [], [], [], [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => (string) now()->getTimestampMs(),
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => str_repeat('0', 64),
'CONTENT_TYPE' => 'application/json',
], $payload)
->assertUnauthorized();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->fresh()->status);
Queue::assertNothingPushed();
}
private function createPreparedSignature(array $overrides = []): CourierQuoteSignature
{
$version = $this->createVersion();
$path = "courier-quotes/{$this->company->id}/QB-900/source.pdf";
$bytes = '%PDF-source-quote';
Storage::disk('uploads')->put($path, $bytes);
return CourierQuoteSignature::create(array_merge([
'company_id' => $this->company->id,
'quote_version_id' => $version->id,
'provider' => 'esign',
'environment' => 'sandbox',
'status' => CourierQuoteSignature::STATUS_PREPARED,
'organization_name' => '新北中通',
'source_pdf_path' => $path,
'source_pdf_sha256' => hash('sha256', $bytes),
'source_pdf_generated_at' => now(),
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
], $overrides));
}
private function createVersion(): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-' . now()->format('Ymd') . '-' . str_pad((string) (CourierQuoteChange::count() + 1), 3, '0', STR_PAD_LEFT),
'source_quote_id' => 'QB-001',
'source_sheet_name' => '新北中通报价',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => now()->addDay()->toDateString(),
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '广东省面单价格调整。',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
$change->items()->create([
'company_id' => $this->company->id,
'position' => 0,
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => 'QB-900',
'source_quote_id' => 'QB-001',
'source_change_id' => $change->id,
'sheet_name' => '新北中通报价',
'company_name' => '新北中通',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'effective_date' => now()->addDay()->toDateString(),
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '续重'], ['广东省', 6.0, 1.2]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[], []], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}

View File

@ -96,12 +96,10 @@ class QuoteManagementPageTest extends FeatureTestCase
->assertSeeText('资料状态')
->assertSeeText('报价历史')
->assertSeeText('发起报价变更')
->assertSee('data-quote-change-open', false)
->assertSee('data-quote-change-modal', false)
->assertSeeText('变更类型')
->assertSeeText('变更说明')
->assertSeeText('生成变更草稿')
->assertDontSee('>draft</span>', false)
->assertSee(route('business.courier-quote-changes.index'), false)
->assertSee(route('business.courier-quote-changes.create'), false)
->assertDontSee('data-quote-change-modal', false)
->assertDontSeeText('只生成浏览器会话内的草稿提示')
->assertSee('<option value="新北中通">新北中通</option>', false)
->assertSee('<option value="新北中通其他">新北中通其他</option>', false)
->assertSee('<option value="新德邦快递超长">新德邦快递超长</option>', false)

View File

@ -0,0 +1,43 @@
<?php
namespace Tests\Unit;
use App\Services\Esign\EsignRequestSigner;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class EsignRequestSignerTest extends TestCase
{
public function test_it_builds_the_official_hmac_sha256_base64_signature(): void
{
$signer = new EsignRequestSigner('app-123', 'secret-456');
$body = '{"name":"报价确认","amount":12.5}';
$headers = $signer->headers('post', '/v3/example?b=2&a=1', $body);
$contentMd5 = base64_encode(md5($body, true));
$stringToSign = "POST\n*/*\n{$contentMd5}\napplication/json; charset=UTF-8\n\n/v3/example?a=1&b=2";
$expected = base64_encode(hash_hmac('sha256', $stringToSign, 'secret-456', true));
$this->assertSame('app-123', $headers['X-Tsign-Open-App-Id']);
$this->assertSame('Signature', $headers['X-Tsign-Open-Auth-Mode']);
$this->assertSame($contentMd5, $headers['Content-MD5']);
$this->assertSame($expected, $headers['X-Tsign-Open-Ca-Signature']);
$this->assertMatchesRegularExpression('/^\d{13}$/', $headers['X-Tsign-Open-Ca-Timestamp']);
}
public function test_it_sorts_query_keys_and_keeps_only_the_first_repeated_value(): void
{
$signer = new EsignRequestSigner('app', 'secret');
$this->assertSame(
'/v3/files?empty&keyword=first&z=9',
$signer->canonicalPath('/v3/files?z=9&keyword=first&empty=&keyword=second'),
);
}
public function test_it_rejects_non_api_paths(): void
{
$this->expectException(InvalidArgumentException::class);
(new EsignRequestSigner('app', 'secret'))->canonicalPath('v3/files');
}
}