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::quoteTableWithNotes(self::quoteLayout(is_array($values) ? $values : []))['rows']; 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->getColumnDimension($lastColumn)->setWidth(48); $sheet->getStyle($lastColumn . '2:' . $lastColumn . count($table))->getAlignment() ->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT); $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 { return self::quoteLayout($values)['table']; } /** * 报价矩阵拆成「价格表」和「价格表以外的文字」: * - table:从「省份」表头行开始,到第一行只有一格文字为止; * - notes:其余所有文字行(执行时间、计费方式、计泡、起算重量、报价备注等),第一行标题除外; * - row_keys:价格表每一行在原矩阵里的下标(用于对应公式)。 * * @param array $values * @return array{table:list>,notes:list,row_keys:list} */ public static function quoteLayout(array $values): array { $arrays = array_filter($values, 'is_array'); $keys = array_keys($arrays); $rows = array_values($arrays); $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' => [], 'notes' => [], 'row_keys' => []]; } $table = []; $rowKeys = []; $end = $start; for ($i = $start; $i < count($rows) && $filled($rows[$i]) >= 2; $i++) { $table[] = array_values($rows[$i]); $rowKeys[] = $keys[$i]; $end = $i + 1; } $width = 0; foreach ($table as $row) { foreach ($row as $column => $cell) { if ($cell !== null && trim((string) $cell) !== '') { $width = max($width, $column + 1); } } } $notes = []; foreach ($rows as $index => $row) { if (($index >= $start && $index < $end) || ($index === 0 && $start > 0)) { continue; } $text = implode(' ', array_map( static fn ($cell): string => trim((string) $cell), array_filter($row, static fn ($cell): bool => $cell !== null && trim((string) $cell) !== ''), )); if ($text !== '') { $notes[] = $text; } } return [ 'table' => array_map(static fn (array $row): array => array_pad(array_slice($row, 0, $width), $width, null), $table), 'notes' => $notes, 'row_keys' => $rowKeys, ]; } /** * 价格表右侧加一列「备注」,每条计费条件占一行;条件比省份多时在表尾补行。 * * @param array{table:list>,notes:list,row_keys:list} $layout * @return array{rows:list>,row_keys:list} */ public static function quoteTableWithNotes(array $layout): array { $table = $layout['table']; if ($table === []) { return ['rows' => [], 'row_keys' => []]; } $notes = $layout['notes']; $width = count($table[0]); $rows = []; $keys = []; $total = max(count($table), count($notes) + 1); for ($i = 0; $i < $total; $i++) { $row = $table[$i] ?? array_fill(0, $width, null); $row[] = $i === 0 ? '备注' : ($notes[$i - 1] ?? null); $rows[] = $row; $keys[] = $layout['row_keys'][$i] ?? null; } return ['rows' => $rows, 'row_keys' => $keys]; } 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, '当前账号没有报价签署管理权限。', ); } }