CaiWuXiTong/app/Http/Controllers/Business/CourierQuoteSignatures.php
limeng 2a3ea39e72 feat(快递报价): 价格表右侧增加备注列,计费条件随确认单盖章并进入导出电子表 (#1020)
快递入驻填写的计费条件(执行时间、计费方式、是否计泡及抛比、起算重量、报价备注里的限长限重等)
原来在确认单里排在价格表下方、导出的报价电子表格里则完全没有,容易事后扯皮。
- 报价确认单 PDF(推送快递 e签宝盖章)与导出的报价 xlsx:价格表右侧增加「备注」列,逐行列出价格表以外的全部条件
- 新生成的入驻/变价报价补充口径:重量不取整说明、重量区间含上限、未列省份及空白价格不在报价范围、开票类型与税率、结算周期、附加费(除报价表及备注列明外不另收取)
- 入驻表单报价备注下提示:要收的附加费须写在这里,没写视为不收取,并随确认单盖章
涉及:报价确认单与导出展示;已签署的确认单不变;不涉及迁移、权限和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-11-09 18:58:05 +08:00

321 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierQuoteSignatures extends Controller
{
public function __construct(private readonly CourierQuoteSignatureService $service)
{
}
public function prepare(Request $request, CourierQuoteVersion $quoteVersion): RedirectResponse
{
$this->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<int,mixed> $values
* @return list<list<string|int|float|null>>
*/
public static function quoteTable(array $values): array
{
return self::quoteLayout($values)['table'];
}
/**
* 报价矩阵拆成「价格表」和「价格表以外的文字」:
* - table:从「省份」表头行开始,到第一行只有一格文字为止;
* - notes:其余所有文字行(执行时间、计费方式、计泡、起算重量、报价备注等),第一行标题除外;
* - row_keys:价格表每一行在原矩阵里的下标(用于对应公式)。
*
* @param array<int,mixed> $values
* @return array{table:list<list<string|int|float|null>>,notes:list<string>,row_keys:list<int|string>}
*/
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<list<mixed>>,notes:list<string>,row_keys:list<int|string>} $layout
* @return array{rows:list<list<mixed>>,row_keys:list<int|string|null>}
*/
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,
'当前账号没有报价签署管理权限。',
);
}
}