CaiWuXiTong/app/Models/Business/CourierOnboarding.php
limeng 8ba7ac8327 feat(快递入驻/快递资料): 生成二维码时必填快递资料并对齐到快递资料;去掉「发起报价变更」按钮;自定义3 改名扣款方式
- 生成入驻二维码新增 11 个必填项(快递方看不到,放在入驻流程下方单独一块):快递类型、快递合作方式、快递属性、扣款方式、单号类型、快递邮箱、
  是否需要核对物流点、快递发货方式、是否已有工作平台账号、快递网点代码、网点所在地区;入驻列表、入驻详情可查看和修改
- 快递资料:扣款方式对齐到「扣款方式」列(工作平台没填时用入驻的),其余字段接在入驻信息列里;网点代码/地区进网点明细;导出同步
- 不再依赖钉钉「新建快递」流程字段,ERP 开通改为手动补录 ERP账户ID
- 快递资料「自定义3」列、筛选、编辑和导出表头改名为「扣款方式」(字段仍是 undefined3)
- 报价管理工具栏、报价历史页去掉「发起报价变更」:它默认拿第一份报价走老的改格子流程,对入驻快递会绕开后台变价;
  变价统一从报价列表每一行的「变价」进入;老报价编辑页新增 …/changes/new 入口,避免套餐限制中间件把 GET …/create 跳到用户页
- 新增迁移 2026_10_14_300000:business_courier_onboardings 加 11 个可空字段

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
2026-10-14 18:31:25 +08:00

341 lines
14 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 => '已作废',
];
/**
* 生成二维码时我方必填的快递资料(快递方看不到),直接对齐到快递资料。
* 字段 => [名称, 可选值(null 为自由填写)]
*/
public const PROFILE_FIELDS = [
'courier_category' => ['快递类型', ['内采快递', '外采快递']],
'cooperation_mode' => ['快递合作方式', ['保丢保损', '保丢不保损', '不保丢不保损']],
'courier_attribute' => ['快递属性', ['简尚', '工厂']],
'deduction_method' => ['扣款方式', ['运费', '损耗+运费', '无']],
'waybill_type' => ['单号类型', ['系统对接', '手填单号']],
'courier_email' => ['快递邮箱', null],
'need_logistics_check' => ['是否需要核对物流点', ['是', '否']],
'shipping_mode' => ['快递发货方式', ['一单一货', '一单多货']],
'workplatform_account' => ['是否已有工作平台账号', ['已有', '新开']],
'network_code' => ['快递网点代码', null],
'network_region' => ['网点所在地区', null],
];
/**
* 校验规则:生成二维码时全部必填;入驻详情里修改时,提交了哪项就校验哪项(不能清空)。
*
* @return array<string,list<string>>
*/
public static function profileRules(bool $onlyPresent = false): array
{
$rules = [];
foreach (self::PROFILE_FIELDS as $field => [$label, $options]) {
$base = $onlyPresent ? ['sometimes', 'required'] : ['required'];
$rules[$field] = [...$base, ...match (true) {
$options !== null => ['in:' . implode(',', $options)],
$field === 'courier_email' => ['email', 'max:191'],
default => ['string', 'max:' . ($field === 'network_code' ? 64 : 191)],
}];
}
return $rules;
}
/** @return array<string,string> 必填提示 */
public static function profileMessages(): array
{
$messages = [];
foreach (self::PROFILE_FIELDS as $field => [$label, $options]) {
$messages[$field . '.required'] = ($options !== null ? '请选择' : '请填写') . $label . '。';
}
return $messages;
}
/** @return array<string,string> 字段名称,用于校验提示 */
public static function profileAttributes(): array
{
return array_map(static fn (array $config): string => $config[0], self::PROFILE_FIELDS);
}
/** 快递方可以继续编辑资料的状态。 */
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',
'courier_category', 'cooperation_mode', 'courier_attribute', 'deduction_method', 'waybill_type', 'courier_email',
'need_logistics_check', 'shipping_mode', 'workplatform_account', 'network_code', 'network_region',
'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),
));
}
}