快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
284 lines
11 KiB
PHP
284 lines
11 KiB
PHP
<?php
|
||
|
||
namespace App\Models\Business;
|
||
|
||
use App\Abstracts\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
||
/**
|
||
* 快递入驻申请:快递方扫码填写资料 → 对接人初审 → 财务复审(生成首版报价)→ 协议签署 → 开通。
|
||
*/
|
||
class CourierOnboarding extends Model
|
||
{
|
||
public const STATUS_INVITED = 'invited';
|
||
public const STATUS_SUBMITTED = 'submitted';
|
||
public const STATUS_RETURNED = 'returned';
|
||
public const STATUS_FIRST_APPROVED = 'first_approved';
|
||
public const STATUS_APPROVED = 'approved';
|
||
public const STATUS_ACTIVATED = 'activated';
|
||
public const STATUS_REJECTED = 'rejected';
|
||
public const STATUS_CANCELLED = 'cancelled';
|
||
|
||
public const STATUS_LABELS = [
|
||
self::STATUS_INVITED => '待快递填写',
|
||
self::STATUS_SUBMITTED => '待初审',
|
||
self::STATUS_RETURNED => '已退回补件',
|
||
self::STATUS_FIRST_APPROVED => '待财务复审',
|
||
self::STATUS_APPROVED => '待签署开通',
|
||
self::STATUS_ACTIVATED => '已开通',
|
||
self::STATUS_REJECTED => '不予合作',
|
||
self::STATUS_CANCELLED => '已作废',
|
||
];
|
||
|
||
/** 快递方可以继续编辑资料的状态。 */
|
||
public const COURIER_EDITABLE = [self::STATUS_INVITED, self::STATUS_RETURNED];
|
||
|
||
public const ENTITY_COMPANY = 'company';
|
||
public const ENTITY_PERSON = 'person';
|
||
public const ENTITY_TYPES = [
|
||
self::ENTITY_COMPANY => '公司 / 个体户',
|
||
self::ENTITY_PERSON => '个人',
|
||
];
|
||
|
||
public const SERVICE_TYPES = ['快递', '快运', '大件专线', '其他'];
|
||
public const INVOICE_TYPES = ['增值税专用发票', '增值税普通发票', '不开票'];
|
||
public const SETTLEMENT_CYCLES = ['月结', '半月结', '周结', '其他'];
|
||
public const QUOTE_MODES = \App\Support\CourierQuoteStructure::MODES;
|
||
public const VOLUMETRIC_RULES = ['不计泡', '计泡'];
|
||
|
||
public const PROVINCES = [
|
||
'江苏', '浙江', '上海', '安徽', '北京', '天津', '河北', '山西', '山东', '河南',
|
||
'湖北', '湖南', '江西', '福建', '广东', '广西', '海南', '重庆', '四川', '贵州',
|
||
'云南', '陕西', '甘肃', '宁夏', '青海', '辽宁', '吉林', '黑龙江', '内蒙古', '新疆', '西藏',
|
||
];
|
||
|
||
protected $table = 'business_courier_onboardings';
|
||
|
||
protected $fillable = [
|
||
'company_id', 'number', 'token', 'status', 'invite_label', 'expires_at',
|
||
'entity_type', 'id_number', 'company_full_name', 'unified_social_credit_code', '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', 'bank_account_number', 'invoice_type', 'tax_rate', 'settlement_cycle',
|
||
'private_account_name', 'private_bank_name', 'private_account_number',
|
||
'workplatform_expcode', 'erp_account_id', 'erp_opened_at', 'erp_opened_source', 'dingtalk_instance_id',
|
||
'quote_mode', 'volumetric_rule', 'volumetric_divisor', 'quote_effective_date', 'quote_structure', 'quote_rows',
|
||
'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'remark',
|
||
'courier_name', 'reconciler', 'return_reason', 'submitted_at',
|
||
'first_reviewed_by', 'first_reviewed_at', 'final_reviewed_by', 'final_reviewed_at',
|
||
'quote_change_id', 'quote_version_id', 'activated_by', 'activated_at', 'created_by', 'updated_by',
|
||
];
|
||
|
||
protected $casts = [
|
||
'company_id' => 'integer',
|
||
'expires_at' => 'datetime',
|
||
'submitted_at' => 'datetime',
|
||
'first_reviewed_at' => 'datetime',
|
||
'final_reviewed_at' => 'datetime',
|
||
'activated_at' => 'datetime',
|
||
'quote_effective_date' => 'date',
|
||
'quote_rows' => 'array',
|
||
'quote_structure' => 'array',
|
||
'min_weight' => 'float',
|
||
'per_ticket_surcharge' => 'float',
|
||
'bank_account_number' => 'encrypted',
|
||
'private_account_number' => 'encrypted',
|
||
'erp_opened_at' => 'datetime',
|
||
'id_number' => 'encrypted',
|
||
'tax_rate' => 'float',
|
||
'volumetric_divisor' => 'integer',
|
||
'first_reviewed_by' => 'integer',
|
||
'final_reviewed_by' => 'integer',
|
||
'activated_by' => 'integer',
|
||
'created_by' => 'integer',
|
||
'deleted_at' => 'datetime',
|
||
];
|
||
|
||
protected $hidden = ['token', 'bank_account_number', 'private_account_number', 'id_number'];
|
||
|
||
public function files(): HasMany
|
||
{
|
||
return $this->hasMany(CourierOnboardingFile::class, 'onboarding_id')->orderBy('id');
|
||
}
|
||
|
||
public function events(): HasMany
|
||
{
|
||
return $this->hasMany(CourierOnboardingEvent::class, 'onboarding_id')->orderBy('id');
|
||
}
|
||
|
||
public function quoteVersion(): BelongsTo
|
||
{
|
||
return $this->belongsTo(CourierQuoteVersion::class, 'quote_version_id');
|
||
}
|
||
|
||
public function isPerson(): bool
|
||
{
|
||
return $this->entity_type === self::ENTITY_PERSON;
|
||
}
|
||
|
||
public function entityLabel(): string
|
||
{
|
||
return $this->isPerson() ? '个人' : '公司';
|
||
}
|
||
|
||
/** 主体名称:公司为公司全称,个人为姓名。 */
|
||
public function subjectLabel(): string
|
||
{
|
||
return $this->isPerson() ? '姓名' : '公司全称';
|
||
}
|
||
|
||
public function maskedIdNumber(): string
|
||
{
|
||
$value = (string) ($this->id_number ?? '');
|
||
|
||
return strlen($value) < 8 ? $value : substr($value, 0, 3) . str_repeat('*', strlen($value) - 7) . substr($value, -4);
|
||
}
|
||
|
||
public function statusLabel(): string
|
||
{
|
||
return self::STATUS_LABELS[$this->status] ?? $this->status;
|
||
}
|
||
|
||
public function courierCanEdit(): bool
|
||
{
|
||
return in_array($this->status, self::COURIER_EDITABLE, true) && ! $this->linkExpired();
|
||
}
|
||
|
||
public function linkExpired(): bool
|
||
{
|
||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||
}
|
||
|
||
public function displayName(): string
|
||
{
|
||
foreach ([$this->courier_name, $this->network_name, $this->company_full_name, $this->invite_label] as $name) {
|
||
if (filled($name)) {
|
||
return (string) $name;
|
||
}
|
||
}
|
||
|
||
return $this->number;
|
||
}
|
||
|
||
/** e签宝通知方式:1 短信(手机号账号),2 邮件(邮箱账号)。 */
|
||
public function esignNoticeType(): string
|
||
{
|
||
return str_contains((string) $this->esign_account, '@') ? '2' : '1';
|
||
}
|
||
|
||
public function quoteSignature(): ?CourierQuoteSignature
|
||
{
|
||
return $this->quoteVersion?->signature;
|
||
}
|
||
|
||
public function quoteModeLabel(): string
|
||
{
|
||
return \App\Support\CourierQuoteStructure::MODES[$this->quote_mode] ?? '';
|
||
}
|
||
|
||
/**
|
||
* @return array{mode:string,flat_bands:list<float>,then_mode:string,first_weight:?float,rate_tiers:list<float>}
|
||
*/
|
||
public function quoteStructure(): array
|
||
{
|
||
$structure = is_array($this->quote_structure) ? $this->quote_structure : [];
|
||
|
||
return [
|
||
'mode' => (string) ($structure['mode'] ?? $this->quote_mode ?? 'waybill_rate'),
|
||
'flat_bands' => array_values($structure['flat_bands'] ?? []),
|
||
'then_mode' => (string) ($structure['then_mode'] ?? ''),
|
||
'first_weight' => isset($structure['first_weight']) ? (float) $structure['first_weight'] : null,
|
||
'rate_tiers' => array_values($structure['rate_tiers'] ?? []),
|
||
];
|
||
}
|
||
|
||
/** @return list<array{key:string,label:string}> */
|
||
public function quoteColumns(): array
|
||
{
|
||
return \App\Support\CourierQuoteStructure::columns($this->quoteStructure());
|
||
}
|
||
|
||
/** 附加计费规则的文字(计泡、起算重量、取整、每票加收)。 */
|
||
public function quoteRuleLines(): array
|
||
{
|
||
$kg = static fn ($value): string => \App\Support\CourierQuoteStructure::kg((float) $value);
|
||
|
||
return array_values(array_filter([
|
||
'计费方式:' . \App\Support\CourierQuoteStructure::describe($this->quoteStructure()),
|
||
$this->volumetric_rule === '计泡' ? '计泡:长*宽*高/' . ($this->volumetric_divisor ?: 6000) . ',取实重与体积重较大值' : ($this->volumetric_rule ? '不计泡' : null),
|
||
$this->min_weight ? '起算重量:不足' . $kg($this->min_weight) . 'kg按' . $kg($this->min_weight) . 'kg计' : null,
|
||
$this->weight_rounding && $this->weight_rounding !== 'none' ? '重量取整:' . (\App\Support\CourierQuoteStructure::ROUNDING[$this->weight_rounding] ?? $this->weight_rounding) : null,
|
||
$this->per_ticket_surcharge ? '每票加收:' . $kg($this->per_ticket_surcharge) . '元' : null,
|
||
]));
|
||
}
|
||
|
||
/** ERP 开通信息来源:钉钉「新建快递」流程同步 / 手动补录 / 工作平台老数据。 */
|
||
public const ERP_SOURCES = [
|
||
'dingtalk' => '钉钉流程同步',
|
||
'manual' => '手动补录',
|
||
'workplatform' => '工作平台',
|
||
];
|
||
|
||
/** 开通后等 ERP 专员在钉钉发起「新建快递」流程、开通发货系统账号。 */
|
||
public function erpPending(): bool
|
||
{
|
||
return $this->status === self::STATUS_ACTIVATED && $this->erp_opened_at === null;
|
||
}
|
||
|
||
public function erpSourceLabel(): string
|
||
{
|
||
return self::ERP_SOURCES[$this->erp_opened_source] ?? '';
|
||
}
|
||
|
||
/** 户名比对:去掉空格,全角括号转半角,英文统一大写。 */
|
||
public static function normalizeName(?string $name): string
|
||
{
|
||
$name = str_replace(['(', ')', ' '], ['(', ')', ''], (string) $name);
|
||
|
||
return mb_strtoupper((string) preg_replace('/\s+/u', '', $name));
|
||
}
|
||
|
||
/** 对私收款人应为谁:公司入驻是法定代表人,个人入驻是本人。 */
|
||
public function expectedPrivatePayee(): ?string
|
||
{
|
||
return $this->isPerson() ? $this->company_full_name : $this->legal_representative;
|
||
}
|
||
|
||
/**
|
||
* 打款账户核对结果:对公户名 = 营业执照公司全称,对私收款人 = 法定代表人(个人为本人)。
|
||
*
|
||
* @return array{corporate:?bool,private:?bool}
|
||
*/
|
||
public function accountChecks(): array
|
||
{
|
||
$same = static fn (?string $a, ?string $b): ?bool => blank($a) || blank($b) ? null : self::normalizeName($a) === self::normalizeName($b);
|
||
|
||
return [
|
||
'corporate' => $this->isPerson() ? null : $same($this->bank_account_name, $this->company_full_name),
|
||
'private' => $same($this->private_account_name, $this->expectedPrivatePayee()),
|
||
];
|
||
}
|
||
|
||
public function maskedBankAccount(): string
|
||
{
|
||
$value = (string) ($this->bank_account_number ?? '');
|
||
if (mb_strlen($value) <= 8) {
|
||
return $value;
|
||
}
|
||
|
||
return mb_substr($value, 0, 4) . str_repeat('*', max(mb_strlen($value) - 8, 4)) . mb_substr($value, -4);
|
||
}
|
||
|
||
/**
|
||
* @return list<array{region:string,flat:list<?float>,base:?float,rates:list<?float>}>
|
||
*/
|
||
public function quoteRowList(): array
|
||
{
|
||
return array_values(array_filter(
|
||
is_array($this->quote_rows) ? $this->quote_rows : [],
|
||
static fn ($row): bool => is_array($row) && filled($row['region'] ?? null),
|
||
));
|
||
}
|
||
}
|