CaiWuXiTong/app/Exceptions/Handler.php
limeng 71652c5a64 feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
2026-10-14 13:47:00 +08:00

408 lines
12 KiB
PHP

<?php
namespace App\Exceptions;
use Akaunting\Money\Exceptions\UnexpectedAmountException;
use App\Events\Email\InvalidEmailDetected;
use App\Exceptions\Http\Resource as ResourceException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\ViewException;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\Exception\HttpTransportException as MailerHttpTransportException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [
//
];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*
* @return void
*/
public function register()
{
$this->reportable(function (Throwable $e) {
if (config('logging.default') == 'bugsnag') {
call_user_func(config('bugsnag.before_send'), $e);
}
if (config('app.error_tracker') === 'sentry') {
\Sentry\Laravel\Integration::captureUnhandledException($e);
}
});
}
/**
* Report or log an exception.
*
* @param \Throwable $exception
* @return void
*
* @throws \Exception
*/
public function report(Throwable $exception)
{
if ($exception instanceof MailerHttpTransportException) {
$email = $this->handleMailerExceptions($exception);
if (! empty($email)) {
return;
}
}
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $exception
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Throwable
*/
public function render($request, Throwable $exception)
{
// 快递扫码填报页:手机原图超过上传上限时,回到填报页给出能看懂的提示,而不是报错页。
if ($exception instanceof \Illuminate\Http\Exceptions\PostTooLargeException
&& $request->is('onboarding/courier/*')) {
return redirect()->to($request->url() . '?too_large=1');
}
if (request_is_api($request)) {
return $this->handleApiExceptions($request, $exception);
}
if (config('app.debug') === false) {
return $this->handleWebExceptions($request, $exception);
}
return parent::render($request, $exception);
}
/**
* Convert an authentication exception into a response.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Auth\AuthenticationException $exception
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
// Store the current url in the session (fullUrl includes query string)
if ($request->fullUrl() !== config('app.url')) {
session(['url.intended' => $request->fullUrl()]);
}
return $request->expectsJson()
? response()->json(['message' => $exception->getMessage()], 401)
: redirect()->to($exception->redirectTo() ?? route('login'));
}
protected function handleWebExceptions($request, $exception)
{
if ($exception instanceof NotFoundHttpException) {
// ajax 404 json feedback
if ($request->ajax()) {
return response()->json([
'error' => trans('errors.header.404'),
], 404);
}
flash(trans('errors.body.page_not_found'))->error()->important();
// normal 404 view page feedback
return redirect()
->back()
->withErrors(['msg', trans('errors.body.page_not_found')]);
}
if ($exception instanceof ModelNotFoundException) {
// ajax 404 json feedback
if ($request->ajax()) {
return response()->json([
'error' => trans('errors.header.404'),
], 404);
}
try {
$names = explode('.', $request->route()->getName());
$names[count($names) - 1] = 'index';
$route = route(implode('.', $names));
flash(trans('errors.message.record'))->warning()->important();
return redirect($route);
} catch (\Exception $e) {
// normal 404 view page feedback
return response()->view('errors.404', [], 404);
}
}
if ($exception instanceof FatalError) {
// ajax 500 json feedback
if ($request->ajax()) {
return response()->json([
'error' => trans('errors.header.500'),
], 500);
}
// normal 500 view page feedback
return response()->view('errors.500', [], 500);
}
if ($exception instanceof ThrottleRequestsException) {
// ajax 500 json feedback
if ($request->ajax()) {
return response()->json([
'error' => $exception->getMessage(),
], 429);
}
}
if ($exception instanceof ViewException) {
$real_exception = $this->getRealException($exception, ViewException::class);
if ($real_exception instanceof UnexpectedAmountException) {
if ($request->ajax()) {
return response()->json([
'error' => trans('errors.message.amount'),
], 500);
}
return response()->view('errors.500', [
'message' => trans('errors.message.amount'),
], 500);
}
}
if ($exception instanceof MailerHttpTransportException) {
$email = $this->handleMailerExceptions($exception);
if (! empty($email)) {
$message = trans('notifications.menu.invalid_email.description', ['email' => $email]);
if ($request->ajax()) {
return response()->json([
'error' => $message,
], $exception->getCode());
}
return response()->view('errors.403', [
'message' => $message,
], $exception->getCode());
}
}
return parent::render($request, $exception);
}
protected function handleApiExceptions($request, $exception): Response
{
$replacements = $this->prepareApiReplacements($exception);
$response = config('api.error_format');
array_walk_recursive($response, function (&$value, $key) use ($replacements) {
if (Str::startsWith($value, ':') && isset($replacements[$value])) {
$value = $replacements[$value];
}
});
$response = $this->recursivelyRemoveEmptyApiReplacements($response);
return new Response($response, $this->getStatusCode($exception), $this->getHeaders($exception));
}
protected function handleMailerExceptions(MailerHttpTransportException $exception): string
{
/**
* Couldn't access the SentMessage object to get the email address
* https://symfony.com/doc/current/mailer.html#debugging-emails
*
* https://codespeedy.com/extract-email-addresses-from-a-string-in-php
* https://phpliveregex.com/p/IMG
*/
preg_match("/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}/", $exception->getMessage(), $matches);
if (empty($matches[0])) {
return '';
}
$email = $matches[0];
event(new InvalidEmailDetected($email, $exception->getMessage()));
return $email;
}
/**
* Prepare the replacements array by gathering the keys and values.
*
* @param Throwable $exception
*
* @return array
*/
protected function prepareApiReplacements(Throwable $exception): array
{
$code = $this->getStatusCode($exception);
if (! $message = $exception->getMessage()) {
$message = sprintf('%d %s', $code, Response::$statusTexts[$code]);
}
$replacements = [
':message' => $message,
':status_code' => $code,
];
if ($exception instanceof ResourceException && $exception->hasErrors()) {
$replacements[':errors'] = $exception->getErrors();
}
if ($exception instanceof ValidationException) {
$replacements[':errors'] = $exception->errors();
$replacements[':status_code'] = $exception->status;
}
if ($code = $exception->getCode()) {
$replacements[':code'] = $code;
}
if (config('api.debug')) {
$replacements[':debug'] = [
'line' => $exception->getLine(),
'file' => $exception->getFile(),
'class' => get_class($exception),
'trace' => explode("\n", $exception->getTraceAsString()),
];
// Attach trace of previous exception, if exists
if (! is_null($exception->getPrevious())) {
$currentTrace = $replacements[':debug']['trace'];
$replacements[':debug']['trace'] = [
'previous' => explode("\n", $exception->getPrevious()->getTraceAsString()),
'current' => $currentTrace,
];
}
}
return $replacements;
}
/**
* Recursively remove any empty replacement values in the response array.
*
* @param array $input
*
* @return array
*/
protected function recursivelyRemoveEmptyApiReplacements(array $input)
{
foreach ($input as &$value) {
if (is_array($value)) {
$value = $this->recursivelyRemoveEmptyApiReplacements($value);
}
}
return array_filter($input, function ($value) {
if (is_string($value)) {
return ! Str::startsWith($value, ':');
}
return true;
});
}
/**
* Get the status code from the exception.
*
* @param Throwable $exception
*
* @return int
*/
protected function getStatusCode(Throwable $exception): int
{
$code = null;
if ($exception instanceof ValidationException) {
$code = $exception->status;
} elseif ($exception instanceof HttpExceptionInterface) {
$code = $exception->getStatusCode();
} else {
// By default throw 500
$code = 500;
}
// Be extra defensive
if ($code < 100 || $code > 599) {
$code = 500;
}
return $code;
}
/**
* Get the headers from the exception.
*/
protected function getHeaders(Throwable $exception): array
{
return ($exception instanceof HttpExceptionInterface)
? $exception->getHeaders()
: [];
}
/**
* Get the real exception.
*/
protected function getRealException(Throwable $exception, string $current): Throwable
{
$previous = $exception->getPrevious() ?? $exception;
while (($previous instanceof $current) && $previous->getPrevious()) {
$previous = $previous->getPrevious();
}
return $previous;
}
}