快递入驻填写的计费条件(执行时间、计费方式、是否计泡及抛比、起算重量、报价备注里的限长限重等) 原来在确认单里排在价格表下方、导出的报价电子表格里则完全没有,容易事后扯皮。 - 报价确认单 PDF(推送快递 e签宝盖章)与导出的报价 xlsx:价格表右侧增加「备注」列,逐行列出价格表以外的全部条件 - 新生成的入驻/变价报价补充口径:重量不取整说明、重量区间含上限、未列省份及空白价格不在报价范围、开票类型与税率、结算周期、附加费(除报价表及备注列明外不另收取) - 入驻表单报价备注下提示:要收的附加费须写在这里,没写视为不收取,并随确认单盖章 涉及:报价确认单与导出展示;已签署的确认单不变;不涉及迁移、权限和上游写入。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
321 lines
15 KiB
PHP
321 lines
15 KiB
PHP
<?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,
|
||
'当前账号没有报价签署管理权限。',
|
||
);
|
||
}
|
||
}
|