快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
251 lines
12 KiB
PHP
251 lines
12 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::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<int,mixed> $values
|
||
* @return list<list<string|int|float|null>>
|
||
*/
|
||
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,
|
||
'当前账号没有报价签署管理权限。',
|
||
);
|
||
}
|
||
}
|