ensureCanManage(); abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。'); $data = $request->validate([ 'signature_date' => ['required', 'date'], ]); $signature = $this->service->prepareVersion($quoteVersion, user_id(), $data['signature_date']); 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'], 'signature_date' => ['required', 'date'], 'expires_at' => ['required', 'date', 'after:now', 'before_or_equal:' . now()->addDays(90)->toDateTimeString()], ]); $this->validateAccountForNotice($data['operator_account'], $data['notice_type']); $quoteSignature = $this->service->prepareVersion( $quoteSignature->version, user_id(), $data['signature_date'], ); $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']); } /** * 批量导出已签署的回签单电子档(签署 PDF + 签署完成证书)为 zip。 * codes 为报价版本号,逗号分隔;不传则导出全部已归档的回签单。 */ public function export(Request $request): BinaryFileResponse { $codes = array_values(array_filter(array_map('trim', explode(',', (string) $request->query('codes', ''))))); $signatures = CourierQuoteSignature::query() ->with('version') ->where('status', CourierQuoteSignature::STATUS_ARCHIVED) ->whereNotNull('signed_pdf_path') ->when($codes !== [], fn ($query) => $query->whereHas('version', fn ($version) => $version->whereIn('version_code', $codes))) ->orderBy('id') ->get(); abort_if($signatures->isEmpty(), 404, '没有可导出的已签署回签单。'); $disk = \Storage::disk('uploads'); $zipPath = tempnam(sys_get_temp_dir(), 'quote-signed-'); $zip = new \ZipArchive(); abort_unless($zip->open($zipPath, \ZipArchive::OVERWRITE) === true, 500, '生成压缩包失败。'); $added = 0; foreach ($signatures as $signature) { $version = $signature->version; $base = $this->safeName(($version->company_name ?: $version->carrier_name) . '-' . $version->version_code); if ($disk->exists($signature->signed_pdf_path)) { $zip->addFromString("{$base}-回签单.pdf", $disk->get($signature->signed_pdf_path)); $added++; } if ($signature->certificate_path && $disk->exists($signature->certificate_path)) { $zip->addFromString("{$base}-签署完成证书.pdf", $disk->get($signature->certificate_path)); } } $zip->close(); abort_if($added === 0, 404, '回签单归档文件不存在。'); $name = $signatures->count() === 1 ? $this->safeName($signatures->first()->version->version_code) . '-回签单电子档.zip' : '快递报价回签单-' . now()->format('Ymd-His') . '.zip'; return response()->download($zipPath, $name, ['Content-Type' => 'application/zip'])->deleteFileAfterSend(); } /** * 导出报价电子表格(xlsx,WPS / Excel 都能打开):只保留「省份」表头和各省价格, * 不带标题、执行时间、计费说明和备注等文字行。 */ public function sheet(CourierQuoteVersion $quoteVersion): StreamedResponse { $values = json_decode((string) $quoteVersion->values_json, true); $table = self::quoteTable(is_array($values) ? $values : []); abort_if($table === [], 404, '这份报价没有可导出的价格表。'); $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle(mb_substr($this->safeName($quoteVersion->version_code), 0, 31)); foreach ($table as $rowIndex => $row) { foreach ($row as $columnIndex => $value) { $cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1) . ($rowIndex + 1); if (is_int($value) || is_float($value)) { $sheet->setCellValue($cell, $value); } elseif ($value !== null && $value !== '') { $sheet->setCellValueExplicit($cell, (string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); } } } $lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($table[0])); $range = 'A1:' . $lastColumn . count($table); $sheet->getStyle($range)->getBorders()->getAllBorders()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN); $sheet->getStyle($range)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER) ->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER)->setWrapText(true); $header = $sheet->getStyle('A1:' . $lastColumn . '1'); $header->getFont()->setBold(true); $header->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('F3EAD7'); $sheet->getColumnDimension('A')->setWidth(12); for ($i = 2; $i <= count($table[0]); $i++) { $sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setWidth(16); } $sheet->freezePane('B2'); $fileName = $this->safeName(($quoteVersion->company_name ?: $quoteVersion->carrier_name) . '-' . $quoteVersion->version_code) . '-报价.xlsx'; return response()->streamDownload(function () use ($spreadsheet): void { (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output'); }, $fileName, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']); } /** * 从报价矩阵里截出价格表:从「省份」表头行开始,到第一行只有一格文字(说明、备注)为止。 * * @param array $values * @return list> */ public static function quoteTable(array $values): array { $rows = array_values(array_filter($values, 'is_array')); $filled = static fn (array $row): int => count(array_filter($row, static fn ($cell): bool => $cell !== null && trim((string) $cell) !== '')); $start = null; foreach ($rows as $index => $row) { $first = trim((string) ($row[0] ?? '')); if (preg_match('/^(省份|省市|区域|地区|目的地|目的省份)/u', $first) === 1 && $filled($row) >= 2) { $start = $index; break; } } if ($start === null) { foreach ($rows as $index => $row) { if ($filled($row) >= 2) { $start = $index; break; } } } if ($start === null) { return []; } $table = []; for ($i = $start; $i < count($rows) && $filled($rows[$i]) >= 2; $i++) { $table[] = array_values($rows[$i]); } $width = 0; foreach ($table as $row) { foreach ($row as $column => $cell) { if ($cell !== null && trim((string) $cell) !== '') { $width = max($width, $column + 1); } } } return array_map(static fn (array $row): array => array_pad(array_slice($row, 0, $width), $width, null), $table); } private function safeName(string $name): string { return trim(preg_replace('~[\\\\/:*?"<>|\s]+~u', '_', $name), '_') ?: 'quote'; } 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, '当前账号没有报价签署管理权限。', ); } }