817 lines
38 KiB
PHP
817 lines
38 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use App\Models\Business\CourierAgreementSignature;
|
||
use App\Models\Business\CourierOnboarding;
|
||
use App\Models\Business\CourierOnboardingEvent;
|
||
use App\Models\Business\CourierOnboardingFile;
|
||
use App\Models\Business\CourierQuoteChange;
|
||
use App\Models\Business\CourierQuoteChangeEvent;
|
||
use App\Models\Business\CourierQuoteSignature;
|
||
use App\Models\Business\CourierQuoteVersion;
|
||
use App\Services\Esign\EsignClient;
|
||
use App\Support\CourierQuoteStructure;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class CourierOnboardingService
|
||
{
|
||
public const DEFAULT_VALID_DAYS = 30;
|
||
|
||
public const MAX_FILES_PER_KIND = 1;
|
||
|
||
/** 提交审核前快递方必须填写的字段。 */
|
||
private const COMPANY_REQUIRED_FIELDS = [
|
||
'company_full_name' => '公司全称',
|
||
'unified_social_credit_code' => '统一社会信用代码',
|
||
'legal_representative' => '法定代表人',
|
||
'bank_account_name' => '对公账户户名',
|
||
'bank_name' => '对公账户开户行',
|
||
'bank_account_number' => '对公账号',
|
||
];
|
||
|
||
private const PERSON_REQUIRED_FIELDS = [
|
||
'company_full_name' => '姓名',
|
||
'id_number' => '身份证号',
|
||
];
|
||
|
||
private const REQUIRED_FIELDS = [
|
||
'carrier_brand' => '快递品牌',
|
||
'network_name' => '网点名称',
|
||
'service_type' => '业务类型',
|
||
'contact_name' => '业务对接人',
|
||
'contact_phone' => '对接人手机',
|
||
'esign_operator_name' => 'e签宝签署人姓名',
|
||
'esign_account' => 'e签宝账号',
|
||
'private_account_name' => '对私收款人',
|
||
'private_bank_name' => '对私账户开户行',
|
||
'private_account_number' => '对私银行卡号',
|
||
'invoice_type' => '开票类型',
|
||
'settlement_cycle' => '结算周期',
|
||
'quote_mode' => '计费方式',
|
||
'volumetric_rule' => '是否计泡',
|
||
'quote_effective_date' => '报价执行日期',
|
||
];
|
||
|
||
public const QUOTE_FIELDS = [
|
||
'quote_mode', 'quote_structure', 'quote_rows', 'volumetric_rule', 'volumetric_divisor',
|
||
'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'quote_effective_date',
|
||
];
|
||
|
||
public function ready(): bool
|
||
{
|
||
return Schema::hasTable('business_courier_onboardings')
|
||
&& Schema::hasTable('business_courier_onboarding_files')
|
||
&& Schema::hasTable('business_courier_onboarding_events');
|
||
}
|
||
|
||
public function invite(?string $label, int $validDays, int $userId, ?string $courierName = null): CourierOnboarding
|
||
{
|
||
$courierName = trim((string) $courierName);
|
||
if ($courierName !== '') {
|
||
$this->assertCourierNameAvailable(null, $courierName);
|
||
}
|
||
|
||
return DB::transaction(function () use ($label, $validDays, $userId, $courierName): CourierOnboarding {
|
||
$onboarding = CourierOnboarding::create([
|
||
'courier_name' => $courierName !== '' ? $courierName : null,
|
||
'company_id' => company_id(),
|
||
'number' => $this->nextNumber(),
|
||
'token' => $this->newToken(),
|
||
'status' => CourierOnboarding::STATUS_INVITED,
|
||
'invite_label' => filled($label) ? trim((string) $label) : null,
|
||
'expires_at' => now()->addDays(max(1, min($validDays, 180)))->endOfDay(),
|
||
'created_by' => $userId,
|
||
'updated_by' => $userId,
|
||
]);
|
||
$this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED,
|
||
trim('系统快递名称:' . ($onboarding->courier_name ?? '—') . ($onboarding->invite_label ? ';备注:' . $onboarding->invite_label : '')), 'user', $userId);
|
||
|
||
return $onboarding;
|
||
});
|
||
}
|
||
|
||
public function findByToken(string $token): ?CourierOnboarding
|
||
{
|
||
if (strlen($token) < 32 || strlen($token) > 64 || ! ctype_alnum($token)) {
|
||
return null;
|
||
}
|
||
|
||
return CourierOnboarding::allCompanies()->where('token', $token)->first();
|
||
}
|
||
|
||
/**
|
||
* 快递方保存或提交资料。
|
||
*
|
||
* @param array<string,mixed> $data
|
||
* @param array<string,list<UploadedFile>> $files
|
||
*/
|
||
public function saveCourierForm(CourierOnboarding $onboarding, array $data, array $files, bool $submit): CourierOnboarding
|
||
{
|
||
if (! $onboarding->courierCanEdit()) {
|
||
throw ValidationException::withMessages(['form' => $onboarding->linkExpired()
|
||
? '入驻链接已过期,请联系对接人重新获取二维码。'
|
||
: '资料已提交审核,暂不能修改。']);
|
||
}
|
||
|
||
return DB::transaction(function () use ($onboarding, $data, $files, $submit): CourierOnboarding {
|
||
$onboarding->fill($this->courierAttributes($data));
|
||
$onboarding->save();
|
||
|
||
foreach ($files as $kind => $uploads) {
|
||
foreach ($uploads as $upload) {
|
||
$this->storeFile($onboarding, $kind, $upload);
|
||
}
|
||
}
|
||
|
||
$removeIds = array_map('intval', (array) ($data['remove_files'] ?? []));
|
||
if ($removeIds !== []) {
|
||
$onboarding->files()->whereIn('id', $removeIds)->get()->each(function (CourierOnboardingFile $file): void {
|
||
Storage::disk('uploads')->delete($file->path);
|
||
$file->delete();
|
||
});
|
||
}
|
||
|
||
$onboarding->unsetRelation('files');
|
||
|
||
if (! $submit) {
|
||
$this->log($onboarding, 'saved', $onboarding->status, $onboarding->status, null, 'courier');
|
||
|
||
return $onboarding;
|
||
}
|
||
|
||
$this->assertCompleteForSubmit($onboarding);
|
||
$from = $onboarding->status;
|
||
$onboarding->update([
|
||
'status' => CourierOnboarding::STATUS_SUBMITTED,
|
||
'submitted_at' => now(),
|
||
'return_reason' => null,
|
||
]);
|
||
$this->log($onboarding, 'submitted', $from, CourierOnboarding::STATUS_SUBMITTED, null, 'courier');
|
||
|
||
return $onboarding;
|
||
});
|
||
}
|
||
|
||
public function firstApprove(CourierOnboarding $onboarding, int $userId, ?string $note): CourierOnboarding
|
||
{
|
||
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $note): CourierOnboarding {
|
||
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。');
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [
|
||
'first_reviewed_by' => $userId,
|
||
'first_reviewed_at' => now(),
|
||
]);
|
||
});
|
||
}
|
||
|
||
public function returnForChanges(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding
|
||
{
|
||
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding {
|
||
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。');
|
||
|
||
$expiresAt = $onboarding->expires_at;
|
||
if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) {
|
||
$expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay();
|
||
}
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [
|
||
'return_reason' => $reason,
|
||
'expires_at' => $expiresAt,
|
||
'first_reviewed_by' => null,
|
||
'first_reviewed_at' => null,
|
||
]);
|
||
});
|
||
}
|
||
|
||
public function reject(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding
|
||
{
|
||
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding {
|
||
$this->assertStatus($onboarding, [
|
||
CourierOnboarding::STATUS_SUBMITTED,
|
||
CourierOnboarding::STATUS_FIRST_APPROVED,
|
||
CourierOnboarding::STATUS_RETURNED,
|
||
], '当前状态不能标记为不予合作。');
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId);
|
||
});
|
||
}
|
||
|
||
public function cancel(CourierOnboarding $onboarding, int $userId): CourierOnboarding
|
||
{
|
||
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED], '只有快递方尚未提交的邀请可以作废。');
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_CANCELLED, 'cancelled', null, $userId);
|
||
}
|
||
|
||
public function extend(CourierOnboarding $onboarding, int $userId): CourierOnboarding
|
||
{
|
||
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED], '只有待快递填写的邀请需要延期。');
|
||
$expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay();
|
||
$onboarding->update(['expires_at' => $expiresAt, 'updated_by' => $userId]);
|
||
$this->log($onboarding, 'extended', $onboarding->status, $onboarding->status, '有效期至 ' . $expiresAt->format('Y-m-d'), 'user', $userId);
|
||
|
||
return $onboarding;
|
||
}
|
||
|
||
/**
|
||
* @param array{courier_name?:?string,reconciler?:?string} $data
|
||
*/
|
||
public function updateInternal(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding
|
||
{
|
||
$courierName = filled($data['courier_name'] ?? null) ? trim((string) $data['courier_name']) : null;
|
||
if ($courierName !== null && $onboarding->quote_version_id !== null && $courierName !== $onboarding->courier_name) {
|
||
throw ValidationException::withMessages(['courier_name' => '已生成首版报价,系统快递名称不能再修改。']);
|
||
}
|
||
if ($courierName !== null) {
|
||
$this->assertCourierNameAvailable($onboarding, $courierName);
|
||
}
|
||
|
||
$onboarding->update([
|
||
'courier_name' => $courierName,
|
||
'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : null,
|
||
'updated_by' => $userId,
|
||
]);
|
||
$this->log($onboarding, 'internal_updated', $onboarding->status, $onboarding->status, trim('系统快递名称:' . ($courierName ?? '—') . ';对账人:' . ($onboarding->reconciler ?? '—')), 'user', $userId);
|
||
|
||
return $onboarding;
|
||
}
|
||
|
||
/**
|
||
* 财务复审:确认系统快递名称,并把快递填的报价写成「报价管理」里的首版报价版本。
|
||
*
|
||
* @param array{courier_name:string,reconciler?:?string,note?:?string} $data
|
||
*/
|
||
public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding
|
||
{
|
||
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($data, $userId): CourierOnboarding {
|
||
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。');
|
||
|
||
// 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。
|
||
$courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name);
|
||
$this->assertCourierNameAvailable($onboarding, $courierName);
|
||
$onboarding->forceFill([
|
||
'courier_name' => $courierName,
|
||
'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : $onboarding->reconciler,
|
||
])->save();
|
||
|
||
$note = filled($data['note'] ?? null) ? trim((string) $data['note']) : null;
|
||
$extra = ['final_reviewed_by' => $userId, 'final_reviewed_at' => now()];
|
||
|
||
$version = $this->createQuoteVersion($onboarding, $userId);
|
||
if ($version !== null) {
|
||
$extra['quote_change_id'] = $version->source_change_id;
|
||
$extra['quote_version_id'] = $version->id;
|
||
$note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code}。");
|
||
} else {
|
||
$note = trim(($note ?? '') . "\n快递未填写报价明细,请在报价管理中补录后再发起报价确认。");
|
||
}
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_APPROVED, 'final_approved', $note, $userId, $extra);
|
||
});
|
||
}
|
||
|
||
private function review(CourierOnboarding $onboarding, int $userId, \Closure $action): CourierOnboarding
|
||
{
|
||
return DB::transaction(function () use ($onboarding, $userId, $action): CourierOnboarding {
|
||
// Serialize all review outcomes on the current row, including stale/repeated requests.
|
||
$current = CourierOnboarding::query()->disableCache()->lockForUpdate()->findOrFail($onboarding->id);
|
||
$this->assertStatus($current, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED,
|
||
CourierOnboarding::STATUS_RETURNED], '申请已由其他人员处理,请刷新页面查看最新状态。');
|
||
$reviewers = app(CourierOnboardingReviewers::class);
|
||
$reviewers->assertAllowed($current, $userId, $reviewers->stage($current));
|
||
|
||
return $action($current);
|
||
});
|
||
}
|
||
|
||
public function activate(CourierOnboarding $onboarding, int $userId, ?string $offlineNote): CourierOnboarding
|
||
{
|
||
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。');
|
||
|
||
$quoteSignature = $onboarding->quoteSignature();
|
||
$quoteSigned = $quoteSignature?->status === CourierQuoteSignature::STATUS_ARCHIVED;
|
||
$signature = $this->signedAgreement($onboarding);
|
||
if (! $quoteSigned && $signature === null && blank($offlineNote)) {
|
||
throw ValidationException::withMessages(['offline_note' => '报价确认单还没在 e签宝签署完成;如已线下签署,请填写说明后再开通。']);
|
||
}
|
||
|
||
$note = match (true) {
|
||
$quoteSigned => '报价确认单已在 e签宝签署:' . $onboarding->quoteVersion->version_code,
|
||
$signature !== null => '电子协议已签署:' . $signature->business_no,
|
||
default => '线下签署:' . trim((string) $offlineNote),
|
||
};
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_ACTIVATED, 'activated', $note, $userId, [
|
||
'activated_by' => $userId,
|
||
'activated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
public function esignReady(): bool
|
||
{
|
||
return EsignClient::configured();
|
||
}
|
||
|
||
/**
|
||
* 用快递填写的报价生成报价确认单 PDF,推送到快递方的 e签宝账号签署。
|
||
*/
|
||
public function pushQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature
|
||
{
|
||
$onboarding->loadMissing('quoteVersion.signature');
|
||
if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true) || $onboarding->quoteVersion === null) {
|
||
throw ValidationException::withMessages(['signature' => '财务复审通过、生成首版报价后才能推送报价确认单。']);
|
||
}
|
||
|
||
return $this->pushVersion($onboarding, $onboarding->quoteVersion, $userId);
|
||
}
|
||
|
||
/**
|
||
* 按入驻资料(公司 / 个人、e签宝签署人和账号)把某个报价版本推送给快递签署。
|
||
* 首版报价和后续变价共用。
|
||
*/
|
||
public function pushVersion(CourierOnboarding $onboarding, CourierQuoteVersion $version, int $userId): CourierQuoteSignature
|
||
{
|
||
if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true)) {
|
||
throw ValidationException::withMessages(['signature' => '这家快递的入驻还没有审核通过,不能推送报价。']);
|
||
}
|
||
if ($version->isVoided()) {
|
||
throw ValidationException::withMessages(['signature' => "{$version->version_code} 已作废,不能推送。"]);
|
||
}
|
||
$existing = $version->signature()->first();
|
||
if ($existing !== null && ! $existing->canSend()) {
|
||
throw ValidationException::withMessages(['signature' => "{$version->version_code} 已经推送过,正在签署或已签署。"]);
|
||
}
|
||
if (! EsignClient::configured()) {
|
||
throw ValidationException::withMessages(['signature' => 'E 签宝尚未配置,暂时无法推送;可线下签署后在开通时填写说明。']);
|
||
}
|
||
if (blank($onboarding->esign_operator_name) || ! $this->validEsignAccount((string) $onboarding->esign_account)) {
|
||
throw ValidationException::withMessages(['signature' => '快递方填写的 e签宝签署人或账号不完整,请退回补件或联系快递确认。']);
|
||
}
|
||
|
||
$quoteSignatures = app(CourierQuoteSignatureService::class);
|
||
$signature = $quoteSignatures->prepareVersion($version, $userId, today()->toDateString());
|
||
$signature = $quoteSignatures->send($signature, [
|
||
'signer_type' => $onboarding->isPerson() ? 'person' : 'organization',
|
||
'organization_name' => (string) $onboarding->company_full_name,
|
||
'operator_name' => (string) $onboarding->esign_operator_name,
|
||
'operator_account' => (string) $onboarding->esign_account,
|
||
'notice_type' => $onboarding->esignNoticeType(),
|
||
'signature_date' => today()->toDateString(),
|
||
'expires_at' => now()->addDays(30)->toDateTimeString(),
|
||
], $userId);
|
||
|
||
$this->log($onboarding, 'quote_pushed', $onboarding->status, $onboarding->status,
|
||
"报价确认单 {$version->version_code} 已推送到 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account})", 'user', $userId);
|
||
|
||
return $signature;
|
||
}
|
||
|
||
public function syncQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature
|
||
{
|
||
$signature = $onboarding->quoteSignature();
|
||
if ($signature === null) {
|
||
throw ValidationException::withMessages(['signature' => '报价确认单还没有推送。']);
|
||
}
|
||
|
||
return app(CourierQuoteSignatureService::class)->synchronize($signature, $userId);
|
||
}
|
||
|
||
/**
|
||
* 取快递签署人的 e签宝 签署链接,可直接发给快递或我方扫码核对。
|
||
*/
|
||
public function quoteSignUrl(CourierOnboarding $onboarding): string
|
||
{
|
||
$signature = $onboarding->quoteSignature();
|
||
if ($signature === null || blank($signature->esign_sign_flow_id)) {
|
||
throw ValidationException::withMessages(['signature' => '报价确认单还没有推送到 e签宝。']);
|
||
}
|
||
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
|
||
throw ValidationException::withMessages(['signature' => '报价确认单已签署完成。']);
|
||
}
|
||
|
||
try {
|
||
return app(\App\Services\Esign\EsignClient::class)->signUrl(
|
||
(string) $signature->esign_sign_flow_id,
|
||
(string) $signature->operator_account,
|
||
$onboarding->isPerson() ? null : (string) $signature->organization_name,
|
||
);
|
||
} catch (\RuntimeException $exception) {
|
||
throw ValidationException::withMessages(['signature' => '获取签署链接失败:' . mb_substr($exception->getMessage(), 0, 300)]);
|
||
}
|
||
}
|
||
|
||
private function validEsignAccount(string $account): bool
|
||
{
|
||
return preg_match('/^1\d{10}$/', $account) === 1 || filter_var($account, FILTER_VALIDATE_EMAIL) !== false;
|
||
}
|
||
|
||
/**
|
||
* 与该快递相关的协议签署任务(按系统快递名称或公司全称匹配),最近的在前。
|
||
*
|
||
* @return \Illuminate\Support\Collection<int,CourierAgreementSignature>
|
||
*/
|
||
public function agreementSignatures(CourierOnboarding $onboarding): \Illuminate\Support\Collection
|
||
{
|
||
if (! Schema::hasTable('business_courier_agreement_signatures')) {
|
||
return collect();
|
||
}
|
||
|
||
$names = array_values(array_filter([
|
||
$onboarding->courier_name,
|
||
$onboarding->company_full_name,
|
||
], 'filled'));
|
||
if ($names === []) {
|
||
return collect();
|
||
}
|
||
|
||
return CourierAgreementSignature::query()
|
||
->where(function ($query) use ($names): void {
|
||
$query->whereIn('courier_name', $names)
|
||
->orWhereIn('company_full_name', $names)
|
||
->orWhereIn('organization_name', $names);
|
||
})
|
||
->latest('id')
|
||
->limit(20)
|
||
->get();
|
||
}
|
||
|
||
public function signedAgreement(CourierOnboarding $onboarding): ?CourierAgreementSignature
|
||
{
|
||
return $this->agreementSignatures($onboarding)->first(static fn (CourierAgreementSignature $signature): bool => in_array(
|
||
$signature->status,
|
||
[CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED],
|
||
true,
|
||
));
|
||
}
|
||
|
||
/**
|
||
* @return array<string,int>
|
||
*/
|
||
public function statusCounts(): array
|
||
{
|
||
return CourierOnboarding::query()
|
||
->select('status', DB::raw('count(*) as total'))
|
||
->groupBy('status')
|
||
->pluck('total', 'status')
|
||
->map(static fn ($total): int => (int) $total)
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $data
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function courierAttributes(array $data): array
|
||
{
|
||
$text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null;
|
||
$attributes = [];
|
||
foreach ([
|
||
'company_full_name', 'legal_representative', 'business_address', 'carrier_brand', 'network_name',
|
||
'service_type', 'contact_name', 'contact_phone', 'finance_contact_name', 'finance_contact_phone',
|
||
'pickup_warehouses', 'esign_operator_name', 'esign_account', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle',
|
||
'private_account_name', 'private_bank_name',
|
||
'remark',
|
||
] as $key) {
|
||
$attributes[$key] = $text($key);
|
||
}
|
||
|
||
$code = $text('unified_social_credit_code');
|
||
$attributes['unified_social_credit_code'] = $code === null ? null : strtoupper($code);
|
||
$account = $text('bank_account_number');
|
||
$attributes['bank_account_number'] = $account === null ? null : preg_replace('/\s+/', '', $account);
|
||
$privateAccount = $text('private_account_number');
|
||
$attributes['private_account_number'] = $privateAccount === null ? null : preg_replace('/\s+/', '', $privateAccount);
|
||
$attributes['tax_rate'] = is_numeric($data['tax_rate'] ?? null) ? (float) $data['tax_rate'] : null;
|
||
$attributes = [...$attributes, ...$this->quoteAttributes($data)];
|
||
|
||
$attributes['entity_type'] = ($data['entity_type'] ?? null) === CourierOnboarding::ENTITY_PERSON
|
||
? CourierOnboarding::ENTITY_PERSON
|
||
: CourierOnboarding::ENTITY_COMPANY;
|
||
if ($attributes['entity_type'] === CourierOnboarding::ENTITY_PERSON) {
|
||
// 个人以本人身份签署:没有企业信息,e签宝签署人就是本人。
|
||
$idNumber = $text('id_number');
|
||
$attributes['id_number'] = $idNumber === null ? null : strtoupper((string) preg_replace('/\s+/', '', $idNumber));
|
||
$attributes['unified_social_credit_code'] = null;
|
||
$attributes['legal_representative'] = null;
|
||
// 个人入驻:对私账户必填,对公账户暂为选填(有个体户/公司账户的可以填)。
|
||
$attributes['esign_operator_name'] = $attributes['company_full_name'];
|
||
} else {
|
||
$attributes['id_number'] = null;
|
||
}
|
||
|
||
return $attributes;
|
||
}
|
||
|
||
/**
|
||
* 报价相关字段(计费结构、各省价格、附加规则、执行日期),入驻表单和后台变价共用。
|
||
*
|
||
* @param array<string,mixed> $data
|
||
* @return array<string,mixed>
|
||
*/
|
||
public function quoteAttributes(array $data): array
|
||
{
|
||
$text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null;
|
||
$structure = CourierQuoteStructure::normalize($data);
|
||
$volumetricRule = $text('volumetric_rule');
|
||
|
||
return [
|
||
'volumetric_rule' => $volumetricRule,
|
||
'quote_note' => $text('quote_note'),
|
||
'volumetric_divisor' => $volumetricRule === '计泡' && is_numeric($data['volumetric_divisor'] ?? null)
|
||
? (int) $data['volumetric_divisor']
|
||
: null,
|
||
'quote_effective_date' => $text('quote_effective_date'),
|
||
'quote_mode' => filled($data['quote_mode'] ?? null) ? $structure['mode'] : null,
|
||
'quote_structure' => $structure,
|
||
'quote_rows' => CourierQuoteStructure::normalizeRows((array) ($data['quote_rows'] ?? []), $structure),
|
||
'min_weight' => is_numeric($data['min_weight'] ?? null) && (float) $data['min_weight'] > 0 ? round((float) $data['min_weight'], 2) : null,
|
||
'weight_rounding' => array_key_exists((string) ($data['weight_rounding'] ?? ''), CourierQuoteStructure::ROUNDING) ? (string) $data['weight_rounding'] : null,
|
||
'per_ticket_surcharge' => is_numeric($data['per_ticket_surcharge'] ?? null) && (float) $data['per_ticket_surcharge'] > 0 ? round((float) $data['per_ticket_surcharge'], 2) : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 当前入驻记录里的报价部分,写进报价变更单,后续变价以此为底稿。
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
public function quotePayload(CourierOnboarding $onboarding): array
|
||
{
|
||
$payload = [];
|
||
foreach (self::QUOTE_FIELDS as $field) {
|
||
$payload[$field] = $onboarding->getAttribute($field);
|
||
}
|
||
$payload['quote_effective_date'] = $onboarding->quote_effective_date?->toDateString();
|
||
$payload['quote_structure'] = $onboarding->quoteStructure();
|
||
$payload['quote_rows'] = $onboarding->quoteRowList();
|
||
|
||
return $payload;
|
||
}
|
||
|
||
private function storeFile(CourierOnboarding $onboarding, string $kind, UploadedFile $upload): void
|
||
{
|
||
if (! array_key_exists($kind, CourierOnboardingFile::allKinds())) {
|
||
return;
|
||
}
|
||
// 每项资料只保留一张,重新上传即替换。
|
||
$onboarding->files()->where('kind', $kind)->get()->each(function (CourierOnboardingFile $old): void {
|
||
Storage::disk('uploads')->delete($old->path);
|
||
$old->delete();
|
||
});
|
||
|
||
$extension = strtolower($upload->getClientOriginalExtension() ?: ($upload->guessExtension() ?: 'bin'));
|
||
$path = sprintf('courier-onboarding/%d/%d/%s-%s.%s', $onboarding->company_id, $onboarding->id, $kind, Str::uuid()->toString(), $extension);
|
||
|
||
if (Storage::disk('uploads')->putFileAs(dirname($path), $upload, basename($path)) === false) {
|
||
throw ValidationException::withMessages(["files.{$kind}" => '文件保存失败,请稍后重试。']);
|
||
}
|
||
|
||
CourierOnboardingFile::create([
|
||
'company_id' => $onboarding->company_id,
|
||
'onboarding_id' => $onboarding->id,
|
||
'kind' => $kind,
|
||
'original_name' => mb_substr($upload->getClientOriginalName() ?: basename($path), 0, 200),
|
||
'path' => $path,
|
||
'mime' => $upload->getMimeType(),
|
||
'size' => (int) $upload->getSize(),
|
||
]);
|
||
}
|
||
|
||
private function assertCompleteForSubmit(CourierOnboarding $onboarding): void
|
||
{
|
||
$errors = [];
|
||
$subjectFields = $onboarding->isPerson() ? self::PERSON_REQUIRED_FIELDS : self::COMPANY_REQUIRED_FIELDS;
|
||
foreach ($subjectFields + self::REQUIRED_FIELDS as $field => $label) {
|
||
if (blank($onboarding->{$field})) {
|
||
$errors[$field] = "请填写{$label}。";
|
||
}
|
||
}
|
||
if ($onboarding->volumetric_rule === '计泡' && blank($onboarding->volumetric_divisor)) {
|
||
$errors['volumetric_divisor'] = '计泡请填写抛比系数(如 6000)。';
|
||
}
|
||
|
||
$checks = $onboarding->accountChecks();
|
||
if ($checks['corporate'] === false) {
|
||
$errors['bank_account_name'] = "对公账户户名「{$onboarding->bank_account_name}」与营业执照公司全称「{$onboarding->company_full_name}」不一致,对公账户须为公司本身的账户。";
|
||
}
|
||
if ($checks['private'] === false) {
|
||
$who = $onboarding->isPerson() ? '本人姓名' : '营业执照法定代表人';
|
||
$errors['private_account_name'] = "对私收款人「{$onboarding->private_account_name}」与{$who}「{$onboarding->expectedPrivatePayee()}」不一致,对私账户须为{$who}本人的银行卡。";
|
||
}
|
||
|
||
if (filled($onboarding->esign_account) && ! $this->validEsignAccount((string) $onboarding->esign_account)) {
|
||
$errors['esign_account'] = 'e签宝账号请填写注册时用的 11 位手机号或邮箱。';
|
||
}
|
||
$kinds = $onboarding->files()->pluck('kind')->unique()->all();
|
||
$errors += CourierQuoteStructure::validate($onboarding->quoteStructure(), $onboarding->quoteRowList());
|
||
if ($onboarding->quote_mode === 'other' && ! in_array(CourierOnboardingFile::QUOTE_SHEET_KIND, $kinds, true)) {
|
||
$errors['files.' . CourierOnboardingFile::QUOTE_SHEET_KIND] = '计费方式选「其他」时,请上传报价表(Excel 或照片)。';
|
||
}
|
||
if ($onboarding->isPerson() && filled($onboarding->id_number) && preg_match('/^\d{17}[\dX]$/', (string) $onboarding->id_number) !== 1) {
|
||
$errors['id_number'] = '身份证号应为 18 位。';
|
||
}
|
||
|
||
foreach (CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) as $kind) {
|
||
if (! in_array($kind, $kinds, true)) {
|
||
$errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::labelFor($kind, $onboarding->entity_type) . '。';
|
||
}
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
throw ValidationException::withMessages($errors);
|
||
}
|
||
}
|
||
|
||
private function assertCourierNameAvailable(?CourierOnboarding $onboarding, string $courierName): void
|
||
{
|
||
if ($courierName === '') {
|
||
throw ValidationException::withMessages(['courier_name' => '请填写系统快递名称。']);
|
||
}
|
||
|
||
$taken = CourierOnboarding::query()
|
||
->where('courier_name', $courierName)
|
||
->when($onboarding !== null, static fn ($query) => $query->whereKeyNot($onboarding->id))
|
||
->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED])
|
||
->exists();
|
||
|
||
if ($taken) {
|
||
throw ValidationException::withMessages(['courier_name' => "系统快递名称「{$courierName}」已被其他入驻申请使用。"]);
|
||
}
|
||
}
|
||
|
||
private function createQuoteVersion(CourierOnboarding $onboarding, int $userId): ?CourierQuoteVersion
|
||
{
|
||
$rows = $onboarding->quoteRowList();
|
||
if ($rows === [] || ! Schema::hasTable('business_courier_quote_versions') || ! Schema::hasTable('business_courier_quote_changes')) {
|
||
return null;
|
||
}
|
||
|
||
$effectiveDate = $onboarding->quote_effective_date ?? today();
|
||
$sheetName = $onboarding->courier_name . '报价';
|
||
$quoteType = '新快递入驻报价';
|
||
$values = $this->quoteMatrix($onboarding, $rows, $effectiveDate);
|
||
|
||
$change = CourierQuoteChange::create([
|
||
'company_id' => company_id(),
|
||
'number' => $this->nextChangeNumber(),
|
||
'source_quote_id' => 'RZ-' . $onboarding->id,
|
||
'source_sheet_name' => $sheetName,
|
||
'carrier_name' => $onboarding->courier_name,
|
||
'quote_type' => $quoteType,
|
||
'change_type' => '新快递入驻',
|
||
'status' => CourierQuoteChange::STATUS_APPROVED,
|
||
'effective_date' => $effectiveDate,
|
||
'is_retrospective' => $effectiveDate->copy()->startOfDay()->lt(today()),
|
||
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
||
'summary' => "快递入驻 {$onboarding->number} 首版报价({$onboarding->company_full_name})。",
|
||
'quote_payload' => Schema::hasColumn('business_courier_quote_changes', 'quote_payload') ? $this->quotePayload($onboarding) : null,
|
||
'created_by' => (int) ($onboarding->first_reviewed_by ?: $userId),
|
||
'submitted_by' => (int) ($onboarding->first_reviewed_by ?: $userId),
|
||
'submitted_at' => $onboarding->first_reviewed_at ?? now(),
|
||
'approved_by' => $userId,
|
||
'approved_at' => now(),
|
||
]);
|
||
|
||
$importedCount = collect(app(QuoteManagementSnapshot::class)->load()['quotes'] ?? [])
|
||
->whereNull('source_change_id')
|
||
->count();
|
||
$version = CourierQuoteVersion::create([
|
||
'company_id' => company_id(),
|
||
'version_code' => 'PENDING-' . $change->id,
|
||
'source_quote_id' => $change->source_quote_id,
|
||
'source_change_id' => $change->id,
|
||
'sheet_name' => $sheetName,
|
||
'company_name' => $onboarding->courier_name,
|
||
'carrier_name' => $onboarding->courier_name,
|
||
'quote_type' => $quoteType,
|
||
'effective_date' => $effectiveDate,
|
||
'source_range' => null,
|
||
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||
'formulas_json' => json_encode([], JSON_THROW_ON_ERROR),
|
||
'status' => 'approved',
|
||
'signature_status' => '未签署',
|
||
'created_by' => $change->created_by,
|
||
'approved_by' => $userId,
|
||
]);
|
||
$version->update(['version_code' => sprintf('QB-%03d', $importedCount + $version->id)]);
|
||
$change->update(['new_quote_version_id' => $version->id]);
|
||
|
||
CourierQuoteChangeEvent::create([
|
||
'company_id' => company_id(),
|
||
'quote_change_id' => $change->id,
|
||
'event_type' => 'approved',
|
||
'from_status' => null,
|
||
'to_status' => CourierQuoteChange::STATUS_APPROVED,
|
||
'note' => "由快递入驻 {$onboarding->number} 财务复审生成。",
|
||
'created_by' => $userId,
|
||
]);
|
||
|
||
return $version;
|
||
}
|
||
|
||
/**
|
||
* 写成与报价单相同的二维表,报价管理和报价确认单可以直接展示。
|
||
*
|
||
* @param list<array{region:string,first_weight:?float,first_price:?float,rate:?float}> $rows
|
||
* @return list<list<string|int|float|null>>
|
||
*/
|
||
public function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array
|
||
{
|
||
$columns = $onboarding->quoteColumns();
|
||
$width = count($columns) + 1;
|
||
$line = static fn (string $text): array => array_pad([$text], $width, null);
|
||
|
||
$matrix = [
|
||
$line($onboarding->courier_name . ' 报价'),
|
||
$line('执行时间:' . $effectiveDate->format('Y.m.d')),
|
||
];
|
||
|
||
$matrix[] = ['省份', ...array_column($columns, 'label')];
|
||
foreach ($rows as $row) {
|
||
$matrix[] = [$row['region'], ...CourierQuoteStructure::rowValues($row, $columns)];
|
||
}
|
||
|
||
// 规则说明放在价格表下方,避免报价确认单抬头的「面单费」误读说明文字。
|
||
foreach ($onboarding->quoteRuleLines() as $rule) {
|
||
$matrix[] = $line($rule);
|
||
}
|
||
|
||
if (filled($onboarding->quote_note)) {
|
||
$matrix[] = $line('备注:' . $onboarding->quote_note);
|
||
}
|
||
$matrix[] = $line('适用快递:' . $onboarding->courier_name);
|
||
|
||
return $matrix;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $allowed
|
||
*/
|
||
private function assertStatus(CourierOnboarding $onboarding, array $allowed, string $message): void
|
||
{
|
||
if (! in_array($onboarding->status, $allowed, true)) {
|
||
throw ValidationException::withMessages(['status' => $message]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $extra
|
||
*/
|
||
private function transition(CourierOnboarding $onboarding, string $to, string $event, ?string $note, int $userId, array $extra = []): CourierOnboarding
|
||
{
|
||
$from = $onboarding->status;
|
||
$onboarding->update(['status' => $to, 'updated_by' => $userId] + $extra);
|
||
$this->log($onboarding, $event, $from, $to, $note, 'user', $userId);
|
||
|
||
return $onboarding;
|
||
}
|
||
|
||
private function log(CourierOnboarding $onboarding, string $event, ?string $from, ?string $to, ?string $note, string $actorType, ?int $actorId = null): void
|
||
{
|
||
CourierOnboardingEvent::create([
|
||
'company_id' => $onboarding->company_id,
|
||
'onboarding_id' => $onboarding->id,
|
||
'event_type' => $event,
|
||
'from_status' => $from,
|
||
'to_status' => $to,
|
||
'note' => filled($note) ? $note : null,
|
||
'actor_type' => $actorType,
|
||
'actor_id' => $actorId,
|
||
]);
|
||
}
|
||
|
||
private function nextNumber(): string
|
||
{
|
||
$prefix = 'RZ-' . now()->format('Ymd') . '-';
|
||
$last = CourierOnboarding::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number');
|
||
$sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1;
|
||
|
||
return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
||
}
|
||
|
||
private function nextChangeNumber(): string
|
||
{
|
||
$prefix = 'BG-' . now()->format('Ymd') . '-';
|
||
$last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number');
|
||
$sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1;
|
||
|
||
return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
||
}
|
||
|
||
private function newToken(): string
|
||
{
|
||
do {
|
||
$token = Str::random(40);
|
||
} while (CourierOnboarding::allCompanies()->withTrashed()->where('token', $token)->exists());
|
||
|
||
return $token;
|
||
}
|
||
}
|