报价变更与 E 签宝签署闭环 (#47)
This commit is contained in:
parent
809bca2e5c
commit
eb76dc4754
249
app/Http/Controllers/Business/CourierQuoteChanges.php
Normal file
249
app/Http/Controllers/Business/CourierQuoteChanges.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Business/CourierQuoteSignatures.php
Normal file
100
app/Http/Controllers/Business/CourierQuoteSignatures.php
Normal 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,
|
||||
'当前账号没有报价签署管理权限。',
|
||||
);
|
||||
}
|
||||
}
|
||||
95
app/Http/Controllers/Integrations/EsignCallbacks.php
Normal file
95
app/Http/Controllers/Integrations/EsignCallbacks.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
'integrations/esign/callback',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Jobs/Business/ArchiveCourierQuoteSignature.php
Normal file
28
app/Jobs/Business/ArchiveCourierQuoteSignature.php
Normal 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);
|
||||
}
|
||||
}
|
||||
82
app/Models/Business/CourierQuoteChange.php
Normal file
82
app/Models/Business/CourierQuoteChange.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
app/Models/Business/CourierQuoteChangeEvent.php
Normal file
39
app/Models/Business/CourierQuoteChangeEvent.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
30
app/Models/Business/CourierQuoteChangeItem.php
Normal file
30
app/Models/Business/CourierQuoteChangeItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
68
app/Models/Business/CourierQuoteSignature.php
Normal file
68
app/Models/Business/CourierQuoteSignature.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Models/Business/CourierQuoteVersion.php
Normal file
33
app/Models/Business/CourierQuoteVersion.php
Normal 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');
|
||||
}
|
||||
}
|
||||
201
app/Services/Esign/EsignClient.php
Normal file
201
app/Services/Esign/EsignClient.php
Normal 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));
|
||||
}
|
||||
}
|
||||
75
app/Services/Esign/EsignRequestSigner.php
Normal file
75
app/Services/Esign/EsignRequestSigner.php
Normal 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;
|
||||
}
|
||||
}
|
||||
317
app/Support/CourierQuoteChangeService.php
Normal file
317
app/Support/CourierQuoteChangeService.php
Normal 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 保持不变。';
|
||||
}
|
||||
|
||||
}
|
||||
46
app/Support/CourierQuotePdf.php
Normal file
46
app/Support/CourierQuotePdf.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
298
app/Support/CourierQuoteSignatureService.php
Normal file
298
app/Support/CourierQuoteSignatureService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
189
resources/fonts/DroidSansFallback-LICENSE.txt
Normal file
189
resources/fonts/DroidSansFallback-LICENSE.txt
Normal 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
|
||||
BIN
resources/fonts/DroidSansFallback.ttf
Normal file
BIN
resources/fonts/DroidSansFallback.ttf
Normal file
Binary file not shown.
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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);
|
||||
}());
|
||||
|
||||
117
resources/views/business/pdf/courier-quote.blade.php
Normal file
117
resources/views/business/pdf/courier-quote.blade.php
Normal 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>
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
305
tests/Feature/Business/CourierQuoteChangeWorkflowTest.php
Normal file
305
tests/Feature/Business/CourierQuoteChangeWorkflowTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
399
tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php
Normal file
399
tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
43
tests/Unit/EsignRequestSignerTest.php
Normal file
43
tests/Unit/EsignRequestSignerTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user