快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
344 lines
15 KiB
PHP
344 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use App\Models\Business\CourierAgreementSignature;
|
||
use App\Models\Business\CourierOnboarding;
|
||
use App\Models\Business\CourierPaymentAccount;
|
||
use App\Models\Business\CourierProfile;
|
||
use App\Models\Business\CourierProfileEvent;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 快递资料在财务系统这一侧的数据:编辑覆盖值、打款信息(入驻对公/对私 + 新增/变更记录)、操作日志。
|
||
* 工作平台镜像只读,这里的数据都存在财务系统自己的表里。
|
||
*/
|
||
final class CourierProfileData
|
||
{
|
||
/** @return array<string,array<string,mixed>> 快递代码 => 覆盖值 */
|
||
public function overrides(array $codes): array
|
||
{
|
||
$codes = array_values(array_unique(array_filter(array_map('strval', $codes), 'filled')));
|
||
if ($codes === [] || ! Schema::hasTable('business_courier_profiles')) {
|
||
return [];
|
||
}
|
||
|
||
return CourierProfile::query()->whereIn('expcode', $codes)->get()
|
||
->mapWithKeys(static fn (CourierProfile $profile): array => [(string) $profile->expcode => (array) $profile->overrides])
|
||
->all();
|
||
}
|
||
|
||
/** 把覆盖值盖到快递资料行上,`_overridden` 记下改过的字段。 */
|
||
public function apply(object $row, array $overrides): object
|
||
{
|
||
$row = clone $row;
|
||
$changed = [];
|
||
foreach ($overrides as $field => $value) {
|
||
if (array_key_exists($field, CourierProfile::EDITABLE)) {
|
||
$row->{$field} = $value;
|
||
$changed[] = $field;
|
||
}
|
||
}
|
||
$row->_overridden = $changed;
|
||
|
||
return $row;
|
||
}
|
||
|
||
/** 财务系统入驻(还没有工作平台记录)的快递,按快递资料的列铺成一行。 */
|
||
public function onboardingRow(array $info): object
|
||
{
|
||
/** @var CourierOnboarding $onboarding */
|
||
$onboarding = $info['onboarding'];
|
||
|
||
return (object) [
|
||
'Id' => null,
|
||
'expcode' => $onboarding->workplatform_expcode,
|
||
'expname' => $onboarding->courier_name,
|
||
'paymethod' => $onboarding->settlement_cycle,
|
||
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
|
||
'kuaiyunsgn' => $onboarding->service_type === '快运' ? '1' : '0',
|
||
'tradebtime' => $info['contract_start'] ?? null,
|
||
'tradeetime' => $info['contract_end'] ?? null,
|
||
'linker' => $onboarding->contact_name,
|
||
'linkmobile' => $onboarding->contact_phone,
|
||
'dzqzjbr' => $onboarding->esign_account,
|
||
'modified' => $onboarding->updated_at?->toDateTimeString(),
|
||
'_onboarding_only' => true,
|
||
'_info' => $info,
|
||
];
|
||
}
|
||
|
||
/** 按快递公司代码取当前显示的一行主信息(工作平台镜像优先,没有则用财务系统入驻),已盖上编辑覆盖值。 */
|
||
public function currentRow(string $code): ?object
|
||
{
|
||
$mirror = app(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class);
|
||
$overview = app(CourierOverviewData::class);
|
||
$row = null;
|
||
try {
|
||
if ($mirror->available() && $mirror->ready()) {
|
||
$found = $mirror->paginate(array_merge(
|
||
array_fill_keys(['expname', 'undefined1', 'undefined2', 'undefined3', 'tradebtime_from', 'tradebtime_to', 'status'], ''),
|
||
['expcode' => $code],
|
||
), 1, 1)['data'][0] ?? null;
|
||
if ($found !== null) {
|
||
$row = clone $found;
|
||
$row->_info = $overview->infoFor($code, $found->expname ?? null);
|
||
}
|
||
}
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
}
|
||
if ($row === null && ($info = $overview->infoFor($code, null)) !== null) {
|
||
$row = $this->onboardingRow($info);
|
||
}
|
||
|
||
return $row !== null ? $this->apply($row, $this->overrides([$code])[$code] ?? []) : null;
|
||
}
|
||
|
||
/**
|
||
* 协议签署页预填:打款信息新增 → 收款账户证明;变更 → 收款账户变更。
|
||
*
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
public static function agreementPrefill(mixed $id): ?array
|
||
{
|
||
if (! is_numeric($id) || ! Schema::hasTable('business_courier_payment_accounts')) {
|
||
return null;
|
||
}
|
||
$record = CourierPaymentAccount::query()->find((int) $id);
|
||
if ($record === null) {
|
||
return null;
|
||
}
|
||
$onboarding = app(CourierOverviewData::class)->infoFor($record->expcode, $record->courier_name)['onboarding'] ?? null;
|
||
$fields = $record->action === CourierPaymentAccount::ACTION_CHANGE
|
||
? [
|
||
'account_change_date' => $record->change_date?->toDateString(),
|
||
'old_account_name' => $record->old_account_name,
|
||
'old_account_number' => $record->old_account_number,
|
||
'old_account_stop_date' => $record->old_stop_date?->toDateString(),
|
||
'new_account_effective_date' => $record->effective_date?->toDateString(),
|
||
'new_account_name' => $record->account_name,
|
||
'new_bank_name' => $record->bank_name,
|
||
'new_bank_account' => $record->account_number,
|
||
]
|
||
: [
|
||
'account_proof_effective_date' => $record->effective_date?->toDateString(),
|
||
'account_proof_account_name' => $record->account_name,
|
||
'account_proof_bank_name' => $record->bank_name,
|
||
'account_proof_bank_account' => $record->account_number,
|
||
];
|
||
if ($onboarding !== null && filled($onboarding->company_full_name)) {
|
||
$fields['counterparty_organization_name'] = $onboarding->company_full_name;
|
||
}
|
||
|
||
return [
|
||
'id' => $record->id,
|
||
'action' => $record->actionLabel(),
|
||
'courier_name' => $record->courier_name,
|
||
'expcode' => $record->expcode,
|
||
'document_type' => $record->documentType(),
|
||
'signer_type' => $onboarding !== null && $onboarding->entity_type === CourierOnboarding::ENTITY_PERSON ? 'person' : 'organization',
|
||
'esign_account' => $onboarding?->esign_account,
|
||
'fields' => array_filter($fields, static fn ($value): bool => filled($value)),
|
||
];
|
||
}
|
||
|
||
/** 保存编辑:只记和当前显示值不同的字段,并写一条操作日志。 */
|
||
public function update(string $expcode, ?string $courierName, object $current, array $values, int $userId): int
|
||
{
|
||
$profile = CourierProfile::query()->firstOrNew(['company_id' => company_id(), 'expcode' => $expcode]);
|
||
$overrides = (array) ($profile->overrides ?? []);
|
||
$changes = [];
|
||
foreach (CourierProfile::EDITABLE as $field => $label) {
|
||
if (! array_key_exists($field, $values)) {
|
||
continue;
|
||
}
|
||
$new = $this->normalize($field, $values[$field]);
|
||
$old = $this->normalize($field, $current->{$field} ?? null);
|
||
if ($new === $old) {
|
||
continue;
|
||
}
|
||
$overrides[$field] = $new;
|
||
$changes[$field] = ['label' => $label, 'old' => $old, 'new' => $new];
|
||
}
|
||
if ($changes === []) {
|
||
return 0;
|
||
}
|
||
|
||
$profile->fill([
|
||
'courier_name' => $courierName ?: $profile->courier_name,
|
||
'overrides' => $overrides,
|
||
'updated_by' => $userId,
|
||
]);
|
||
$profile->created_by ??= $userId;
|
||
$profile->save();
|
||
|
||
$this->log($expcode, 'edit', '修改 ' . implode('、', array_column($changes, 'label')), $changes, $userId);
|
||
|
||
return count($changes);
|
||
}
|
||
|
||
public function log(string $expcode, string $action, string $summary, ?array $changes, int $userId): void
|
||
{
|
||
CourierProfileEvent::query()->create([
|
||
'company_id' => company_id(),
|
||
'expcode' => $expcode,
|
||
'action' => $action,
|
||
'summary' => mb_substr($summary, 0, 500),
|
||
'changes' => $changes,
|
||
'created_by' => $userId,
|
||
]);
|
||
}
|
||
|
||
/** @return Collection<int,CourierProfileEvent> */
|
||
public function events(?string $expcode = null, int $limit = 100): Collection
|
||
{
|
||
if (! Schema::hasTable('business_courier_profile_events')) {
|
||
return collect();
|
||
}
|
||
|
||
return CourierProfileEvent::query()
|
||
->with('creator')
|
||
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
|
||
->latest('id')
|
||
->limit($limit)
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* 财务系统这一侧的打款信息(完整显示):入驻时填的对公/对私账户 + 打款信息新增/变更记录。
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public function financeAccounts(?string $expcode = null, ?CourierOnboarding $onboarding = null): array
|
||
{
|
||
$rows = [];
|
||
if (Schema::hasTable('business_courier_payment_accounts')) {
|
||
$records = CourierPaymentAccount::query()
|
||
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
|
||
->latest('id')
|
||
->get();
|
||
foreach ($records as $record) {
|
||
$status = $this->paymentStatus($record);
|
||
$rows[] = [
|
||
'source' => $record->actionLabel(),
|
||
'expcode' => $record->expcode,
|
||
'courier_name' => $record->courier_name,
|
||
'type' => $record->account_type,
|
||
'name' => $record->account_name,
|
||
'bank' => $record->bank_name,
|
||
'number' => $record->account_number,
|
||
'effective_date' => $record->effective_date?->toDateString(),
|
||
'old' => $record->action === CourierPaymentAccount::ACTION_CHANGE
|
||
? trim($record->old_account_name . ' ' . $record->old_account_number) . ($record->old_stop_date ? '(' . $record->old_stop_date->toDateString() . ' 停用)' : '')
|
||
: null,
|
||
'status' => $status['label'],
|
||
'tone' => $status['tone'],
|
||
'erp_account_id' => null,
|
||
'created_at' => $record->created_at?->toDateTimeString(),
|
||
'record' => $record,
|
||
];
|
||
}
|
||
}
|
||
|
||
$onboardings = $onboarding !== null
|
||
? collect([$onboarding])
|
||
: (Schema::hasTable('business_courier_onboardings')
|
||
? CourierOnboarding::query()
|
||
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
|
||
->when(filled($expcode), static fn ($query) => $query->where('workplatform_expcode', $expcode))
|
||
->latest('id')
|
||
->get()
|
||
: collect());
|
||
foreach ($onboardings as $item) {
|
||
$checks = $item->accountChecks();
|
||
foreach ([
|
||
['对公', $item->bank_account_name, $item->bank_name, $item->bank_account_number, $checks['corporate'] ?? null],
|
||
['对私', $item->private_account_name, $item->private_bank_name, $item->private_account_number, $checks['private'] ?? null],
|
||
] as [$type, $name, $bank, $number, $check]) {
|
||
if (! filled($name) && ! filled($number)) {
|
||
continue;
|
||
}
|
||
$rows[] = [
|
||
'source' => '快递入驻 ' . $item->number,
|
||
'expcode' => $item->workplatform_expcode,
|
||
'courier_name' => $item->courier_name,
|
||
'type' => $type,
|
||
'name' => $name,
|
||
'bank' => $bank,
|
||
'number' => $number,
|
||
'effective_date' => $item->activated_at?->toDateString(),
|
||
'old' => null,
|
||
'status' => $check === false ? ($type === '对公' ? '户名与营业执照主体不一致' : '收款人与法定代表人不一致') : $item->statusLabel(),
|
||
'tone' => $check === false ? 'is-bad' : ($item->status === CourierOnboarding::STATUS_ACTIVATED ? 'is-ok' : 'is-warn'),
|
||
'erp_account_id' => $item->erp_account_id,
|
||
'created_at' => $item->created_at?->toDateTimeString(),
|
||
'onboarding' => $item,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 打款信息新增/变更的状态:按协议签署里同一快递、同类文件、同一新账号的签署任务推导。
|
||
*
|
||
* @return array{label:string,tone:string,signature:?CourierAgreementSignature}
|
||
*/
|
||
public function paymentStatus(CourierPaymentAccount $record): array
|
||
{
|
||
$signature = null;
|
||
if (Schema::hasTable('business_courier_agreement_signatures') && filled($record->courier_name)) {
|
||
$field = $record->action === CourierPaymentAccount::ACTION_CHANGE ? 'new_bank_account' : 'account_proof_bank_account';
|
||
$number = $this->digits($record->account_number);
|
||
$signature = CourierAgreementSignature::query()
|
||
->where('courier_name', $record->courier_name)
|
||
->where('document_type', $record->documentType())
|
||
->where('created_at', '>=', $record->created_at)
|
||
->latest('id')
|
||
->get()
|
||
->first(function (CourierAgreementSignature $signature) use ($field, $number): bool {
|
||
try {
|
||
return $this->digits((string) data_get($signature->payload, $field)) === $number;
|
||
} catch (Throwable) {
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
|
||
return match (true) {
|
||
$signature === null => ['label' => '待推送协议', 'tone' => 'is-warn', 'signature' => null],
|
||
in_array($signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true) => ['label' => '已生效', 'tone' => 'is-ok', 'signature' => $signature],
|
||
in_array($signature->status, [CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED], true) => ['label' => '签署未完成,需重新推送', 'tone' => 'is-bad', 'signature' => $signature],
|
||
default => ['label' => '签署中', 'tone' => 'is-warn', 'signature' => $signature],
|
||
};
|
||
}
|
||
|
||
private function digits(?string $value): string
|
||
{
|
||
return (string) preg_replace('/[\s\-]+/u', '', (string) $value);
|
||
}
|
||
|
||
private function normalize(string $field, mixed $value): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
$value = trim((string) $value);
|
||
if ($value === '' || $value === '-') {
|
||
return null;
|
||
}
|
||
if (in_array($field, CourierProfile::BOOLEAN_FIELDS, true)) {
|
||
return in_array(strtolower($value), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
|
||
}
|
||
if (in_array($field, CourierProfile::DATE_FIELDS, true)) {
|
||
return substr($value, 0, 10);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
}
|